diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index d8040f78f..b067a2b4e 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,6 +1,7 @@ { "packages": [ "packages/core", + "packages/sql", "packages/ui", "packages/antd", "packages/mui", diff --git a/.eslintrc.js b/.eslintrc.js index f2881e320..bb3371d9e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,7 +20,7 @@ module.exports = { "eslint:recommended", "plugin:import/recommended", // "plugin:import/typescript", // not needed for JS - "plugin:react/recommended", + // "plugin:react/recommended", // not needed for core // "plugin:@typescript-eslint/eslint-recommended", // not needed for JS ], "globals": { @@ -134,73 +134,97 @@ module.exports = { ] }, "overrides": [ - { - "files": ["packages/tests/**/*"], - "env": { - "mocha": true, - // "jasmine": true, + { + "files": ["packages/sql/**/*"], + "settings": { + "import/resolver": { + "typescript": { + "project": [ + "packages/sql/tsconfig.json", + ], + }, + }, + }, }, - "settings": { - "import/core-modules": [ - "sinon", - "chai", - "mocha" - ], - // "import/resolver": { - // "webpack": { - // "config": "./webpack.config.js" - // } - // }, + + { + "files": ["packages/tests/**/*"], + "env": { + "mocha": true, + // "jasmine": true, + }, + "settings": { + "import/core-modules": [ + "sinon", + "chai", + "mocha" + ], + // "import/resolver": { + // "webpack": { + // "config": "./webpack.config.js" + // } + // }, + }, }, - }, - { - "files": ["packages/sandbox_simple/**/*"], - "parser": "@babel/eslint-parser", - "parserOptions": { - "requireConfigFile": false, - "babelOptions": { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" + + { + "files": ["packages/sandbox_simple/**/*"], + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "babelOptions": { + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ], + }, + "sourceType": "module", + }, + "settings": { + "import/core-modules": [ + "react", + "@react-awesome-query-builder/ui/css/styles.css" ], }, - "sourceType": "module", }, - "settings": { - "import/core-modules": [ - "react", - "@react-awesome-query-builder/ui/css/styles.css" + + { + "files": ["**/*.ts", "**/*.tsx"], + "extends": [ + "eslint:recommended", + "plugin:import/recommended", + "plugin:import/typescript", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" ], + "rules": { + "@typescript-eslint/no-unnecessary-type-assertion": 0, + //todo + "@typescript-eslint/no-unused-vars": 0, + "@typescript-eslint/ban-types": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/unbound-method": 0, + "@typescript-eslint/prefer-regexp-exec": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-floating-promises": 0, + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/no-non-null-asserted-optional-chain": 0, + } + }, + { + "files": ["**/*.jsx", "**/*.tsx"], + "extends": [ + "plugin:react/recommended", + ], + "rules": { + //todo + "react/display-name": 0, + "react/prop-types": 0, + } }, - }, - - { - "files": ["**/*.ts", "**/*.tsx"], - "extends": [ - "eslint:recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "plugin:react/recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking" - ], - "rules": { - "@typescript-eslint/no-unnecessary-type-assertion": 0, - //todo - "@typescript-eslint/no-unused-vars": 0, - "@typescript-eslint/ban-types": 0, - "@typescript-eslint/explicit-module-boundary-types": 0, - "@typescript-eslint/no-explicit-any": 0, - "@typescript-eslint/no-empty-interface": 0, - "@typescript-eslint/unbound-method": 0, - "@typescript-eslint/prefer-regexp-exec": 0, - "@typescript-eslint/no-empty-function": 0, - "@typescript-eslint/ban-ts-comment": 0, - "@typescript-eslint/no-floating-promises": 0, - "@typescript-eslint/no-non-null-assertion": 0, - "@typescript-eslint/no-non-null-asserted-optional-chain": 0, - } - }, ], } diff --git a/.gitignore b/.gitignore index 4e9afa50b..d1da64966 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ yarn.lock packages/*/cjs/ packages/*/esm/ packages/*/css/ +packages/*/types/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db22fef6..9ad25c5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +- 6.6.5 + - Support import fom SQL (`SqlUtils.loadFromSql`) (PR #1103) (issue #911, #593) + - Fixed type issue with SerializedFunction (PR #1103) (issue #1024) - 6.6.4 - Support groups inside rule-group (PR #1111) (issue #1108) - Fixed cardinality issue (PR #1136) (issue #1118) diff --git a/CONFIG.adoc b/CONFIG.adoc index bde53a583..008ca06a0 100644 --- a/CONFIG.adoc +++ b/CONFIG.adoc @@ -19,6 +19,7 @@ endif::[] :renderSwitch: https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/antd/modules/config/index.jsx#L54 :config_ser: https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/sandbox_next/lib/config_ser.js :d_ts: https://github.com/ukrbublik/react-awesome-query-builder/blob/master/packages/core/modules/index.d.ts +:ts-pattern: https://github.com/gvergnaud/ts-pattern = Config format @@ -37,7 +38,7 @@ Optionally you can override some options in basic config or add your own types/w There are functions for building query string: `formatConj`, `formatValue`, `formatOp`, `formatField`, `formatFunc` which are used for `QbUtils.queryString()`. + They have common param `isForDisplay` - false by default, true will be used for {queryString}[`QbUtils.queryString(immutableTree, config, true)`] (see 3rd param true). + Also there are similar `mongoConj`, `mongoFormatOp`, `mongoFormatValue`, `mongoFunc`, `mongoFormatFunc`, `mongoArgsAsObject` for building MongoDb query with `QbUtils.mongodbFormat()`. + -And `sqlFormatConj`, `sqlOp`, `sqlFormatOp`, `sqlFormatValue`, `sqlFormatReverse`, `formatSpelField`, `sqlFunc`, `sqlFormatFunc` for building SQL where query with `QbUtils.sqlFormat()`. + +And `sqlFormatConj`, `sqlOp`, `sqlOps`, `sqlFormatOp`, `sqlFormatValue`, `sqlFormatReverse`, `formatSpelField`, `sqlFunc`, `sqlFormatFunc`, `sqlImport` for building SQL where query with `QbUtils.sqlFormat()`. + And `spelFormatConj`, `spelOp`, `spelFormatOp`, `spelFormatValue`, `spelFormatReverse`, `spelFunc`, `spelFormatFunc` for building query in (https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html)[Spring Expression Language (SpEL)] with `QbUtils.spelFormat()`. + And `jsonLogic` for building http://jsonlogic.com[JsonLogic] with `QbUtils.jsonLogicFormat()`. + @@ -347,7 +348,7 @@ Behaviour settings: |removeEmptyGroupsOnLoad |true |Remove empty groups during initial validation of `value` prop passed to `` |removeInvalidMultiSelectValuesOnLoad |true |Remove values that are not in `listValues` during initial validation of `value` prop passed to ``? + By default `true`, but `false` for AntDesign as can be removed manually -|useConfigCompress |false |Set to `true` if you use `Utils.decompressConfig()` +|useConfigCompress |false |Set to `true` if you use `Utils.ConfigUtils.decompressConfig()` |fieldItemKeysForSearch |`["label", "path", "altLabel", "grouplabel"]` |Keys in field item (see {d_ts}[type] `FieldItem`) available for search. Available keys: "key", "path", "label", "altLabel" (label2), "tooltip", "grouplabel" (label of parent group, for subfields of complex fields) |listKeysForSearch |`["title", "value"]` |Keys in list item (see {d_ts}[type] `ListItem`) available for search. Available keys: "title", "value", "groupTitle" |reverseOperatorsForNot |false |True to convert "!(x == 1)" to "x != 1" on import and export @@ -549,9 +550,11 @@ where `AND` and `OR` - available conjuctions (logical operators). You can add `N `value` - mixed for `cardinality==1` -or- `Array` for `cardinality>2` + `useExpr` - true if resulted expression will be wrapped in https://docs.mongodb.com/manual/reference/operator/query/expr/index.html[`{'$expr': {...}}`] (used only if you compare field with another field or function) (you need to use aggregation operators in this case, like https://docs.mongodb.com/manual/reference/operator/aggregation/eq/[$eq (aggregation)] instead of https://docs.mongodb.com/manual/reference/operator/query/eq/[$eq]) |sqlOp |+ for SQL format | |Operator name in SQL +|sqlOps |- for SQL format | |Operator names in SQL |sqlFormatOp |- for SQL format | |Function for advanced formatting SQL WHERE query when just `sqlOp` is not enough. + `(string field, string op, mixed value, string valueSrc, string valueType, Object opDef, Object operatorOptions, Object fieldDef) => string` + `value` - mixed for `cardinality==1` -or- `Array` for `cardinality>2` +|sqlImport |- for SQL format | |Function to convert given raw SQL value (not string, but object got from `node-sql-parser`) to `{ children: Array }`. If given expression can't be parsed into current operator, throw an error. |spelOp |+ for SpEL format | |Operator name in SpEL |spelFormatOp |- for SpEL format | |Function for advanced formatting query in SpEL when just `spelOp` is not enough. + `(string field, string op, mixed value, string valueSrc, string valueType, Object opDef, Object operatorOptions, Object fieldDef) => string` + @@ -768,6 +771,7 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be |sqlFunc |- for SQL format |same as func key |Func name in SQL |sqlFormatFunc |- for SQL format | |Can be used instead of `sqlFunc`. Function with 1 param - args object `{ : }`, should return formatted function expression string. + Example: SUM function can be formatted with `({a, b}) => a + " + " + b` +|sqlImport |- for SQL format | |Function to convert given raw SQL value (not string, but object got from `node-sql-parser`) to `{args: Object}`. If given expression can't be parsed into current function, throw an error. |spelFunc |- for SpEL format |same as func key |Func name in SpEL |spelFormatFunc |- for SpEL format | |Can be used instead of `spelFunc`. Function with 1 param - args object `{ : }`, should return formatted function expression string. + Example: SUM function can be formatted with `({a, b}) => a + " + " + b` @@ -776,7 +780,7 @@ To enable this feature set `valueSources` of type to `['value', 'func']` (see be |mongoFormatFunc |- for MongoDB format | |Can be used instead of `mongoFunc`. Function with 1 param - args object `{ : }`, should return formatted function expression object. |jsonLogic |+ for http://jsonlogic.com[JsonLogic] | |String (function name) or function with 1 param - args object `{ : }`, should return formatted function expression for JsonLogic. |jsonLogicImport | | |Function to convert given JsonLogic expression to array of arguments of current function. If given expression can't be parsed into current function, throw an error. -|spelImport | | |Function to convert given raw SpEL value to array of arguments of current function. If given value can't be parsed into current function, throw an error. +|spelImport | | |Function to convert given raw SpEL value to object of arguments of current function. If given value can't be parsed into current function, throw an error or return undefined. |args.* | | |Arguments of function. Config is almost same as for simple link:#configfields[fields] |args..label | |arg's key |Label to be displayed in arg's label or placeholder (if `config.settings.showLabels` is false) |args..type |+ | |One of types described in link:#configtypes[config.types] @@ -850,13 +854,13 @@ const ctx = { const zipConfig = { fields, settings: { - useConfigCompress: true, // this is required to use Utils.decompressConfig() + useConfigCompress: true, // this is required to use Utils.ConfigUtils.decompressConfig() }, // you can add here other sections like `widgets` or `types`, but don't add `ctx` }; // Config can be loaded from backend with providing `ctx` -const config = Utils.decompressConfig(zipConfig, BasicConfig, ctx); +const config = Utils.ConfigUtils.decompressConfig(zipConfig, BasicConfig, ctx); ---- You _can't_ just pass JS function to `validateValue` in `fieldSettings` because functions can't be serialized to JSON. @@ -932,7 +936,7 @@ const zipConfig = { To build zip config from full config you can use this util: [source,javascript] ---- -const zipConfig = Utils.compressConfig(config, BasicConfig); +const zipConfig = Utils.ConfigUtils.compressConfig(config, BasicConfig); ---- In order to generate zip config corretly (to JSON-serializable object), you should put your custom functions to `ctx` and refer to them by names as in examples above. [source,javascript] @@ -954,11 +958,11 @@ const config = merge({}, BasicConfig, { }, ctx, }); -const zipConfig = Utils.compressConfig(config, BasicConfig); -const config2 = Utils.decompressConfig(zipConfig, BasicConfig, ctx); // should be same as `config` +const zipConfig = Utils.ConfigUtils.compressConfig(config, BasicConfig); +const config2 = Utils.ConfigUtils.decompressConfig(zipConfig, BasicConfig, ctx); // should be same as `config` ---- -NOTE: `settings.useConfigCompress` should be `true` if you use `Utils.decompressConfig()` +NOTE: `settings.useConfigCompress` should be `true` if you use `Utils.ConfigUtils.decompressConfig()` {nbsp} + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64199dd5e..48906c6cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,7 @@ Or with VSCode: - [`stores`](/packages/core/modules/stores) - Tree store reducer - [`actions`](/packages/core/modules/actions) - Actions to dispatch on store - [`index.d.ts`](/packages/core/modules/index.d.ts) - TS definitions +- [`packages/sql/modules`](/packages/sql/modules) - SQL support - [`packages/ui/modules`](/packages/ui/modules) - Core React components - [`stores`](/packages/ui/modules/stores) - Tree store reducer for Redux (reused from `core`) - [`actions`](/packages/ui/modules/actions) - Actions to dispatch on store (reused from `core`, added `drag`) diff --git a/README.md b/README.md index 28a5a2be8..9312a922b 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ From v6 library is divided into packages: ```mermaid graph LR; - core((core))-->ui(ui); + core-->ui; + core-->sql((sql)); ui-->antd; ui-->mui; ui-->material; @@ -528,6 +529,12 @@ Wrapping in `div.query-builder-container` is necessary if you put query builder `Utils.Import.loadFromSpel (string, config) -> [Immutable, errors]` Convert query value from [Spring Expression Language (SpEL)](https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html) format to internal Immutable format. + #### `loadFromSql` + `SqlUtils.loadFromSql (string, config) -> {tree: Immutable, errors: string[]}` + Convert query value from SQL format to internal Immutable format. + Requires import of `@react-awesome-query-builder/sql`: + `import { SqlUtils } from "@react-awesome-query-builder/sql"` + ### Save/load config from server #### `compressConfig` @@ -673,15 +680,15 @@ See [example](/packages/examples/src/demo_switch/index.tsx) ## SSR You can save and load config from server with help of utils: -- [Utils.compressConfig()](#compressconfig) -- [Utils.decompressConfig()](#decompressconfig) +- [Utils.ConfigUtils.compressConfig()](#compressconfig) +- [Utils.ConfigUtils.decompressConfig()](#decompressconfig) You need these utils because you can't just send config *as-is* to server, as it contains functions that can't be serialized to JSON. Note that you need to set `config.settings.useConfigCompress = true` to enable this feature. To put it simple: - `ZipConfig` type is a JSON that contains only changes against basic config (differences). At minimum it contains your `fields`. It does not contain [`ctx`](#ctx). -- `Utils.decompressConfig()` will merge `ZipConfig` to basic config (and add `ctx` if passed). +- `Utils.ConfigUtils.decompressConfig()` will merge `ZipConfig` to basic config (and add `ctx` if passed). See [sandbox_next demo app](/packages/sandbox_next) that demonstrates server-side features. diff --git a/package.json b/package.json index 57c81b7b1..ede8a1571 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "author": "Denis Oblogin (https://github.com/ukrbublik)", "workspaces": [ "packages/core", + "packages/sql", "packages/ui", "packages/antd", "packages/mui", @@ -111,6 +112,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.34.1", "lerna": "^8.1.9", + "madge": "^8.0.0", "typescript": "~5.4.5" }, "engines": { diff --git a/packages/antd/modules/utils/stuff.js b/packages/antd/modules/utils/stuff.js index 8e3f99921..bf0311a46 100644 --- a/packages/antd/modules/utils/stuff.js +++ b/packages/antd/modules/utils/stuff.js @@ -1,9 +1,8 @@ import { Utils } from "@react-awesome-query-builder/ui"; const { getItemInListValues, listValuesToArray } = Utils.ListUtils; +const { isObjectOrArray } = Utils.OtherUtils; -const isObject = (v) => (typeof v == "object" && v !== null); // object or array - export const defaultTreeDataMap = {id: "value", pId: "parent", rootPId: undefined}; // converts from treeData to treeDataSimpleMode format (https://ant.design/components/tree-select/) @@ -39,7 +38,7 @@ const flatizeTreeData = (treeData) => { len = treeData.length; for (rind = 0 ; rind < len ; rind++) { const c = treeData[rind]; - if (!isObject(c)) + if (!isObjectOrArray(c)) continue; if (c[tdm.pId] !== undefined && c[tdm.pId] != tdm.rootPId) continue; //not lev 1 diff --git a/packages/antd/scripts/build-npm.sh b/packages/antd/scripts/build-npm.sh index 455355bd2..e93c36808 100755 --- a/packages/antd/scripts/build-npm.sh +++ b/packages/antd/scripts/build-npm.sh @@ -3,12 +3,18 @@ rm -rf ./cjs rm -rf ./esm babel --extensions ".tsx,.jsx,.ts,.js" -d ./cjs ./modules +find cjs/ -type f -name "*.d.js" | xargs -I{} rm {} #find ./cjs -name "*.js" -exec sed -i.bak "s+antd/es/+antd/lib/+g" {} + #rm ./cjs/**/*.bak node ./scripts/fix-antd.js + ESM=1 babel --extensions ".tsx,.jsx,.ts,.js" -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts +find esm/ -type f -name "*.d.js" | xargs -I{} rm {} + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} rm -rf ./css sass -I node_modules -I ../../node_modules styles/:css/ --no-source-map --style=expanded diff --git a/packages/antd/tsconfig.json b/packages/antd/tsconfig.json index b91377de1..2a11b7e37 100644 --- a/packages/antd/tsconfig.json +++ b/packages/antd/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], diff --git a/packages/bootstrap/scripts/build-npm.sh b/packages/bootstrap/scripts/build-npm.sh index e2fbcd373..5137aee5a 100755 --- a/packages/bootstrap/scripts/build-npm.sh +++ b/packages/bootstrap/scripts/build-npm.sh @@ -4,8 +4,11 @@ rm -rf ./esm babel -d ./cjs ./modules ESM=1 babel -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} rm -rf ./css sass -I node_modules -I ../../node_modules styles/:css/ --no-source-map --style=expanded diff --git a/packages/bootstrap/tsconfig.json b/packages/bootstrap/tsconfig.json index 9d6384be7..246aa4e6b 100644 --- a/packages/bootstrap/tsconfig.json +++ b/packages/bootstrap/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], diff --git a/packages/core/modules/actions/tree.js b/packages/core/modules/actions/tree.js index 9d63e2ece..30653ae77 100644 --- a/packages/core/modules/actions/tree.js +++ b/packages/core/modules/actions/tree.js @@ -1,7 +1,7 @@ import Immutable, {fromJS} from "immutable"; import {toImmutableList} from "../utils/stuff"; import * as constants from "../stores/constants"; -import { defaultRuleProperties, defaultGroupProperties } from "../utils/defaultUtils"; +import { defaultRuleProperties, defaultGroupProperties } from "../utils/defaultRuleUtils"; import uuid from "../utils/uuid"; diff --git a/packages/core/modules/config/funcs.js b/packages/core/modules/config/funcs.js index cc16167a1..ac9fa2566 100644 --- a/packages/core/modules/config/funcs.js +++ b/packages/core/modules/config/funcs.js @@ -12,6 +12,7 @@ const NOW = { //spelFunc: "new java.util.Date()", spelFunc: "T(java.time.LocalDateTime).now()", sqlFormatFunc: () => "NOW()", + sqlFunc: "NOW", mongoFormatFunc: () => new Date(), formatFunc: () => "NOW", }; @@ -63,6 +64,21 @@ const RELATIVE_DATETIME = { // MySQL //todo: other SQL dialects? sqlFormatFunc: ({date, op, val, dim}) => `DATE_ADD(${date}, INTERVAL ${parseInt(val) * (op == "minus" ? -1 : +1)} ${dim.replace(/^'|'$/g, "")})`, + sqlImport: (sqlObj) => { + if (["DATE_ADD", "DATE_SUB"].includes(sqlObj?.func) && sqlObj.children?.length === 2) { + const [date, interval] = sqlObj.children; + if (interval._type == "interval") { + return { + args: { + date, + op: sqlObj?.func === "DATE_ADD" ? "plus" : "minus", + val: interval.value, + dim: interval.unit, + } + }; + } + } + }, mongoFormatFunc: null, //todo: support? formatFunc: ({date, op, val, dim}) => (!val ? date : `${date} ${op == "minus" ? "-" : "+"} ${val} ${dim}`), args: { @@ -71,7 +87,7 @@ const RELATIVE_DATETIME = { type: "datetime", defaultValue: {func: "NOW", args: []}, valueSources: ["func", "field", "value"], - spelEscapeForFormat: true, + escapeForFormat: true, }, op: { label: "Op", @@ -89,7 +105,7 @@ const RELATIVE_DATETIME = { minus: "-", }, }, - spelEscapeForFormat: false, + escapeForFormat: false, }, val: { label: "Value", @@ -99,7 +115,7 @@ const RELATIVE_DATETIME = { }, defaultValue: 0, valueSources: ["value"], - spelEscapeForFormat: false, + escapeForFormat: false, }, dim: { label: "Dimension", @@ -119,7 +135,7 @@ const RELATIVE_DATETIME = { year: "year", }, }, - spelEscapeForFormat: false, + escapeForFormat: false, }, } }; @@ -128,6 +144,7 @@ const LOWER = { label: "Lowercase", mongoFunc: "$toLower", jsonLogic: "toLowerCase", + sqlFunc: "LOWER", spelFunc: "${str}.toLowerCase()", //jsonLogicIsMethod: true, // Removed in JsonLogic 2.x due to Prototype Pollution jsonLogicCustomOps: { @@ -147,6 +164,7 @@ const UPPER = { label: "Uppercase", mongoFunc: "$toUpper", jsonLogic: "toUpperCase", + sqlFunc: "UPPER", spelFunc: "${str}.toUpperCase()", //jsonLogicIsMethod: true, // Removed in JsonLogic 2.x due to Prototype Pollution jsonLogicCustomOps: { @@ -178,6 +196,21 @@ const LINEAR_REGRESSION = { } } }, + sqlImport: (sqlObj) => { + if (["+"].includes(sqlObj?.operator) && sqlObj.children?.length === 2) { + const [left, bias] = sqlObj.children; + if (["*"].includes(left?.operator) && left.children?.length === 2) { + const [coef, val] = left.children; + return { + args: { + coef, + val, + bias, + } + }; + } + } + }, mongoFormatFunc: ({coef, bias, val}) => ({"$sum": [{"$multiply": [coef, val]}, bias]}), jsonLogic: ({coef, bias, val}) => ({ "+": [ {"*": [coef, val]}, bias ] }), jsonLogicImport: (v) => { diff --git a/packages/core/modules/config/index.js b/packages/core/modules/config/index.js index 177e67a15..4b0836912 100644 --- a/packages/core/modules/config/index.js +++ b/packages/core/modules/config/index.js @@ -59,10 +59,15 @@ const conjunctions = { ? (not ? "NOT " : "") + "(" + children.join(" " + (isForDisplay ? "OR" : "||") + " ") + ")" : (not ? "NOT (" : "") + children.first() + (not ? ")" : ""); }, - sqlFormatConj: (children, conj, not) => { - return children.size > 1 - ? (not ? "NOT " : "") + "(" + children.join(" " + "OR" + " ") + ")" - : (not ? "NOT (" : "") + children.first() + (not ? ")" : ""); + sqlFormatConj: function (children, conj, not) { + let ret = (children.size > 1 ? children.join(" " + "OR" + " ") : children.first()); + if (children.size > 1 || not) { + ret = this.utils.wrapWithBrackets(ret); + } + if (not) { + ret = "NOT " + ret; + } + return ret; }, spelFormatConj: (children, conj, not, omitBrackets) => { if (not) omitBrackets = false; @@ -101,6 +106,7 @@ const operators = { label: "!=", labelForFormat: "!=", sqlOp: "<>", + sqlOps: ["<>", "!="], spelOp: "!=", spelOps: ["!=", "ne"], reversedOp: "equal", @@ -162,6 +168,28 @@ const operators = { labelForFormat: "Contains", reversedOp: "not_like", sqlOp: "LIKE", + // tip: this function covers import of 3 operators + sqlImport: (sqlObj) => { + if (sqlObj?.operator == "LIKE" || sqlObj?.operator == "NOT LIKE") { + const not = sqlObj?.operator == "NOT LIKE"; + const [_left, right] = sqlObj.children || []; + if (right?.valueType?.endsWith("_quote_string")) { + if (right?.value.startsWith("%") && right?.value.endsWith("%")) { + right.value = right.value.substring(1, right.value.length - 1); + sqlObj.operator = not ? "not_like" : "like"; + return sqlObj; + } else if (right?.value.startsWith("%")) { + right.value = right.value.substring(1); + sqlObj.operator = "ends_with"; + return sqlObj; + } else if (right?.value.endsWith("%")) { + right.value = right.value.substring(0, right.value.length - 1); + sqlObj.operator = "starts_with"; + return sqlObj; + } + } + } + }, spelOp: "${0}.contains(${1})", valueTypes: ["text"], mongoFormatOp: function(...args) { return this.utils.mongoFormatOp1("$regex", v => (typeof v == "string" ? this.utils.escapeRegExp(v) : undefined), false, ...args); }, @@ -213,6 +241,7 @@ const operators = { else return `${field} >= ${valFrom} && ${field} <= ${valTo}`; }, + // tip: this op can be imported from SpEL manually without using config spelFormatOp: (field, op, values, valueSrc, valueTypes, opDef, operatorOptions, fieldDef) => { const valFrom = values[0]; const valTo = values[1]; @@ -290,6 +319,18 @@ const operators = { const empty = this.utils.sqlEmptyValue(fieldDef); return `COALESCE(${field}, ${empty}) = ${empty}`; }, + // tip: this function covers import of 2 operators + sqlImport: (sqlObj) => { + if (sqlObj?.operator === "=" || sqlObj?.operator === "<>") { + const [left, right] = sqlObj.children || []; + if (right?.value === "" && left?.func === "COALESCE" && left?.children?.[1]?.value === "") { + sqlObj.operator = sqlObj?.operator === "=" ? "is_empty" : "is_not_empty"; + sqlObj.children = [ left.children[0] ]; + return sqlObj; + } + } + }, + // tip: this op can be imported from SpEL manually without using config spelFormatOp: (field, op, values, valueSrc, valueTypes, opDef, operatorOptions, fieldDef) => { //tip: is empty or null return `${field} <= ''`; @@ -322,11 +363,23 @@ const operators = { label: "Is null", labelForFormat: "IS NULL", sqlOp: "IS NULL", + // tip: this function covers import of 2 operators + sqlImport: (sqlObj) => { + if (sqlObj?.operator === "IS" || sqlObj?.operator === "IS NOT") { + const [left, right] = sqlObj.children || []; + if (right?.valueType == "null") { + sqlObj.operator = sqlObj?.operator === "IS" ? "is_null" : "is_not_null"; + sqlObj.value = left; + return sqlObj; + } + } + }, cardinality: 0, reversedOp: "is_not_null", formatOp: (field, op, value, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { return isForDisplay ? `${field} IS NULL` : `!${field}`; }, + // tip: this op can be imported from SpEL manually without using config spelFormatOp: (field, op, values, valueSrc, valueTypes, opDef, operatorOptions, fieldDef) => { return `${field} == null`; }, @@ -371,6 +424,7 @@ const operators = { label: "!=", labelForFormat: "!=", sqlOp: "<>", // enum/set + sqlOps: ["<>", "!="], formatOp: (field, op, value, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { return `${field} != ${value}`; }, @@ -428,6 +482,7 @@ const operators = { multiselect_contains: { label: "Contains", labelForFormat: "CONTAINS", + valueTypes: ["multiselect"], formatOp: (field, op, values, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { if (valueSrc == "value") return `${field} CONTAINS [${values.join(", ")}]`; @@ -448,6 +503,7 @@ const operators = { isNotOp: true, label: "Not contains", labelForFormat: "NOT CONTAINS", + valueTypes: ["multiselect"], formatOp: (field, op, values, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { if (valueSrc == "value") return `${field} NOT CONTAINS [${values.join(", ")}]`; @@ -465,6 +521,7 @@ const operators = { label: "Equals", labelForFormat: "==", sqlOp: "=", + valueTypes: ["multiselect"], formatOp: (field, op, values, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { const opStr = isForDisplay ? "=" : "=="; if (valueSrc == "value") @@ -494,6 +551,8 @@ const operators = { label: "Not equals", labelForFormat: "!=", sqlOp: "<>", + sqlOps: ["<>", "!="], + valueTypes: ["multiselect"], formatOp: (field, op, values, valueSrc, valueType, opDef, operatorOptions, isForDisplay) => { if (valueSrc == "value") return `${field} != [${values.join(", ")}]`; @@ -542,6 +601,27 @@ const operators = { const prox = operatorOptions?.get("proximity"); return `CONTAINS(${field}, 'NEAR((${aVal1}, ${aVal2}), ${prox})')`; }, + sqlImport: (sqlObj) => { + if (sqlObj?.func === "CONTAINS") { + const [left, right] = sqlObj.children || []; + if (right?.value?.includes("NEAR(")) { + const m = right.value.match(/NEAR\(\((\w+), (\w+)\), (\d+)\)/); + if (m) { + delete sqlObj.func; + sqlObj.operator = "proximity"; + sqlObj.children = [ + left, + { value: m[1] }, + { value: m[2] }, + ]; + sqlObj.operatorOptions = { + proximity: parseInt(m[3]) + }; + return sqlObj; + } + } + } + }, mongoFormatOp: undefined, // not supported jsonLogic: undefined, // not supported options: { @@ -920,6 +1000,26 @@ const widgets = { return [undefined, "Invalid date"]; } }, + // Moved to `sqlImportDate` in `packages/sql/modules/import/conv` + // sqlImport: function (sqlObj, wgtDef) { + // if (["TO_DATE"].includes(sqlObj?.func) && sqlObj?.children?.length >= 1) { + // const [valArg, patternArg] = sqlObj.children; + // if (valArg?.valueType == "single_quote_string") { + // // tip: moment doesn't support SQL date format, so ignore patternArg + // const dateVal = this.utils.moment(valArg.value); + // if (dateVal.isValid()) { + // return { + // value: dateVal.format(wgtDef?.valueFormat), + // }; + // } else { + // return { + // value: null, + // error: "Invalid date", + // }; + // } + // } + // } + // }, jsonLogic: function (val, fieldDef, wgtDef) { return this.utils.moment(val, wgtDef.valueFormat).toDate(); }, diff --git a/packages/core/modules/export/elasticSearch.js b/packages/core/modules/export/elasticSearch.js index be15341c7..488fac1c4 100644 --- a/packages/core/modules/export/elasticSearch.js +++ b/packages/core/modules/export/elasticSearch.js @@ -1,6 +1,6 @@ -import {getWidgetForFieldOp} from "../utils/ruleUtils"; +import {getWidgetForFieldOp} from "../utils/configUtils"; import {defaultConjunction} from "../utils/defaultUtils"; -import { extendConfig } from "../utils/configUtils"; +import { extendConfig } from "../utils/configExtend"; /** diff --git a/packages/core/modules/export/jsonLogic.js b/packages/core/modules/export/jsonLogic.js index 3ec223a50..d9fa5b3cc 100644 --- a/packages/core/modules/export/jsonLogic.js +++ b/packages/core/modules/export/jsonLogic.js @@ -1,8 +1,9 @@ import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, extendConfig, getFieldParts + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, getWidgetForFieldOp } from "../utils/configUtils"; -import {getWidgetForFieldOp, formatFieldName, completeValue, getOneChildOrDescendant} from "../utils/ruleUtils"; +import { extendConfig } from "../utils/configExtend"; +import {formatFieldName, completeValue, getOneChildOrDescendant} from "../utils/ruleUtils"; import {defaultConjunction} from "../utils/defaultUtils"; import {List, Map} from "immutable"; import pick from "lodash/pick"; diff --git a/packages/core/modules/export/mongoDb.js b/packages/core/modules/export/mongoDb.js index 9aca9ca5b..ff540912f 100644 --- a/packages/core/modules/export/mongoDb.js +++ b/packages/core/modules/export/mongoDb.js @@ -1,16 +1,13 @@ -import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; +import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit, isObject} from "../utils/stuff"; import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, extendConfig, + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, getWidgetForFieldOp, } from "../utils/configUtils"; -import {getFieldPathLabels, getWidgetForFieldOp, formatFieldName, completeValue, getOneChildOrDescendant} from "../utils/ruleUtils"; +import {extendConfig} from "../utils/configExtend"; +import {getFieldPathLabels, formatFieldName, completeValue, getOneChildOrDescendant} from "../utils/ruleUtils"; import {defaultConjunction} from "../utils/defaultUtils"; import pick from "lodash/pick"; import {List, Map} from "immutable"; - -// helpers -const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); - export const mongodbFormat = (tree, config) => { return _mongodbFormat(tree, config, false); }; diff --git a/packages/core/modules/export/queryString.js b/packages/core/modules/export/queryString.js index 4702fe8b2..cdadcbf63 100644 --- a/packages/core/modules/export/queryString.js +++ b/packages/core/modules/export/queryString.js @@ -1,8 +1,9 @@ import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, extendConfig, + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, getWidgetForFieldOp, } from "../utils/configUtils"; +import {extendConfig} from "../utils/configExtend"; import { - getFieldPathLabels, getWidgetForFieldOp, formatFieldName, completeValue + getFieldPathLabels, formatFieldName, completeValue } from "../utils/ruleUtils"; import pick from "lodash/pick"; import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; @@ -295,7 +296,12 @@ const formatValue = (config, meta, value, valueSrc, valueType, fieldWidgetDef, f const valFieldDefinition = getFieldConfig(config, value) || {}; args.push(valFieldDefinition); } - ret = fn.call(config.ctx, ...args); + const doEscape = fieldDef?.escapeForFormat ?? true; + if (!doEscape) { + ret = value; + } else { + ret = fn.call(config.ctx, ...args); + } } else { ret = value; } @@ -352,6 +358,7 @@ const formatFunc = (config, meta, funcValue, parentField = null) => { const fieldDef = getFieldConfig(config, argConfig); const {defaultValue, isOptional} = argConfig || {}; const defaultValueSrc = defaultValue?.func ? "func" : "value"; + const fieldWidgetDef = getFieldWidgetConfig(config, argConfig, undefined, undefined, defaultValueSrc, { forExport: true }); const argName = isForDisplay && argConfig?.label || argKey; const argVal = args ? args.get(argKey) : undefined; let argValue = argVal ? argVal.get("value") : undefined; @@ -362,7 +369,7 @@ const formatFunc = (config, meta, funcValue, parentField = null) => { } const argAsyncListValues = argVal ? argVal.get("asyncListValues") : undefined; const formattedArgVal = formatValue( - config, meta, argValue, argValueSrc, argConfig?.type, fieldDef, argConfig, null, null, parentField, argAsyncListValues + config, meta, argValue, argValueSrc, argConfig?.type, fieldWidgetDef, argConfig, null, null, parentField, argAsyncListValues ); if (argValue != undefined && formattedArgVal === undefined) { if (argValueSrc != "func") // don't triger error if args value is another incomplete function @@ -372,7 +379,7 @@ const formatFunc = (config, meta, funcValue, parentField = null) => { let formattedDefaultVal; if (formattedArgVal === undefined && !isOptional && defaultValue != undefined) { formattedDefaultVal = formatValue( - config, meta, defaultValue, defaultValueSrc, argConfig?.type, fieldDef, argConfig, null, null, parentField, argAsyncListValues + config, meta, defaultValue, defaultValueSrc, argConfig?.type, fieldWidgetDef, argConfig, null, null, parentField, argAsyncListValues ); if (formattedDefaultVal === undefined) { if (defaultValueSrc != "func") // don't triger error if args value is another incomplete function diff --git a/packages/core/modules/export/spel.js b/packages/core/modules/export/spel.js index fe1120050..31b04325a 100644 --- a/packages/core/modules/export/spel.js +++ b/packages/core/modules/export/spel.js @@ -1,11 +1,13 @@ import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, extendConfig, getFieldParts + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, getWidgetForFieldOp, + getFieldPartsConfigs, } from "../utils/configUtils"; +import {extendConfig} from "../utils/configExtend"; import { - getWidgetForFieldOp, formatFieldName, getFieldPartsConfigs, completeValue + formatFieldName, completeValue } from "../utils/ruleUtils"; import pick from "lodash/pick"; -import {getOpCardinality, logger, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; +import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; import {defaultConjunction} from "../utils/defaultUtils"; import {List, Map} from "immutable"; import {spelEscape} from "../utils/export"; @@ -467,7 +469,7 @@ const formatFunc = (meta, config, currentValue, parentField = null) => { argValue = argValue.toJS(); } const argAsyncListValues = argVal ? argVal.get("asyncListValues") : undefined; - const doEscape = argConfig.spelEscapeForFormat ?? true; + const doEscape = argConfig.escapeForFormat ?? true; const operator = null; const widget = getWidgetForFieldOp(config, argConfig, operator, argValueSrc); const fieldWidgetDef = getFieldWidgetConfig(config, argConfig, operator, widget, argValueSrc, { forExport: true }); diff --git a/packages/core/modules/export/sql.js b/packages/core/modules/export/sql.js index e4f5ad72d..19f16bfd1 100644 --- a/packages/core/modules/export/sql.js +++ b/packages/core/modules/export/sql.js @@ -1,8 +1,9 @@ import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, extendConfig, + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldParts, getWidgetForFieldOp, } from "../utils/configUtils"; +import {extendConfig} from "../utils/configExtend"; import { - getFieldPathLabels, getWidgetForFieldOp, formatFieldName, completeValue + getFieldPathLabels, formatFieldName, completeValue } from "../utils/ruleUtils"; import pick from "lodash/pick"; import {getOpCardinality, widgetDefKeysToOmit, opDefKeysToOmit, omit} from "../utils/stuff"; diff --git a/packages/core/modules/import/jsonLogic.js b/packages/core/modules/import/jsonLogic.js index 9dce6b0ec..a5be22c35 100644 --- a/packages/core/modules/import/jsonLogic.js +++ b/packages/core/modules/import/jsonLogic.js @@ -1,7 +1,7 @@ import uuid from "../utils/uuid"; -import {getOpCardinality, isJsonLogic, shallowEqual, logger} from "../utils/stuff"; -import {getFieldConfig, extendConfig, normalizeField, getFuncConfig, iterateFuncs, getFieldParts} from "../utils/configUtils"; -import {getWidgetForFieldOp} from "../utils/ruleUtils"; +import {getOpCardinality, isJsonLogic, shallowEqual} from "../utils/stuff"; +import {getFieldConfig, normalizeField, getFuncConfig, iterateFuncs, getFieldParts, getWidgetForFieldOp} from "../utils/configUtils"; +import {extendConfig} from "../utils/configExtend"; import {loadTree} from "./tree"; import {defaultGroupConjunction} from "../utils/defaultUtils"; @@ -482,7 +482,7 @@ const convertFuncRhs = (op, vals, conv, config, not, fieldConfig = null, meta, p if (fc.jsonLogicImport && (returnType ? fc.returnType == returnType : true)) { let parsed; try { - parsed = fc.jsonLogicImport(v); + parsed = fc.jsonLogicImport.call(config.ctx, v); } catch(_e) { // given expression `v` can't be parsed into function } diff --git a/packages/core/modules/import/spel.js b/packages/core/modules/import/spel.js deleted file mode 100644 index 4817fed98..000000000 --- a/packages/core/modules/import/spel.js +++ /dev/null @@ -1,1329 +0,0 @@ -import { SpelExpressionEvaluator } from "spel2js"; -import uuid from "../utils/uuid"; -import {getFieldConfig, getFuncConfig, extendConfig, normalizeField, iterateFuncs} from "../utils/configUtils"; -import {getWidgetForFieldOp} from "../utils/ruleUtils"; -import {loadTree} from "./tree"; -import {defaultGroupConjunction} from "../utils/defaultUtils"; -import {getOpCardinality, logger, isJsonCompatible} from "../utils/stuff"; -import moment from "moment"; -import {compareToSign} from "../export/spel"; - -// https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html#expressions - -// spel type => raqb type -const SpelPrimitiveTypes = { - number: "number", - string: "text", - boolean: "boolean", - null: "null" // should not be -}; -// spel class => raqb type -const SpelPrimitiveClasses = { - String: "text", -}; -const ListValueType = "multiselect"; -const isFuncableProperty = (p) => ["length"].includes(p); - -const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); - -export const loadFromSpel = (logicTree, config) => { - return _loadFromSpel(logicTree, config, true); -}; - -export const _loadFromSpel = (spelStr, config, returnErrors = true) => { - //meta is mutable - let meta = { - errors: [] - }; - const extendedConfig = extendConfig(config, undefined, false); - const conv = buildConv(extendedConfig); - - let compiledExpression; - let convertedObj; - let jsTree = undefined; - try { - const compileRes = SpelExpressionEvaluator.compile(spelStr); - compiledExpression = compileRes._compiledExpression; - } catch (e) { - meta.errors.push(e); - } - - if (compiledExpression) { - //logger.debug("compiledExpression:", compiledExpression); - convertedObj = postprocessCompiled(compiledExpression, meta); - logger.debug("convertedObj:", convertedObj, meta); - - jsTree = convertToTree(convertedObj, conv, extendedConfig, meta); - if (jsTree && jsTree.type != "group" && jsTree.type != "switch_group") { - jsTree = wrapInDefaultConj(jsTree, extendedConfig, convertedObj["not"]); - } - logger.debug("jsTree:", jsTree); - } - - const immTree = jsTree ? loadTree(jsTree) : undefined; - - if (returnErrors) { - return [immTree, meta.errors]; - } else { - if (meta.errors.length) - console.warn("Errors while importing from SpEL:", meta.errors); - return immTree; - } -}; - -const postprocessCompiled = (expr, meta, parentExpr = null) => { - const type = expr.getType(); - let children = expr.getChildren().map(child => postprocessCompiled(child, meta, expr)); - - // flatize OR/AND - if (type == "op-or" || type == "op-and") { - children = children.reduce((acc, child) => { - const canFlatize = child.type == type && !child.not; - const flat = canFlatize ? child.children : [child]; - return [...acc, ...flat]; - }, []); - } - - // unwrap NOT - if (type == "op-not") { - if (children.length != 1) { - meta.errors.push(`Operator NOT should have 1 child, but got ${children.length}}`); - } - return { - ...children[0], - not: !(children[0].not || false) - }; - } - - if (type == "compound") { - // remove `.?[true]` - children = children.filter(child => { - const isListFix = child.type == "selection" && child.children.length == 1 && child.children[0].type == "boolean" && child.children[0].val == true; - return !isListFix; - }); - // aggregation - // eg. `results.?[product == 'abc'].length` - const selection = children.find(child => - child.type == "selection" - ); - if (selection && selection.children.length != 1) { - meta.errors.push(`Selection should have 1 child, but got ${selection.children.length}`); - } - const filter = selection ? selection.children[0] : null; - let lastChild = children[children.length - 1]; - const isSize = lastChild.type == "method" && lastChild.val.methodName == "size" - || lastChild.type == "!func" && lastChild.methodName == "size"; - const isLength = lastChild.type == "property" && lastChild.val == "length"; - const sourceParts = children.filter(child => - child !== selection && child !== lastChild - ); - const source = { - type: "compound", - children: sourceParts - }; - const isAggr = (isSize || isLength) && convertPath(sourceParts) != null; - if (isAggr) { - return { - type: "!aggr", - filter, - source - }; - } - // remove `#this`, `#root` - children = children.filter(child => { - const isThis = child.type == "variable" && child.val == "this"; - const isRoot = child.type == "variable" && child.val == "root"; - return !(isThis || isRoot); - }); - // indexer - children = children.map(child => { - if (child.type == "indexer" && child.children.length == 1) { - return { - type: "indexer", - val: child.children[0].val, - itype: child.children[0].type - }; - } else { - return child; - } - }); - // method - // if (lastChild.type == "method") { - // // seems like obsolete code! - // debugger - // const obj = children.filter(child => - // child !== lastChild - // ); - // return { - // type: "!func", - // obj, - // methodName: lastChild.val.methodName, - // args: lastChild.val.args - // }; - // } - // !func - if (lastChild.type == "!func") { - const ret = {}; - let curr = ret; - do { - Object.assign(curr, lastChild); - children = children.filter(child => child !== lastChild); - lastChild = children[children.length - 1]; - if (lastChild?.type == "!func") { - curr.obj = {}; - curr = curr.obj; - } else { - if (children.length > 1) { - curr.obj = { - type: "compound", - children - }; - } else { - curr.obj = lastChild; - } - } - } while(lastChild?.type == "!func"); - return ret; - } - } - - // getRaw || getValue - let val; - try { - if (expr.getRaw) { // use my fork - val = expr.getRaw(); - } else if (expr.getValue.length == 0) { // getValue not requires context arg -> can use - val = expr.getValue(); - } - } catch(e) { - logger.error("[spel2js] Error in getValue()", e); - } - - // ternary - if (type == "ternary") { - val = flatizeTernary(children); - } - - // convert method/function args - if (typeof val === "object" && val !== null) { - if (val.methodName || val.functionName) { - val.args = val.args.map(child => postprocessCompiled(child, meta, expr)); - } - } - // convert list - if (type == "list") { - val = val.map(item => postprocessCompiled(item, meta, expr)); - - // fix whole expression wrapped in `{}` - if (!parentExpr && val.length == 1) { - return val[0]; - } - } - // convert constructor - if (type == "constructorref") { - const qid = children.find(child => child.type == "qualifiedidentifier"); - const cls = qid?.val; - if (!cls) { - meta.errors.push(`Can't find qualifiedidentifier in constructorref children: ${JSON.stringify(children)}`); - return undefined; - } - const args = children.filter(child => child.type != "qualifiedidentifier"); - return { - type: "!new", - cls, - args - }; - } - // convert type - if (type == "typeref") { - const qid = children.find(child => child.type == "qualifiedidentifier"); - const cls = qid?.val; - if (!cls) { - meta.errors.push(`Can't find qualifiedidentifier in typeref children: ${JSON.stringify(children)}`); - return undefined; - } - const _args = children.filter(child => child.type != "qualifiedidentifier"); - return { - type: "!type", - cls - }; - } - // convert function/method - if (type == "function" || type == "method") { - // `foo()` is method, `#foo()` is function - // let's use common property `methodName` and just add `isVar` for function - const {functionName, methodName, args} = val; - return { - type: "!func", - methodName: functionName || methodName, - isVar: type == "function", - args - }; - } - - return { - type, - children, - val, - }; -}; - -const flatizeTernary = (children) => { - let flat = []; - function _processTernaryChildren(tern) { - let [cond, if_val, else_val] = tern; - flat.push([cond, if_val]); - if (else_val?.type == "ternary") { - _processTernaryChildren(else_val.children); - } else { - flat.push([undefined, else_val]); - } - } - _processTernaryChildren(children); - return flat; -}; - -const buildConv = (config) => { - let operators = {}; - for (let opKey in config.operators) { - const opConfig = config.operators[opKey]; - if (opConfig.spelOps) { - // examples: "==", "eq", ".contains", "matches" (can be used for starts_with, ends_with) - opConfig.spelOps.forEach(spelOp => { - const opk = spelOp; // + "/" + getOpCardinality(opConfig); - if (!operators[opk]) - operators[opk] = []; - operators[opk].push(opKey); - }); - } else if (opConfig.spelOp) { - const opk = opConfig.spelOp; // + "/" + getOpCardinality(opConfig); - if (!operators[opk]) - operators[opk] = []; - operators[opk].push(opKey); - } else { - logger.log(`[spel] No spelOp for operator ${opKey}`); - } - } - - let conjunctions = {}; - for (let conjKey in config.conjunctions) { - const conjunctionDefinition = config.conjunctions[conjKey]; - const ck = conjunctionDefinition.spelConj || conjKey.toLowerCase(); - conjunctions[ck] = conjKey; - } - - let funcs = {}; - for (const [funcPath, funcConfig] of iterateFuncs(config)) { - let fks = []; - const {spelFunc} = funcConfig; - if (typeof spelFunc === "string") { - const optionalArgs = Object.keys(funcConfig.args || {}) - .reverse() - .filter(argKey => !!funcConfig.args[argKey].isOptional || funcConfig.args[argKey].defaultValue != undefined); - const funcSignMain = spelFunc - .replace(/\${(\w+)}/g, (_, _k) => "?"); - const funcSignsOptional = optionalArgs - .reduce((acc, argKey) => ( - [ - ...acc, - [ - argKey, - ...(acc[acc.length-1] || []), - ] - ] - ), []) - .map(optionalArgKeys => ( - spelFunc - .replace(/(?:, )?\${(\w+)}/g, (found, a) => ( - optionalArgKeys.includes(a) ? "" : found - )) - .replace(/\${(\w+)}/g, (_, _k) => "?") - )); - fks = [ - funcSignMain, - ...funcSignsOptional, - ]; - } - for (const fk of fks) { - if (!funcs[fk]) - funcs[fk] = []; - funcs[fk].push(funcPath); - } - } - - let valueFuncs = {}; - for (let w in config.widgets) { - const widgetDef = config.widgets[w]; - const {spelImportFuncs, type} = widgetDef; - if (spelImportFuncs) { - for (const fk of spelImportFuncs) { - if (typeof fk === "string") { - const fs = fk.replace(/\${(\w+)}/g, (_, k) => "?"); - const argsOrder = [...fk.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); - if (!valueFuncs[fs]) - valueFuncs[fs] = []; - valueFuncs[fs].push({ - w, - argsOrder - }); - } - } - } - } - - let opFuncs = {}; - for (let op in config.operators) { - const opDef = config.operators[op]; - const {spelOp} = opDef; - if (spelOp?.includes("${0}")) { - const fs = spelOp.replace(/\${(\w+)}/g, (_, k) => "?"); - const argsOrder = [...spelOp.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); - if (!opFuncs[fs]) - opFuncs[fs] = []; - opFuncs[fs].push({ - op, - argsOrder - }); - } - } - // Special .compareTo() - const compareToSS = compareToSign.replace(/\${(\w+)}/g, (_, k) => "?"); - opFuncs[compareToSS] = [{ - op: "!compare", - argsOrder: ["0", "1"] - }]; - - return { - operators, - conjunctions, - funcs, - valueFuncs, - opFuncs, - }; -}; - -const convertToTree = (spel, conv, config, meta, parentSpel = null) => { - if (!spel) return undefined; - spel._groupField = spel._groupField ?? parentSpel?._groupField; - - let res, canParseAsArg = true; - if (spel.type.indexOf("op-") === 0 || spel.type === "matches") { - res = convertOp(spel, conv, config, meta, parentSpel); - } else if (spel.type == "!aggr") { - const groupFieldValue = convertToTree(spel.source, conv, config, meta, spel); - spel._groupField = groupFieldValue?.value; - let groupFilter = convertToTree(spel.filter, conv, config, meta, spel); - if (groupFilter?.type == "rule") { - groupFilter = wrapInDefaultConj(groupFilter, config, spel.filter.not); - } - res = { - groupFilter, - groupFieldValue - }; - if (!parentSpel) { - // !aggr can't be in root, it should be compared with something - res = undefined; - meta.errors.push("Unexpected !aggr in root"); - canParseAsArg = false; - } - } else if (spel.type == "ternary") { - const children1 = {}; - spel.val.forEach(v => { - const [cond, val] = v; - const caseI = buildCase(cond, val, conv, config, meta, spel); - if (caseI) { - children1[caseI.id] = caseI; - } - }); - res = { - type: "switch_group", - id: uuid(), - children1, - properties: {} - }; - } - - if (!res && canParseAsArg) { - res = convertArg(spel, conv, config, meta, parentSpel); - } - - if (res && !res.type && !parentSpel) { - // res is not a rule, it's value at root - // try to parse whole `"1"` as ternary - const sw = buildSimpleSwitch(spel, conv, config, meta); - if (sw) { - res = sw; - } else { - res = undefined; - meta.errors.push(`Can't convert rule of type ${spel.type}, it looks like var/literal`); - } - } - - return res; -}; - -const convertOp = (spel, conv, config, meta, parentSpel = null) => { - let res; - - let op = spel.type.startsWith("op-") ? spel.type.slice("op-".length) : spel.type; - - // unary - const isUnary = (op == "minus" || op == "plus") && spel.children.length == 1; - if (isUnary) { - let negative = spel.negative; - if (op == "minus") { - negative = !negative; - } - spel.children[0].negative = negative; - return convertToTree(spel.children[0], conv, config, meta, parentSpel); - } - - // between - const isBetweenNormal = (op == "and" && spel.children.length == 2 && spel.children[0].type == "op-ge" && spel.children[1].type == "op-le"); - const isBetweenRev = (op == "or" && spel.children.length == 2 && spel.children[0].type == "op-lt" && spel.children[1].type == "op-gt"); - const isBetween = isBetweenNormal || isBetweenRev; - if (isBetween) { - const [left, from] = spel.children[0].children; - const [right, to] = spel.children[1].children; - const isSameSource = compareArgs(left, right, spel, conv, config, meta, parentSpel); - if (isSameSource) { - const _fromValue = from.val; - const _toValue = to.val; - const oneSpel = { - type: "op-between", - children: [ - left, - from, - to - ], - not: isBetweenRev, - }; - oneSpel._groupField = parentSpel?._groupField; - return convertOp(oneSpel, conv, config, meta, parentSpel); - } - } - - // find op - let opKeys = conv.operators[op]; - if (op == "eq" && spel.children[1].type == "null") { - opKeys = ["is_null"]; - } else if (op == "ne" && spel.children[1].type == "null") { - opKeys = ["is_not_null"]; - } else if (op == "le" && spel.children[1].type == "string" && spel.children[1].val == "") { - opKeys = ["is_empty"]; - } else if (op == "gt" && spel.children[1].type == "string" && spel.children[1].val == "") { - opKeys = ["is_not_empty"]; - } else if (op == "between") { - opKeys = ["between"]; - } - - // convert children - const convertChildren = () => { - let newChildren = spel.children.map(child => - convertToTree(child, conv, config, meta, spel) - ); - if (newChildren.length >= 2 && newChildren?.[0]?.type == "!compare") { - newChildren = newChildren[0].children; - } - return newChildren; - }; - if (op == "and" || op == "or") { - const children1 = {}; - const vals = convertChildren(); - vals.forEach(v => { - if (v) { - const id = uuid(); - v.id = id; - if (v.type != undefined) { - children1[id] = v; - } else { - meta.errors.push(`Bad item in AND/OR: ${JSON.stringify(v)}`); - } - } - }); - res = { - type: "group", - id: uuid(), - children1, - properties: { - conjunction: conv.conjunctions[op], - not: spel.not - } - }; - } else if (opKeys) { - const vals = convertChildren(); - const fieldObj = vals[0]; - let convertedArgs = vals.slice(1); - const groupField = fieldObj?.groupFieldValue?.value; - const opArg = convertedArgs?.[0]; - - let opKey = opKeys[0]; - if (opKeys.length > 1) { - const valueType = vals[0]?.valueType || vals[1]?.valueType; - //todo: it's naive, use valueType - const field = fieldObj?.value; - const widgets = opKeys.map(op => ({op, widget: getWidgetForFieldOp(config, field, op)})); - logger.warn(`[spel] Spel operator ${op} can be mapped to ${opKeys}.`, - "widgets:", widgets, "vals:", vals, "valueType=", valueType); - - if (op == "eq" || op == "ne") { - const ws = widgets.find(({ op, widget }) => (widget && widget != "field")); - if (ws) { - opKey = ws.op; - } - } - } - - // some/all/none - if (fieldObj?.groupFieldValue) { - if (opArg && opArg.groupFieldValue && opArg.groupFieldValue.valueSrc == "field" && opArg.groupFieldValue.value == groupField) { - // group.?[...].size() == group.size() - opKey = "all"; - convertedArgs = []; - } else if (opKey == "equal" && opArg.valueSrc == "value" && opArg.valueType == "number" && opArg.value == 0) { - opKey = "none"; - convertedArgs = []; - } else if (opKey == "greater" && opArg.valueSrc == "value" && opArg.valueType == "number" && opArg.value == 0) { - opKey = "some"; - convertedArgs = []; - } - } - - let opConfig = config.operators[opKey]; - const reversedOpConfig = config.operators[opConfig?.reversedOp]; - const opNeedsReverse = spel.not && ["between"].includes(opKey); - const opCanReverse = !!reversedOpConfig; - const canRev = opCanReverse && (!!config.settings.reverseOperatorsForNot || opNeedsReverse); - const needRev = spel.not && canRev || opNeedsReverse; - if (needRev) { - opKey = opConfig.reversedOp; - opConfig = config.operators[opKey]; - spel.not = !spel.not; - } - const needWrapWithNot = !!spel.not; - spel.not = false; // handled with needWrapWithNot - - if (!fieldObj) { - // LHS can't be parsed - } else if (fieldObj.groupFieldValue) { - // 1. group - if (fieldObj.groupFieldValue.valueSrc != "field") { - meta.errors.push(`Expected group field ${JSON.stringify(fieldObj)}`); - } - - res = buildRuleGroup(fieldObj, opKey, convertedArgs, config, meta); - } else { - // 2. not group - if (fieldObj.valueSrc != "field" && fieldObj.valueSrc != "func") { - meta.errors.push(`Expected field/func at LHS, but got ${JSON.stringify(fieldObj)}`); - } - const field = fieldObj.value; - res = buildRule(config, meta, field, opKey, convertedArgs, spel); - } - - if (needWrapWithNot) { - if (res.type !== "group") { - res = wrapInDefaultConj(res, config, true); - } else { - res.properties.not = !res.properties.not; - } - } - } else { - if (!parentSpel) { - // try to parse whole `"str" + prop + #var` as ternary - res = buildSimpleSwitch(spel, conv, config, meta); - } - // if (!res) { - // meta.errors.push(`Can't convert op ${op}`); - // } - } - return res; -}; - -const convertPath = (parts, meta = {}, expectingField = false) => { - let isError = false; - const res = parts.map(c => { - if (c.type == "variable" || c.type == "property" || c.type == "indexer" && c.itype == "string") { - return c.val; - } else { - isError = true; - expectingField && meta?.errors?.push?.(`Unexpected item in field path compound: ${JSON.stringify(c)}`); - } - }); - return !isError ? res : undefined; -}; - -const convertArg = (spel, conv, config, meta, parentSpel = null) => { - if (spel == undefined) - return undefined; - const {fieldSeparator} = config.settings; - spel._groupField = spel._groupField ?? parentSpel?._groupField; - - if (spel.type == "variable" || spel.type == "property") { - // normal field - const field = normalizeField(config, spel.val, spel._groupField); - const fieldConfig = getFieldConfig(config, field); - const isVariable = spel.type == "variable"; - return { - valueSrc: "field", - valueType: fieldConfig?.type, - isVariable, - value: field, - }; - } else if (spel.type == "compound") { - // complex field - const parts = convertPath(spel.children, meta); - if (parts) { - const field = normalizeField(config, parts.join(fieldSeparator), spel._groupField); - const fieldConfig = getFieldConfig(config, field); - const isVariable = spel.children?.[0]?.type == "variable"; - return { - valueSrc: "field", - valueType: fieldConfig?.type, - isVariable, - value: field, - }; - } else { - // it's not complex field - } - } else if (SpelPrimitiveTypes[spel.type]) { - let value = spel.val; - const valueType = SpelPrimitiveTypes[spel.type]; - if (spel.negative) { - value = -value; - } - return { - valueSrc: "value", - valueType, - value, - }; - } else if (spel.type == "!new" && SpelPrimitiveClasses[spel.cls.at(-1)]) { - const args = spel.args.map(v => convertArg(v, conv, config, meta, spel)); - const value = args?.[0]; - const valueType = SpelPrimitiveClasses[spel.cls.at(-1)]; - return { - ...value, - valueType, - }; - } else if (spel.type == "list") { - const values = spel.val.map(v => convertArg(v, conv, config, meta, spel)); - const _itemType = values.length ? values[0]?.valueType : null; - const value = values.map(v => v?.value); - const valueType = ListValueType; - return { - valueSrc: "value", - valueType, - value, - }; - } else if (spel.type === "op-plus" && parentSpel?.type === "ternary" && config.settings.caseValueField?.type === "case_value") { - /** - * @deprecated - */ - return buildCaseValueConcat(spel, conv, config, meta); - } - - let maybe = convertFunc(spel, conv, config, meta, parentSpel); - if (maybe !== undefined) { - return maybe; - } - - meta.errors.push(`Can't convert arg of type ${spel.type}`); - return undefined; -}; - -const buildFuncSignatures = (spel) => { - // branches - const brns = [ - {s: "", params: [], objs: []} - ]; - _buildFuncSignatures(spel, brns); - return brns.map(({s, params}) => ({s, params})).reverse().filter(({s}) => s !== "" && s !== "?"); -}; - -// a.toLower().toUpper() -// -> -// ?.toLower().toUpper() -// ?.toUpper() -const _buildFuncSignatures = (spel, brns) => { - let params = [], s = ""; - const { type, methodName, val, obj, args, isVar, cls, children } = spel; - const lastChild = children?.[children.length-1]; - let currBrn = brns[brns.length-1]; - if (type === "!func") { - // T(DateTimeFormat).forPattern(?).parseDateTime(?) -- ok - // T(LocalDateTime).parse(?, T(DateTimeFormatter).ofPattern(?)) -- will not work - let o = obj; - while (o) { - const [s1, params1] = _buildFuncSignatures({...o, obj: null}, [{}]); - if (s1 !== "?") { - // start new branch - const newBrn = { - s: currBrn.s, - params: [...currBrn.params], - objs: [...currBrn.objs] - }; - // finish old branch - currBrn.objs.unshift("?"); - currBrn.params.unshift(o); - // switch - brns.push(newBrn); - currBrn = brns[brns.length-1]; - } - // step - currBrn.objs.unshift(s1); - currBrn.params.unshift(...params1); - o = o.type === "!func" ? o.obj : null; - } - for (const brn of brns) { - params = [ - ...(brn?.params || []), - ...(args || []), - ]; - s = ""; - if (brn?.objs?.length) - s += brn.objs.join(".") + "."; - s += (isVar ? "#" : "") + methodName; - s += "(" + (args || []).map(_ => "?").join(", ") + ")"; - brn.s = s; - brn.params = params; - } - } else if (type === "!new") { - // new java.text.SimpleDateFormat('HH:mm:ss').parse('...') - params = args || []; - s = `new ${cls.join(".")}(${params.map(_ => "?").join(", ")})`; - } else if (type === "!type") { - // T(java.time.LocalTime).parse('...') - s = `T(${cls.join(".")})`; - } else if (type === "compound" && lastChild.type === "property" && isFuncableProperty(lastChild.val)) { - // {1,2}.length -- ok - // 'Hello World'.bytes.length -- will not work - s = children.map((c) => { - if (c === lastChild) - return c.val; - const [s1, params1] = _buildFuncSignatures({...c, obj: null}, [{}]); - params.push(...params1); - return s1; - }).join("."); - } else { - params = [spel]; - s = "?"; - } - - if (currBrn) { - currBrn.s = s; - currBrn.params = params; - } - - return [s, params]; -}; - -const convertFunc = (spel, conv, config, meta, parentSpel = null) => { - // Build signatures - const convertFuncArg = v => convertToTree(v, conv, config, meta, spel); - const fsigns = buildFuncSignatures(spel); - const firstSign = fsigns?.[0]?.s; - if (fsigns.length) - logger.debug("Signatures for ", spel, ":", firstSign, fsigns); - - // 1. Try to parse as value - let maybeValue = convertFuncToValue(spel, conv, config, meta, parentSpel, fsigns, convertFuncArg); - if (maybeValue !== undefined) - return maybeValue; - - // 2. Try to parse as op - let maybeOp = convertFuncToOp(spel, conv, config, meta, parentSpel, fsigns, convertFuncArg); - if (maybeOp !== undefined) - return maybeOp; - - // 3. Try to parse as func - let funcKey, funcConfig, argsObj; - // try func signature matching - for (const {s, params} of fsigns) { - const funcKeys = conv.funcs[s]; - if (funcKeys) { - // todo: here we can check arg types, if we have function overloading - funcKey = funcKeys[0]; - funcConfig = getFuncConfig(config, funcKey); - const {spelFunc} = funcConfig; - const argsArr = params.map(convertFuncArg); - const argsOrder = [...spelFunc.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); - argsObj = Object.fromEntries( - argsOrder.map((argKey, i) => [argKey, argsArr[i]]) - ); - break; - } - } - // try `spelImport` - if (!funcKey) { - for (const [f, fc] of iterateFuncs(config)) { - if (fc.spelImport) { - let parsed; - try { - parsed = fc.spelImport(spel); - } catch(_e) { - // can't be parsed - } - if (parsed) { - funcKey = f; - funcConfig = getFuncConfig(config, funcKey); - argsObj = {}; - for (let argKey in parsed) { - argsObj[argKey] = convertFuncArg(parsed[argKey]); - } - } - } - } - } - - // convert - if (funcKey) { - const funcArgs = {}; - for (let argKey in funcConfig.args) { - const argConfig = funcConfig.args[argKey]; - let argVal = argsObj[argKey]; - if (argVal === undefined) { - argVal = argConfig?.defaultValue; - if (argVal === undefined) { - if (argConfig?.isOptional) { - //ignore - } else { - meta.errors.push(`No value for arg ${argKey} of func ${funcKey}`); - return undefined; - } - } else { - argVal = { - value: argVal, - valueSrc: argVal?.func ? "func" : "value", - valueType: argConfig.type, - }; - } - } - if (argVal) - funcArgs[argKey] = argVal; - } - - return { - valueSrc: "func", - value: { - func: funcKey, - args: funcArgs - }, - valueType: funcConfig.returnType, - }; - } - - const {methodName} = spel; - if (methodName) - meta.errors.push(`Signature ${firstSign} - failed to convert`); - - return undefined; -}; - -const convertFuncToValue = (spel, conv, config, meta, parentSpel, fsigns, convertFuncArg) => { - let errs, foundSign, foundWidget; - const candidates = []; - - for (let w in config.widgets) { - const widgetDef = config.widgets[w]; - const {spelImportFuncs} = widgetDef; - if (spelImportFuncs) { - for (let i = 0 ; i < spelImportFuncs.length ; i++) { - const fj = spelImportFuncs[i]; - if (isObject(fj)) { - const bag = {}; - if (isJsonCompatible(fj, spel, bag)) { - for (const k in bag) { - bag[k] = convertFuncArg(bag[k]); - } - candidates.push({ - s: `widgets.${w}.spelImportFuncs[${i}]`, - w, - argsObj: bag, - }); - } - } - } - } - } - - for (const {s, params} of fsigns) { - const found = conv.valueFuncs[s] || []; - for (const {w, argsOrder} of found) { - const argsArr = params.map(convertFuncArg); - const argsObj = Object.fromEntries( - argsOrder.map((argKey, i) => [argKey, argsArr[i]]) - ); - candidates.push({ - s, - w, - argsObj, - }); - } - } - - for (const {s, w, argsObj} of candidates) { - const widgetDef = config.widgets[w]; - const {spelImportValue, type} = widgetDef; - foundWidget = w; - foundSign = s; - errs = []; - for (const k in argsObj) { - if (!["value"].includes(argsObj[k].valueSrc)) { - errs.push(`${k} has unsupported value src ${argsObj[k].valueSrc}`); - } - } - let value = argsObj.v.value; - if (spelImportValue && !errs.length) { - [value, errs] = spelImportValue.call(config.ctx, argsObj.v, widgetDef, argsObj); - if (errs && !Array.isArray(errs)) - errs = [errs]; - } - if (!errs.length) { - return { - valueSrc: "value", - valueType: type, - value, - }; - } - } - - if (foundWidget && errs.length) { - meta.errors.push(`Signature ${foundSign} - looks like convertable to ${foundWidget}, but: ${errs.join("; ")}`); - } - - return undefined; -}; - -const convertFuncToOp = (spel, conv, config, meta, parentSpel, fsigns, convertFuncArg) => { - let errs, opKey, foundSign; - for (const {s, params} of fsigns) { - const found = conv.opFuncs[s] || []; - for (const {op, argsOrder} of found) { - const argsArr = params.map(convertFuncArg); - opKey = op; - if (op === "!compare") { - if ( - parentSpel.type.startsWith("op-") - && parentSpel.children.length == 2 - && parentSpel.children[1].type == "number" - && parentSpel.children[1].val === 0 - ) { - return { - type: "!compare", - children: argsArr, - }; - } else { - errs.push("Result of compareTo() should be compared to 0"); - } - } - foundSign = s; - errs = []; - const opDef = config.operators[opKey]; - const {spelOp, valueTypes} = opDef; - const argsObj = Object.fromEntries( - argsOrder.map((argKey, i) => [argKey, argsArr[i]]) - ); - const field = argsObj["0"]; - const convertedArgs = Object.keys(argsObj).filter(k => parseInt(k) > 0).map(k => argsObj[k]); - - const valueType = argsArr.filter(a => !!a).find(({valueSrc}) => valueSrc === "value")?.valueType; - if (valueTypes && valueType && !valueTypes.includes(valueType)) { - errs.push(`Op supports types ${valueTypes}, but got ${valueType}`); - } - if (!errs.length) { - return buildRule(config, meta, field, opKey, convertedArgs, spel); - } - } - } - - if (opKey && errs.length) { - meta.errors.push(`Signature ${foundSign} - looks like convertable to ${opKey}, but: ${errs.join("; ")}`); - } - - return undefined; -}; - -const buildRule = (config, meta, field, opKey, convertedArgs, spel) => { - if (convertedArgs.filter(v => v === undefined).length) { - return undefined; - } - let fieldSrc = field?.func ? "func" : "field"; - if (isObject(field) && field.valueSrc) { - // if comed from convertFuncToOp() - fieldSrc = field.valueSrc; - field = field.value; - } - const fieldConfig = getFieldConfig(config, field); - if (!fieldConfig) { - meta.errors.push(`No config for field ${field}`); - return undefined; - } - - const parentFieldConfig = getFieldConfig(config, spel?._groupField); - const isRuleGroup = fieldConfig.type == "!group"; - const isGroupArray = isRuleGroup && fieldConfig.mode == "array"; - const isInRuleGroup = parentFieldConfig?.type == "!group"; - - let opConfig = config.operators[opKey]; - const reversedOpConfig = config.operators[opConfig?.reversedOp]; - const opNeedsReverse = spel?.not && ["between"].includes(opKey); - const opCanReverse = !!reversedOpConfig; - const canRev = opCanReverse && ( - !!config.settings.reverseOperatorsForNot - || opNeedsReverse - || !isRuleGroup && isInRuleGroup // 2+ rules in rule-group should be flat. see inits.with_not_and_in_some in test - ); - const needRev = spel?.not && canRev || opNeedsReverse; - if (needRev) { - // todo: should be already handled at convertOp ? or there are special cases to handle here, like rule-group ? - opKey = opConfig.reversedOp; - opConfig = config.operators[opKey]; - spel.not = !spel.not; - } - const needWrapWithNot = !!spel?.not; - - const widget = getWidgetForFieldOp(config, field, opKey); - const widgetConfig = config.widgets[widget || fieldConfig.mainWidget]; - const asyncListValuesArr = convertedArgs.map(v => v.asyncListValues).filter(v => v != undefined); - const asyncListValues = asyncListValuesArr.length ? asyncListValuesArr[0] : undefined; - - let res = { - type: "rule", - id: uuid(), - properties: { - field, - fieldSrc, - operator: opKey, - value: convertedArgs.map(v => v.value), - valueSrc: convertedArgs.map(v => v.valueSrc), - valueType: convertedArgs.map(v => { - if (v.valueSrc == "value") { - return widgetConfig?.type || fieldConfig?.type || v.valueType; - } - return v.valueType; - }), - ...(asyncListValues ? {asyncListValues} : {}), - } - }; - - if (needWrapWithNot) { - res = wrapInDefaultConj(res, config, spel.not); - // spel.not = !spel.not; // why I added this line? - } - - return res; -}; - -const buildRuleGroup = ({groupFilter, groupFieldValue}, opKey, convertedArgs, config, meta) => { - if (groupFieldValue.valueSrc != "field") - throw `Bad groupFieldValue: ${JSON.stringify(groupFieldValue)}`; - const groupField = groupFieldValue.value; - let groupOpRule = buildRule(config, meta, groupField, opKey, convertedArgs); - if (!groupOpRule) - return undefined; - const fieldConfig = getFieldConfig(config, groupField); - const mode = fieldConfig?.mode; - let res; - - if (groupFilter?.type === "group") { - res = { - ...(groupFilter || {}), - type: "rule_group", - properties: { - ...groupOpRule.properties, - ...(groupFilter?.properties || {}), - mode - } - }; - } else if (groupFilter) { - // rule_group in rule_group - res = { - ...(groupOpRule || {}), - type: "rule_group", - children1: [ groupFilter ], - properties: { - ...groupOpRule.properties, - mode - } - }; - } else { - res = { - ...(groupOpRule || {}), - type: "rule_group", - properties: { - ...groupOpRule.properties, - mode - } - }; - } - - if (!res.id) - res.id = uuid(); - return res; -}; - - -const compareArgs = (left, right, spel, conv, config, meta) => { - if (left.type == right.type) { - if (left.type == "!aggr") { - const [leftSource, rightSource] = [left.source, right.source].map(v => convertArg(v, conv, config, meta, spel)); - //todo: check same filter - return leftSource.value == rightSource.value; - } else { - const [leftVal, rightVal] = [left, right].map(v => convertArg(v, conv, config, meta, spel)); - return leftVal.value == rightVal.value; - } - } - return false; -}; - -const buildSimpleSwitch = (val, conv, config, meta) => { - let children1 = {}; - const cond = null; - const caseI = buildCase(cond, val, conv, config, meta); - if (caseI) { - children1[caseI.id] = caseI; - } - let res = { - type: "switch_group", - id: uuid(), - children1, - properties: {} - }; - return res; -}; - -const buildCase = (cond, val, conv, config, meta, spel = null) => { - const valProperties = buildCaseValProperties(config, meta, conv, val, spel); - - let caseI; - if (cond) { - caseI = convertToTree(cond, conv, config, meta, spel); - if (caseI && caseI.type) { - if (caseI.type != "group") { - caseI = wrapInDefaultConj(caseI, config); - } - caseI.type = "case_group"; - } else { - meta.errors.push(`Unexpected case: ${JSON.stringify(caseI)}`); - caseI = undefined; - } - } else { - caseI = { - id: uuid(), - type: "case_group", - properties: {} - }; - } - - if (caseI) { - caseI.properties = { - ...caseI.properties, - ...valProperties - }; - } - - return caseI; -}; - -/** - * @deprecated - */ -const buildCaseValueConcat = (spel, conv, config, meta) => { - let flat = []; - function _processConcatChildren(children) { - children.map(child => { - if (child.type === "op-plus") { - _processConcatChildren(child.children); - } else { - const convertedChild = convertArg(child, conv, config, meta, spel); - if (convertedChild) { - flat.push(convertedChild); - } else { - meta.errors.push(`Can't convert ${child.type} in concatenation`); - } - } - }); - } - _processConcatChildren(spel.children); - return { - valueSrc: "value", - valueType: "case_value", - value: flat, - }; -}; - -const buildCaseValProperties = (config, meta, conv, val, spel = null) => { - let valProperties = {}; - let convVal; - let widget; - let widgetConfig; - const caseValueFieldConfig = getFieldConfig(config, "!case_value"); - if (val?.type === "op-plus" && config.settings.caseValueField?.type === "case_value") { - /** - * @deprecated - */ - widget = "case_value"; - convVal = buildCaseValueConcat(val, conv, config, meta); - } else { - widget = caseValueFieldConfig?.mainWidget; - widgetConfig = config.widgets[widget]; - convVal = convertArg(val, conv, config, meta, spel); - if (convVal && convVal.valueSrc === "value") { - convVal.valueType = widgetConfig?.type || caseValueFieldConfig?.type || convVal.valueType; - } - } - const widgetDef = config.widgets[widget]; - if (widget === "case_value") { - /** - * @deprecated - */ - const importCaseValue = widgetDef?.spelImportValue; - if (importCaseValue) { - const [normVal, normErrors] = importCaseValue.call(config.ctx, convVal); - normErrors.map(e => meta.errors.push(e)); - if (normVal != undefined) { - valProperties = { - value: [normVal], - valueSrc: ["value"], - valueType: [widgetDef?.type ?? "case_value"], - field: "!case_value", - }; - } - } - } else if (convVal != undefined && convVal?.value != undefined) { - valProperties = { - value: [convVal.value], - valueSrc: [convVal.valueSrc], - valueType: [convVal.valueType], - field: "!case_value", - }; - } - return valProperties; -}; - -// const wrapInDefaultConjRuleGroup = (rule, groupField, groupFieldConfig, config, conj) => { -// if (!rule) return undefined; -// return { -// type: "rule_group", -// id: uuid(), -// children1: { [rule.id]: rule }, -// properties: { -// conjunction: conj || defaultGroupConjunction(config, groupFieldConfig), -// not: false, -// field: groupField, -// } -// }; -// }; - -const wrapInDefaultConj = (rule, config, not = false) => { - return { - type: "group", - id: uuid(), - children1: { [rule.id]: rule }, - properties: { - conjunction: defaultGroupConjunction(config), - not: not || false - } - }; -}; diff --git a/packages/core/modules/import/spel/builder.js b/packages/core/modules/import/spel/builder.js new file mode 100644 index 000000000..d09ba7031 --- /dev/null +++ b/packages/core/modules/import/spel/builder.js @@ -0,0 +1,248 @@ +import * as Utils from "../../utils"; + +const { isObject, uuid } = Utils.OtherUtils; +const { defaultConjunction, defaultGroupConjunction } = Utils.DefaultUtils; +const { getFieldConfig, getWidgetForFieldOp } = Utils.ConfigUtils; + + +// export const wrapInDefaultConjRuleGroup = (rule, parentField, parentFieldConfig, config, conj) => { +// if (!rule) return undefined; +// return { +// type: "rule_group", +// id: uuid(), +// children1: { [rule.id]: rule }, +// properties: { +// conjunction: conj || defaultGroupConjunction(config, parentFieldConfig), +// not: false, +// field: parentField, +// } +// }; +// }; + +export const wrapInDefaultConj = (rule, config, not = false) => { + return { + type: "group", + id: uuid(), + children1: { [rule.id]: rule }, + properties: { + conjunction: defaultConjunction(config), + not: not || false + } + }; +}; + +export const buildCase = (convCond, convVal, conv, config, meta, spel = null) => { + const valProperties = buildCaseValProperties(config, meta, conv, convVal, spel); + + let caseI; + if (convCond) { + caseI = convCond; + if (caseI.type) { + if (caseI.type != "group" && caseI.type != "case_group") { + caseI = wrapInDefaultConj(caseI, config); + } + caseI.type = "case_group"; + } else { + meta.errors.push(`Unexpected case: ${JSON.stringify(caseI)}`); + caseI = undefined; + } + } else { + caseI = { + id: uuid(), + type: "case_group", + properties: {} + }; + } + + if (caseI) { + caseI.properties = { + ...caseI.properties, + ...valProperties + }; + } + + return caseI; +}; + + +export const buildSimpleSwitch = (convVal, conv, config, meta) => { + let children1 = {}; + const caseI = buildCase(null, convVal, conv, config, meta); + if (caseI) { + children1[caseI.id] = caseI; + } + let res = { + type: "switch_group", + id: uuid(), + children1, + properties: {} + }; + return res; +}; + +export const buildRuleGroup = ({groupFilter, groupFieldValue}, opKey, convertedArgs, config, meta) => { + if (groupFieldValue.valueSrc != "field") + throw `Bad groupFieldValue: ${JSON.stringify(groupFieldValue)}`; + const groupField = groupFieldValue.value; + let groupOpRule = buildRule(config, meta, groupField, opKey, convertedArgs); + if (!groupOpRule) + return undefined; + const fieldConfig = getFieldConfig(config, groupField); + const mode = fieldConfig?.mode; + let res; + + if (groupFilter?.type === "group") { + res = { + ...(groupFilter || {}), + type: "rule_group", + properties: { + ...groupOpRule.properties, + ...(groupFilter?.properties || {}), + mode + } + }; + } else if (groupFilter) { + // rule_group in rule_group + res = { + ...(groupOpRule || {}), + type: "rule_group", + children1: [ groupFilter ], + properties: { + ...groupOpRule.properties, + mode + } + }; + } else { + res = { + ...(groupOpRule || {}), + type: "rule_group", + properties: { + ...groupOpRule.properties, + mode + } + }; + } + + if (!res.id) + res.id = uuid(); + return res; +}; + + + +export const buildCaseValProperties = (config, meta, conv, convVal, spel = null) => { + let valProperties = {}; + let widget; + let widgetConfig; + const caseValueFieldConfig = getFieldConfig(config, "!case_value"); + if (convVal?.valueType === "case_value") { + /** + * @deprecated + */ + widget = "case_value"; + } else { + widget = caseValueFieldConfig?.mainWidget; + widgetConfig = config.widgets[widget]; + if (convVal && convVal.valueSrc === "value") { + convVal.valueType = widgetConfig?.type || caseValueFieldConfig?.type || convVal.valueType; + } + } + const widgetDef = config.widgets[widget]; + if (widget === "case_value") { + /** + * @deprecated + */ + const importCaseValue = widgetDef?.spelImportValue; + if (importCaseValue) { + const [normVal, normErrors] = importCaseValue.call(config.ctx, convVal); + normErrors.map(e => meta.errors.push(e)); + if (normVal != undefined) { + valProperties = { + value: [normVal], + valueSrc: ["value"], + valueType: [widgetDef?.type ?? "case_value"], + field: "!case_value", + }; + } + } + } else if (convVal != undefined && convVal?.value != undefined) { + valProperties = { + value: [convVal.value], + valueSrc: [convVal.valueSrc], + valueType: [convVal.valueType], + field: "!case_value", + }; + } + return valProperties; +}; + +export const buildRule = (config, meta, field, opKey, convertedArgs, spel) => { + if (convertedArgs.filter(v => v === undefined).length) { + return undefined; + } + let fieldSrc = field?.func ? "func" : "field"; + if (isObject(field) && field.valueSrc) { + // if comed from convertFuncToOp() + fieldSrc = field.valueSrc; + field = field.value; + } + const fieldConfig = getFieldConfig(config, field); + if (!fieldConfig) { + meta.errors.push(`No config for field ${field}`); + return undefined; + } + + const parentFieldConfig = getFieldConfig(config, spel?._groupField); + const isRuleGroup = fieldConfig.type == "!group"; + const isGroupArray = isRuleGroup && fieldConfig.mode == "array"; + const isInRuleGroup = parentFieldConfig?.type == "!group"; + + let opConfig = config.operators[opKey]; + const reversedOpConfig = config.operators[opConfig?.reversedOp]; + const opNeedsReverse = spel?.not && ["between"].includes(opKey); + const opCanReverse = !!reversedOpConfig; + const canRev = opCanReverse && ( + !!config.settings.reverseOperatorsForNot + || opNeedsReverse + || !isRuleGroup && isInRuleGroup // 2+ rules in rule-group should be flat. see inits.with_not_and_in_some in test + ); + const needRev = spel?.not && canRev || opNeedsReverse; + if (needRev) { + // todo: should be already handled at convertOp ? or there are special cases to handle here, like rule-group ? + opKey = opConfig.reversedOp; + opConfig = config.operators[opKey]; + spel.not = !spel.not; + } + const needWrapWithNot = !!spel?.not; + + const widget = getWidgetForFieldOp(config, field, opKey); + const widgetConfig = config.widgets[widget || fieldConfig.mainWidget]; + const asyncListValuesArr = convertedArgs.map(v => v.asyncListValues).filter(v => v != undefined); + const asyncListValues = asyncListValuesArr.length ? asyncListValuesArr[0] : undefined; + + let res = { + type: "rule", + id: uuid(), + properties: { + field, + fieldSrc, + operator: opKey, + value: convertedArgs.map(v => v.value), + valueSrc: convertedArgs.map(v => v.valueSrc), + valueType: convertedArgs.map(v => { + if (v.valueSrc == "value") { + return widgetConfig?.type || fieldConfig?.type || v.valueType; + } + return v.valueType; + }), + ...(asyncListValues ? {asyncListValues} : {}), + } + }; + + if (needWrapWithNot) { + res = wrapInDefaultConj(res, config, spel.not); + // spel.not = !spel.not; // why I added this line? + } + + return res; +}; diff --git a/packages/core/modules/import/spel/conv.js b/packages/core/modules/import/spel/conv.js new file mode 100644 index 000000000..ced069286 --- /dev/null +++ b/packages/core/modules/import/spel/conv.js @@ -0,0 +1,225 @@ +import * as Utils from "../../utils"; +import { compareToSign } from "../../export/spel"; + +const { iterateFuncs } = Utils.ConfigUtils; +const { logger } = Utils.OtherUtils; + +const isFuncableProperty = (p) => ["length"].includes(p); + +export const manuallyImportedOps = [ + "between", "not_between", "is_empty", "is_not_empty", "is_null", "is_not_null", + "some", "all", "none", +]; +export const unsupportedOps = ["proximity"]; + + +export const buildConv = (config) => { + let operators = {}; + for (let opKey in config.operators) { + const opConfig = config.operators[opKey]; + // const isGroupOp = config.settings.groupOperators?.includes(opKey); + const spelOps = opConfig.spelOps ? opConfig.spelOps : opConfig.spelOp ? [opConfig.spelOp] : undefined; + if (spelOps) { + // examples of 2+: "==", "eq", ".contains", "matches" (can be used for starts_with, ends_with) + spelOps.forEach(spelOp => { + const opk = spelOp; // + "/" + getOpCardinality(opConfig); + if (!operators[opk]) + operators[opk] = []; + operators[opk].push(opKey); + }); + } else { + const revOpConfig = config.operators?.[opConfig.reversedOp]; + const canUseRev = revOpConfig?.spelOp || revOpConfig?.spelOps; + const canIgnore = canUseRev + || manuallyImportedOps.includes(opKey) || manuallyImportedOps.includes(opConfig.reversedOp) + || unsupportedOps.includes(opKey); + if (!canIgnore) { + logger.warn(`[spel] No spelOp for operator ${opKey}`); + } + } + } + + let conjunctions = {}; + for (let conjKey in config.conjunctions) { + const conjunctionDefinition = config.conjunctions[conjKey]; + const ck = conjunctionDefinition.spelConj || conjKey.toLowerCase(); + conjunctions[ck] = conjKey; + } + + let funcs = {}; + for (const [funcPath, funcConfig] of iterateFuncs(config)) { + let fks = []; + const {spelFunc} = funcConfig; + if (typeof spelFunc === "string") { + const optionalArgs = Object.keys(funcConfig.args || {}) + .reverse() + .filter(argKey => !!funcConfig.args[argKey].isOptional || funcConfig.args[argKey].defaultValue != undefined); + const funcSignMain = spelFunc + .replace(/\${(\w+)}/g, (_, _k) => "?"); + const funcSignsOptional = optionalArgs + .reduce((acc, argKey) => ( + [ + ...acc, + [ + argKey, + ...(acc[acc.length-1] || []), + ] + ] + ), []) + .map(optionalArgKeys => ( + spelFunc + .replace(/(?:, )?\${(\w+)}/g, (found, a) => ( + optionalArgKeys.includes(a) ? "" : found + )) + .replace(/\${(\w+)}/g, (_, _k) => "?") + )); + fks = [ + funcSignMain, + ...funcSignsOptional, + ]; + } + for (const fk of fks) { + if (!funcs[fk]) + funcs[fk] = []; + funcs[fk].push(funcPath); + } + } + + let valueFuncs = {}; + for (let w in config.widgets) { + const widgetDef = config.widgets[w]; + const {spelImportFuncs} = widgetDef; + if (spelImportFuncs) { + for (const fk of spelImportFuncs) { + if (typeof fk === "string") { + const fs = fk.replace(/\${(\w+)}/g, (_, k) => "?"); + const argsOrder = [...fk.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); + if (!valueFuncs[fs]) + valueFuncs[fs] = []; + valueFuncs[fs].push({ + w, + argsOrder + }); + } + } + } + } + + let opFuncs = {}; + for (let op in config.operators) { + const opDef = config.operators[op]; + const spelOps = opDef.spelOps ? opDef.spelOps : opDef.spelOp ? [opDef.spelOp] : undefined; + spelOps?.forEach(spelOp => { + if (spelOp?.includes("${0}")) { + const fs = spelOp.replace(/\${(\w+)}/g, (_, k) => "?"); + const argsOrder = [...spelOp.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); + if (!opFuncs[fs]) + opFuncs[fs] = []; + opFuncs[fs].push({ + op, + argsOrder + }); + } + }); + } + // Special .compareTo() + const compareToSS = compareToSign.replace(/\${(\w+)}/g, (_, k) => "?"); + opFuncs[compareToSS] = [{ + op: "!compare", + argsOrder: ["0", "1"] + }]; + + return { + operators, + conjunctions, + funcs, + valueFuncs, + opFuncs, + }; +}; + +export const buildFuncSignatures = (spel) => { + // branches + const brns = [ + {s: "", params: [], objs: []} + ]; + _buildFuncSignatures(spel, brns); + return brns.map(({s, params}) => ({s, params})).reverse().filter(({s}) => s !== "" && s !== "?"); +}; + +// a.toLower().toUpper() +// -> +// ?.toLower().toUpper() +// ?.toUpper() +const _buildFuncSignatures = (spel, brns) => { + let params = [], s = ""; + const { type, methodName, val, obj, args, isVar, cls, children } = spel; + const lastChild = children?.[children.length-1]; + let currBrn = brns[brns.length-1]; + if (type === "!func") { + // T(DateTimeFormat).forPattern(?).parseDateTime(?) -- ok + // T(LocalDateTime).parse(?, T(DateTimeFormatter).ofPattern(?)) -- will not work + let o = obj; + while (o) { + const [s1, params1] = _buildFuncSignatures({...o, obj: null}, [{}]); + if (s1 !== "?") { + // start new branch + const newBrn = { + s: currBrn.s, + params: [...currBrn.params], + objs: [...currBrn.objs] + }; + // finish old branch + currBrn.objs.unshift("?"); + currBrn.params.unshift(o); + // switch + brns.push(newBrn); + currBrn = brns[brns.length-1]; + } + // step + currBrn.objs.unshift(s1); + currBrn.params.unshift(...params1); + o = o.type === "!func" ? o.obj : null; + } + for (const brn of brns) { + params = [ + ...(brn?.params || []), + ...(args || []), + ]; + s = ""; + if (brn?.objs?.length) + s += brn.objs.join(".") + "."; + s += (isVar ? "#" : "") + methodName; + s += "(" + (args || []).map(_ => "?").join(", ") + ")"; + brn.s = s; + brn.params = params; + } + } else if (type === "!new") { + // new java.text.SimpleDateFormat('HH:mm:ss').parse('...') + params = args || []; + s = `new ${cls.join(".")}(${params.map(_ => "?").join(", ")})`; + } else if (type === "!type") { + // T(java.time.LocalTime).parse('...') + s = `T(${cls.join(".")})`; + } else if (type === "compound" && lastChild.type === "property" && isFuncableProperty(lastChild.val)) { + // {1,2}.length -- ok + // 'Hello World'.bytes.length -- will not work + s = children.map((c) => { + if (c === lastChild) + return c.val; + const [s1, params1] = _buildFuncSignatures({...c, obj: null}, [{}]); + params.push(...params1); + return s1; + }).join("."); + } else { + params = [spel]; + s = "?"; + } + + if (currBrn) { + currBrn.s = s; + currBrn.params = params; + } + + return [s, params]; +}; diff --git a/packages/core/modules/import/spel/convert.js b/packages/core/modules/import/spel/convert.js new file mode 100644 index 000000000..a995c5e6c --- /dev/null +++ b/packages/core/modules/import/spel/convert.js @@ -0,0 +1,625 @@ +import { wrapInDefaultConj, buildCase, buildSimpleSwitch, buildRuleGroup, buildRule } from "./builder"; +import { convertPath } from "./postprocess"; +import { buildFuncSignatures } from "./conv"; +import * as Utils from "../../utils"; + +const { isJsonCompatible, isObject, uuid, logger } = Utils.OtherUtils; +const { getFieldConfig, getFuncConfig, normalizeField, iterateFuncs, getWidgetForFieldOp } = Utils.ConfigUtils; + +// spel type => raqb type +const SpelPrimitiveTypes = { + number: "number", + string: "text", + boolean: "boolean", + null: "null" // should not be +}; +// spel class => raqb type +const SpelPrimitiveClasses = { + String: "text", +}; +const ListValueType = "multiselect"; + + +export const convertToTree = (spel, conv, config, meta, parentSpel = null) => { + if (!spel) return undefined; + spel._groupField = spel._groupField ?? parentSpel?._groupField; + + let res, canParseAsArg = true; + if (spel.type.indexOf("op-") === 0 || spel.type === "matches") { + res = convertOp(spel, conv, config, meta, parentSpel); + } else if (spel.type == "!aggr") { + const groupFieldValue = convertToTree(spel.source, conv, config, meta, spel); + spel._groupField = groupFieldValue?.value; + let groupFilter = convertToTree(spel.filter, conv, config, meta, spel); + if (groupFilter?.type == "rule") { + groupFilter = wrapInDefaultConj(groupFilter, config, spel.filter.not); + } + res = { + groupFilter, + groupFieldValue + }; + if (!parentSpel) { + // !aggr can't be in root, it should be compared with something + res = undefined; + meta.errors.push("Unexpected !aggr in root"); + canParseAsArg = false; + } + } else if (spel.type == "ternary") { + const children1 = {}; + spel.val.forEach(v => { + const [cond, val] = v; + const convCond = convertToTree(cond, conv, config, meta, spel); + const convVal = convertCaseValue(val, conv, config, meta, spel); + const caseI = buildCase(convCond, convVal, conv, config, meta, spel); + if (caseI) { + children1[caseI.id] = caseI; + } + }); + res = { + type: "switch_group", + id: uuid(), + children1, + properties: {} + }; + } + + if (!res && canParseAsArg) { + res = convertArg(spel, conv, config, meta, parentSpel); + } + + if (res && !res.type && !parentSpel) { + // res is not a rule, it's value at root + // try to parse whole `"1"` as ternary + const convVal = convertCaseValue(spel, conv, config, meta); + const sw = buildSimpleSwitch(convVal, conv, config, meta); + if (sw) { + res = sw; + } else { + res = undefined; + meta.errors.push(`Can't convert rule of type ${spel.type}, it looks like var/literal`); + } + } + + return res; +}; + +const convertOp = (spel, conv, config, meta, parentSpel = null) => { + let res; + + let op = spel.type.startsWith("op-") ? spel.type.slice("op-".length) : spel.type; + + // unary + const isUnary = (op == "minus" || op == "plus") && spel.children.length == 1; + if (isUnary) { + let negative = spel.negative; + if (op == "minus") { + negative = !negative; + } + spel.children[0].negative = negative; + return convertToTree(spel.children[0], conv, config, meta, parentSpel); + } + + // between + const isBetweenNormal = (op == "and" && spel.children.length == 2 && spel.children[0].type == "op-ge" && spel.children[1].type == "op-le"); + const isBetweenRev = (op == "or" && spel.children.length == 2 && spel.children[0].type == "op-lt" && spel.children[1].type == "op-gt"); + const isBetween = isBetweenNormal || isBetweenRev; + if (isBetween) { + const [left, from] = spel.children[0].children; + const [right, to] = spel.children[1].children; + const isSameSource = compareArgs(left, right, spel, conv, config, meta, parentSpel); + if (isSameSource) { + const _fromValue = from.val; + const _toValue = to.val; + const oneSpel = { + type: "op-between", + children: [ + left, + from, + to + ], + not: isBetweenRev, + }; + oneSpel._groupField = parentSpel?._groupField; + return convertOp(oneSpel, conv, config, meta, parentSpel); + } + } + + // find op + let opKeys = conv.operators[op]; + if (op == "eq" && spel.children[1].type == "null") { + opKeys = ["is_null"]; + } else if (op == "ne" && spel.children[1].type == "null") { + opKeys = ["is_not_null"]; + } else if (op == "le" && spel.children[1].type == "string" && spel.children[1].val == "") { + opKeys = ["is_empty"]; + } else if (op == "gt" && spel.children[1].type == "string" && spel.children[1].val == "") { + opKeys = ["is_not_empty"]; + } else if (op == "between") { + opKeys = ["between"]; + } + + // convert children + const convertChildren = () => { + let newChildren = spel.children.map(child => + convertToTree(child, conv, config, meta, spel) + ); + if (newChildren.length >= 2 && newChildren?.[0]?.type == "!compare") { + newChildren = newChildren[0].children; + } + return newChildren; + }; + if (op == "and" || op == "or") { + const children1 = {}; + const vals = convertChildren(); + vals.forEach(v => { + if (v) { + const id = uuid(); + v.id = id; + if (v.type != undefined) { + children1[id] = v; + } else { + meta.errors.push(`Bad item in AND/OR: ${JSON.stringify(v)}`); + } + } + }); + res = { + type: "group", + id: uuid(), + children1, + properties: { + conjunction: conv.conjunctions[op], + not: spel.not + } + }; + } else if (opKeys) { + const vals = convertChildren(); + const fieldObj = vals[0]; + let convertedArgs = vals.slice(1); + const groupField = fieldObj?.groupFieldValue?.value; + const opArg = convertedArgs?.[0]; + + let opKey = opKeys[0]; + if (opKeys.length > 1) { + const valueType = vals[0]?.valueType || vals[1]?.valueType; + //todo: it's naive, use valueType + const field = fieldObj?.value; + const widgets = opKeys.map(op => ({op, widget: getWidgetForFieldOp(config, field, op)})); + + if (op == "eq" || op == "ne") { + const ws = widgets.find(({ op, widget }) => (widget && widget != "field")); + if (ws) { + opKey = ws.op; + } + } else { + logger.warn(`[spel] Spel operator ${op} can be mapped to ${opKeys}.`, + "widgets:", widgets, "vals:", vals, "valueType=", valueType); + } + } + + // some/all/none + if (fieldObj?.groupFieldValue) { + if (opArg && opArg.groupFieldValue && opArg.groupFieldValue.valueSrc == "field" && opArg.groupFieldValue.value == groupField) { + // group.?[...].size() == group.size() + opKey = "all"; + convertedArgs = []; + } else if (opKey == "equal" && opArg.valueSrc == "value" && opArg.valueType == "number" && opArg.value == 0) { + opKey = "none"; + convertedArgs = []; + } else if (opKey == "greater" && opArg.valueSrc == "value" && opArg.valueType == "number" && opArg.value == 0) { + opKey = "some"; + convertedArgs = []; + } + } + + let opConfig = config.operators[opKey]; + const reversedOpConfig = config.operators[opConfig?.reversedOp]; + const opNeedsReverse = spel.not && ["between"].includes(opKey); + const opCanReverse = !!reversedOpConfig; + const canRev = opCanReverse && (!!config.settings.reverseOperatorsForNot || opNeedsReverse); + const needRev = spel.not && canRev || opNeedsReverse; + if (needRev) { + opKey = opConfig.reversedOp; + opConfig = config.operators[opKey]; + spel.not = !spel.not; + } + const needWrapWithNot = !!spel.not; + spel.not = false; // handled with needWrapWithNot + + if (!fieldObj) { + // LHS can't be parsed + } else if (fieldObj.groupFieldValue) { + // 1. group + if (fieldObj.groupFieldValue.valueSrc != "field") { + meta.errors.push(`Expected group field ${JSON.stringify(fieldObj)}`); + } + + res = buildRuleGroup(fieldObj, opKey, convertedArgs, config, meta); + } else { + // 2. not group + if (fieldObj.valueSrc != "field" && fieldObj.valueSrc != "func") { + meta.errors.push(`Expected field/func at LHS, but got ${JSON.stringify(fieldObj)}`); + } + const field = fieldObj.value; + res = buildRule(config, meta, field, opKey, convertedArgs, spel); + } + + if (needWrapWithNot) { + if (res.type !== "group") { + res = wrapInDefaultConj(res, config, true); + } else { + res.properties.not = !res.properties.not; + } + } + } else { + if (!parentSpel) { + // try to parse whole `"str" + prop + #var` as ternary + const convVal = convertCaseValue(spel, conv, config, meta); + res = buildSimpleSwitch(convVal, conv, config, meta); + } + // if (!res) { + // meta.errors.push(`Can't convert op ${op}`); + // } + } + return res; +}; + + +export const convertArg = (spel, conv, config, meta, parentSpel = null) => { + if (spel == undefined) + return undefined; + const {fieldSeparator} = config.settings; + spel._groupField = spel._groupField ?? parentSpel?._groupField; + + if (spel.type == "variable" || spel.type == "property") { + // normal field + const field = normalizeField(config, spel.val, spel._groupField); + const fieldConfig = getFieldConfig(config, field); + const isVariable = spel.type == "variable"; + return { + valueSrc: "field", + valueType: fieldConfig?.type, + isVariable, + value: field, + }; + } else if (spel.type == "compound") { + // complex field + const parts = convertPath(spel.children, meta); + if (parts) { + const field = normalizeField(config, parts.join(fieldSeparator), spel._groupField); + const fieldConfig = getFieldConfig(config, field); + const isVariable = spel.children?.[0]?.type == "variable"; + return { + valueSrc: "field", + valueType: fieldConfig?.type, + isVariable, + value: field, + }; + } else { + // it's not complex field + } + } else if (SpelPrimitiveTypes[spel.type]) { + let value = spel.val; + const valueType = SpelPrimitiveTypes[spel.type]; + if (spel.negative) { + value = -value; + } + return { + valueSrc: "value", + valueType, + value, + }; + } else if (spel.type == "!new" && SpelPrimitiveClasses[spel.cls.at(-1)]) { + const args = spel.args.map(v => convertArg(v, conv, config, meta, spel)); + const value = args?.[0]; + const valueType = SpelPrimitiveClasses[spel.cls.at(-1)]; + return { + ...value, + valueType, + }; + } else if (spel.type == "list") { + const values = spel.val.map(v => convertArg(v, conv, config, meta, spel)); + const _itemType = values.length ? values[0]?.valueType : null; + const value = values.map(v => v?.value); + const valueType = ListValueType; + return { + valueSrc: "value", + valueType, + value, + }; + } else if (spel.type === "op-plus" && parentSpel?.type === "ternary" && config.settings.caseValueField?.type === "case_value") { + /** + * @deprecated + */ + return convertCaseValueConcat(spel, conv, config, meta); + } + + let maybe = convertFunc(spel, conv, config, meta, parentSpel); + if (maybe !== undefined) { + return maybe; + } + + meta.errors.push(`Can't convert arg of type ${spel.type}`); + return undefined; +}; + + + +const convertFunc = (spel, conv, config, meta, parentSpel = null) => { + // Build signatures + const convertFuncArg = v => convertToTree(v, conv, config, meta, spel); + const fsigns = buildFuncSignatures(spel); + const firstSign = fsigns?.[0]?.s; + if (fsigns.length) + logger.debug("Signatures for ", spel, ":", firstSign, fsigns); + + // 1. Try to parse as value + let maybeValue = convertFuncToValue(spel, conv, config, meta, parentSpel, fsigns, convertFuncArg); + if (maybeValue !== undefined) + return maybeValue; + + // 2. Try to parse as op + let maybeOp = convertFuncToOp(spel, conv, config, meta, parentSpel, fsigns, convertFuncArg); + if (maybeOp !== undefined) + return maybeOp; + + // 3. Try to parse as func + let funcKey, funcConfig, argsObj; + // try func signature matching + for (const {s, params} of fsigns) { + const funcKeys = conv.funcs[s]; + if (funcKeys) { + // todo: here we can check arg types, if we have function overloading + funcKey = funcKeys[0]; + funcConfig = getFuncConfig(config, funcKey); + const {spelFunc} = funcConfig; + const argsArr = params.map(convertFuncArg); + const argsOrder = [...spelFunc.matchAll(/\${(\w+)}/g)].map(([_, k]) => k); + argsObj = Object.fromEntries( + argsOrder.map((argKey, i) => [argKey, argsArr[i]]) + ); + break; + } + } + // try `spelImport` + if (!funcKey) { + for (const [f, fc] of iterateFuncs(config)) { + if (fc.spelImport) { + let parsed; + try { + parsed = fc.spelImport.call(config.ctx, spel); + } catch(_e) { + // can't be parsed + } + if (parsed) { + funcKey = f; + funcConfig = getFuncConfig(config, funcKey); + argsObj = {}; + for (let argKey in parsed) { + argsObj[argKey] = convertFuncArg(parsed[argKey]); + } + } + } + } + } + + // convert + if (funcKey) { + const funcArgs = {}; + for (let argKey in funcConfig.args) { + const argConfig = funcConfig.args[argKey]; + let argVal = argsObj[argKey]; + if (argVal === undefined) { + argVal = argConfig?.defaultValue; + if (argVal === undefined) { + if (argConfig?.isOptional) { + //ignore + } else { + meta.errors.push(`No value for arg ${argKey} of func ${funcKey}`); + return undefined; + } + } else { + argVal = { + value: argVal, + valueSrc: argVal?.func ? "func" : "value", + valueType: argConfig.type, + }; + } + } + if (argVal) + funcArgs[argKey] = argVal; + } + + return { + valueSrc: "func", + value: { + func: funcKey, + args: funcArgs + }, + valueType: funcConfig.returnType, + }; + } + + const {methodName} = spel; + if (methodName) + meta.errors.push(`Signature ${firstSign} - failed to convert`); + + return undefined; +}; + +const convertFuncToValue = (spel, conv, config, meta, parentSpel, fsigns, convertFuncArg) => { + let errs, foundSign, foundWidget; + const candidates = []; + + for (let w in config.widgets) { + const widgetDef = config.widgets[w]; + const {spelImportFuncs} = widgetDef; + if (spelImportFuncs) { + for (let i = 0 ; i < spelImportFuncs.length ; i++) { + const fj = spelImportFuncs[i]; + if (isObject(fj)) { + const bag = {}; + if (isJsonCompatible(fj, spel, bag)) { + for (const k in bag) { + bag[k] = convertFuncArg(bag[k]); + } + candidates.push({ + s: `widgets.${w}.spelImportFuncs[${i}]`, + w, + argsObj: bag, + }); + } + } + } + } + } + + for (const {s, params} of fsigns) { + const found = conv.valueFuncs[s] || []; + for (const {w, argsOrder} of found) { + const argsArr = params.map(convertFuncArg); + const argsObj = Object.fromEntries( + argsOrder.map((argKey, i) => [argKey, argsArr[i]]) + ); + candidates.push({ + s, + w, + argsObj, + }); + } + } + + for (const {s, w, argsObj} of candidates) { + const widgetDef = config.widgets[w]; + const {spelImportValue, type} = widgetDef; + foundWidget = w; + foundSign = s; + errs = []; + for (const k in argsObj) { + if (!["value"].includes(argsObj[k].valueSrc)) { + errs.push(`${k} has unsupported value src ${argsObj[k].valueSrc}`); + } + } + let value = argsObj.v.value; + if (spelImportValue && !errs.length) { + [value, errs] = spelImportValue.call(config.ctx, argsObj.v, widgetDef, argsObj); + if (errs && !Array.isArray(errs)) + errs = [errs]; + } + if (!errs.length) { + return { + valueSrc: "value", + valueType: type, + value, + }; + } + } + + if (foundWidget && errs.length) { + meta.errors.push(`Signature ${foundSign} - looks like convertable to ${foundWidget}, but: ${errs.join("; ")}`); + } + + return undefined; +}; + +const convertFuncToOp = (spel, conv, config, meta, parentSpel, fsigns, convertFuncArg) => { + let errs, opKey, foundSign; + for (const {s, params} of fsigns) { + const found = conv.opFuncs[s] || []; + for (const {op, argsOrder} of found) { + const argsArr = params.map(convertFuncArg); + opKey = op; + if (op === "!compare") { + if ( + parentSpel.type.startsWith("op-") + && parentSpel.children.length == 2 + && parentSpel.children[1].type == "number" + && parentSpel.children[1].val === 0 + ) { + return { + type: "!compare", + children: argsArr, + }; + } else { + errs.push("Result of compareTo() should be compared to 0"); + } + } + foundSign = s; + errs = []; + const opDef = config.operators[opKey]; + const {valueTypes} = opDef; + const argsObj = Object.fromEntries( + argsOrder.map((argKey, i) => [argKey, argsArr[i]]) + ); + const field = argsObj["0"]; + const convertedArgs = Object.keys(argsObj).filter(k => parseInt(k) > 0).map(k => argsObj[k]); + + const valueType = argsArr.filter(a => !!a).find(({valueSrc}) => valueSrc === "value")?.valueType; + if (valueTypes && valueType && !valueTypes.includes(valueType)) { + errs.push(`Op supports types ${valueTypes}, but got ${valueType}`); + } + if (!errs.length) { + return buildRule(config, meta, field, opKey, convertedArgs, spel); + } + } + } + + if (opKey && errs.length) { + meta.errors.push(`Signature ${foundSign} - looks like convertable to ${opKey}, but: ${errs.join("; ")}`); + } + + return undefined; +}; + +const compareArgs = (left, right, spel, conv, config, meta) => { + if (left.type == right.type) { + if (left.type == "!aggr") { + const [leftSource, rightSource] = [left.source, right.source].map(v => convertArg(v, conv, config, meta, spel)); + //todo: check same filter + return leftSource.value == rightSource.value; + } else { + const [leftVal, rightVal] = [left, right].map(v => convertArg(v, conv, config, meta, spel)); + return leftVal.value == rightVal.value; + } + } + return false; +}; + +export const convertCaseValue = (val, conv, config, meta, spel = null) => { + let convVal; + if (val?.type === "op-plus" && config.settings.caseValueField?.type === "case_value") { + /** + * @deprecated + */ + convVal = convertCaseValueConcat(val, conv, config, meta); + } else { + convVal = convertArg(val, conv, config, meta, spel); + } + return convVal; +}; + +/** + * @deprecated + */ +export const convertCaseValueConcat = (spel, conv, config, meta) => { + let flat = []; + function _processConcatChildren(children) { + children.map(child => { + if (child.type === "op-plus") { + _processConcatChildren(child.children); + } else { + const convertedChild = convertArg(child, conv, config, meta, spel); + if (convertedChild) { + flat.push(convertedChild); + } else { + meta.errors.push(`Can't convert ${child.type} in concatenation`); + } + } + }); + } + _processConcatChildren(spel.children); + return { + valueSrc: "value", + valueType: "case_value", + value: flat, + }; +}; diff --git a/packages/core/modules/import/spel/index.js b/packages/core/modules/import/spel/index.js new file mode 100644 index 000000000..0d8c833fe --- /dev/null +++ b/packages/core/modules/import/spel/index.js @@ -0,0 +1,58 @@ +import { SpelExpressionEvaluator } from "spel2js"; +import { loadTree } from "../tree"; +import { buildConv } from "./conv"; +import { convertToTree } from "./convert"; +import { postprocessCompiled } from "./postprocess"; +import { wrapInDefaultConj } from "./builder"; +import * as Utils from "../../utils"; + +const { logger } = Utils.OtherUtils; +const { extendConfig } = Utils.ConfigUtils; + +// https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/expressions.html#expressions + + +export const loadFromSpel = (spelStr, config) => { + return _loadFromSpel(spelStr, config, true); +}; + +export const _loadFromSpel = (spelStr, config, returnErrors = true) => { + //meta is mutable + let meta = { + errors: [] + }; + const extendedConfig = extendConfig(config, undefined, false); + const conv = buildConv(extendedConfig); + + let compiledExpression; + let convertedObj; + let jsTree = undefined; + try { + const compileRes = SpelExpressionEvaluator.compile(spelStr); + compiledExpression = compileRes._compiledExpression; + } catch (e) { + meta.errors.push(e); + } + + if (compiledExpression) { + //logger.debug("compiledExpression:", compiledExpression); + convertedObj = postprocessCompiled(compiledExpression, meta); + logger.debug("convertedObj:", convertedObj, meta); + + jsTree = convertToTree(convertedObj, conv, extendedConfig, meta); + if (jsTree && jsTree.type != "group" && jsTree.type != "switch_group") { + jsTree = wrapInDefaultConj(jsTree, extendedConfig, convertedObj["not"]); + } + logger.debug("jsTree:", jsTree); + } + + const immTree = jsTree ? loadTree(jsTree) : undefined; + + if (returnErrors) { + return [immTree, meta.errors]; + } else { + if (meta.errors.length) + console.warn("Errors while importing from SpEL:", meta.errors); + return immTree; + } +}; diff --git a/packages/core/modules/import/spel/postprocess.js b/packages/core/modules/import/spel/postprocess.js new file mode 100644 index 000000000..ad000a346 --- /dev/null +++ b/packages/core/modules/import/spel/postprocess.js @@ -0,0 +1,229 @@ +import * as Utils from "../../utils"; + +const { logger } = Utils.OtherUtils; + + +export const postprocessCompiled = (expr, meta, parentExpr = null) => { + const type = expr.getType(); + let children = expr.getChildren().map(child => postprocessCompiled(child, meta, expr)); + + // flatize OR/AND + if (type == "op-or" || type == "op-and") { + children = children.reduce((acc, child) => { + const canFlatize = child.type == type && !child.not; + const flat = canFlatize ? child.children : [child]; + return [...acc, ...flat]; + }, []); + } + + // unwrap NOT + if (type == "op-not") { + if (children.length != 1) { + meta.errors.push(`Operator NOT should have 1 child, but got ${children.length}}`); + } + return { + ...children[0], + not: !(children[0].not || false) + }; + } + + if (type == "compound") { + // remove `.?[true]` + children = children.filter(child => { + const isListFix = child.type == "selection" && child.children.length == 1 && child.children[0].type == "boolean" && child.children[0].val == true; + return !isListFix; + }); + // aggregation + // eg. `results.?[product == 'abc'].length` + const selection = children.find(child => + child.type == "selection" + ); + if (selection && selection.children.length != 1) { + meta.errors.push(`Selection should have 1 child, but got ${selection.children.length}`); + } + const filter = selection ? selection.children[0] : null; + let lastChild = children[children.length - 1]; + const isSize = lastChild.type == "method" && lastChild.val.methodName == "size" + || lastChild.type == "!func" && lastChild.methodName == "size"; + const isLength = lastChild.type == "property" && lastChild.val == "length"; + const sourceParts = children.filter(child => + child !== selection && child !== lastChild + ); + const source = { + type: "compound", + children: sourceParts + }; + const isAggr = (isSize || isLength) && convertPath(sourceParts) != null; + if (isAggr) { + return { + type: "!aggr", + filter, + source + }; + } + // remove `#this`, `#root` + children = children.filter(child => { + const isThis = child.type == "variable" && child.val == "this"; + const isRoot = child.type == "variable" && child.val == "root"; + return !(isThis || isRoot); + }); + // indexer + children = children.map(child => { + if (child.type == "indexer" && child.children.length == 1) { + return { + type: "indexer", + val: child.children[0].val, + itype: child.children[0].type + }; + } else { + return child; + } + }); + // method + // if (lastChild.type == "method") { + // // seems like obsolete code! + // debugger + // const obj = children.filter(child => + // child !== lastChild + // ); + // return { + // type: "!func", + // obj, + // methodName: lastChild.val.methodName, + // args: lastChild.val.args + // }; + // } + // !func + if (lastChild.type == "!func") { + const ret = {}; + let curr = ret; + do { + Object.assign(curr, lastChild); + children = children.filter(child => child !== lastChild); + lastChild = children[children.length - 1]; + if (lastChild?.type == "!func") { + curr.obj = {}; + curr = curr.obj; + } else { + if (children.length > 1) { + curr.obj = { + type: "compound", + children + }; + } else { + curr.obj = lastChild; + } + } + } while(lastChild?.type == "!func"); + return ret; + } + } + + // getRaw || getValue + let val; + try { + if (expr.getRaw) { // use my fork + val = expr.getRaw(); + } else if (expr.getValue.length == 0) { // getValue not requires context arg -> can use + val = expr.getValue(); + } + } catch(e) { + logger.error("[spel2js] Error in getValue()", e); + } + + // ternary + if (type == "ternary") { + val = flatizeTernary(children); + } + + // convert method/function args + if (typeof val === "object" && val !== null) { + if (val.methodName || val.functionName) { + val.args = val.args.map(child => postprocessCompiled(child, meta, expr)); + } + } + // convert list + if (type == "list") { + val = val.map(item => postprocessCompiled(item, meta, expr)); + + // fix whole expression wrapped in `{}` + if (!parentExpr && val.length == 1) { + return val[0]; + } + } + // convert constructor + if (type == "constructorref") { + const qid = children.find(child => child.type == "qualifiedidentifier"); + const cls = qid?.val; + if (!cls) { + meta.errors.push(`Can't find qualifiedidentifier in constructorref children: ${JSON.stringify(children)}`); + return undefined; + } + const args = children.filter(child => child.type != "qualifiedidentifier"); + return { + type: "!new", + cls, + args + }; + } + // convert type + if (type == "typeref") { + const qid = children.find(child => child.type == "qualifiedidentifier"); + const cls = qid?.val; + if (!cls) { + meta.errors.push(`Can't find qualifiedidentifier in typeref children: ${JSON.stringify(children)}`); + return undefined; + } + const _args = children.filter(child => child.type != "qualifiedidentifier"); + return { + type: "!type", + cls + }; + } + // convert function/method + if (type == "function" || type == "method") { + // `foo()` is method, `#foo()` is function + // let's use common property `methodName` and just add `isVar` for function + const {functionName, methodName, args} = val; + return { + type: "!func", + methodName: functionName || methodName, + isVar: type == "function", + args + }; + } + + return { + type, + children, + val, + }; +}; + +const flatizeTernary = (children) => { + let flat = []; + function _processTernaryChildren(tern) { + let [cond, if_val, else_val] = tern; + flat.push([cond, if_val]); + if (else_val?.type == "ternary") { + _processTernaryChildren(else_val.children); + } else { + flat.push([undefined, else_val]); + } + } + _processTernaryChildren(children); + return flat; +}; + +export const convertPath = (parts, meta = {}, expectingField = false) => { + let isError = false; + const res = parts.map(c => { + if (c.type == "variable" || c.type == "property" || c.type == "indexer" && c.itype == "string") { + return c.val; + } else { + isError = true; + expectingField && meta?.errors?.push?.(`Unexpected item in field path compound: ${JSON.stringify(c)}`); + } + }); + return !isError ? res : undefined; +}; diff --git a/packages/core/modules/import/tree.js b/packages/core/modules/import/tree.js index 9d19de608..ea1ad2952 100644 --- a/packages/core/modules/import/tree.js +++ b/packages/core/modules/import/tree.js @@ -1,10 +1,9 @@ -import Immutable, { fromJS, Map } from "immutable"; -import {getLightTree, _fixImmutableValue, fixPathsInTree} from "../utils/treeUtils"; +import Immutable, { Map } from "immutable"; +import {getLightTree, _fixImmutableValue, fixPathsInTree, jsToImmutable} from "../utils/treeUtils"; import {isJsonLogic} from "../utils/stuff"; -import uuid from "../utils/uuid"; export { - isJsonLogic, + isJsonLogic, jsToImmutable, }; export const getTree = (immutableTree, light = true, children1AsArray = true) => { @@ -37,42 +36,4 @@ export const isTree = (tree) => { return typeof tree == "object" && (tree.type == "group" || tree.type == "switch_group"); }; -export function jsToImmutable(tree) { - const imm = fromJS(tree, function (key, value, path) { - const isFuncArg = path - && path.length > 3 - && path[path.length-1] === "value" - && path[path.length-3] === "args"; - const isRuleValue = path - && path.length > 3 - && path[path.length-1] === "value" - && path[path.length-2] === "properties"; - let outValue; - if (key == "properties") { - outValue = value.toOrderedMap(); - - // `value` should be undefined instead of null - // JSON doesn't support undefined and replaces undefined -> null - // So fix: null -> undefined - for (let i = 0 ; i < 2 ; i++) { - if (outValue.get("value")?.get?.(i) === null) { - outValue = outValue.setIn(["value", i], undefined); - } - } - } else if (isFuncArg) { - outValue = _fixImmutableValue(value); - } else if ((path ? isRuleValue : key == "value") && Immutable.Iterable.isIndexed(value)) { - outValue = value.map(_fixImmutableValue).toList(); - } else if (key == "asyncListValues") { - // keep in JS format - outValue = value.toJS(); - } else if (key == "children1" && Immutable.Iterable.isIndexed(value)) { - outValue = new Immutable.OrderedMap(value.map(child => [child?.get("id") || uuid(), child])); - } else { - outValue = Immutable.Iterable.isIndexed(value) ? value.toList() : value.toOrderedMap(); - } - return outValue; - }); - return imm; -} diff --git a/packages/core/modules/index.d.ts b/packages/core/modules/index.d.ts index c189e457d..a4447bd17 100644 --- a/packages/core/modules/index.d.ts +++ b/packages/core/modules/index.d.ts @@ -4,10 +4,10 @@ import {List as ImmList, Map as ImmMap, OrderedMap as ImmOMap} from "immutable"; import {ElementType, ReactElement, Factory} from "react"; import moment from "moment"; -import type { Moment as MomentType } from "moment"; +import type { Moment, MomentInput } from "moment"; import type { i18n } from "i18next"; -export type Moment = MomentType; +export type { Moment, MomentInput }; export type ImmutableList = ImmList; export type ImmutableMap = ImmMap; export type ImmutableOMap = ImmOMap; @@ -25,16 +25,28 @@ interface ReactAttributes { export type FactoryWithContext

= (props: ReactAttributes & P, ctx?: ConfigContext) => ReactElement

; export type RenderedReactElement = ReactElement | string; export type SerializedFunction = JsonLogicFunction | string; +export type SerializableType = SER extends true ? T | SerializedFunction : T; type AnyObject = Record; type Empty = null | undefined; -type ImmutablePath = ImmutableList; -type IdPath = Array | ImmutablePath; // should be used in actions only +export type ImmutablePath = ImmutableList; +export type IdPath = Array | ImmutablePath; // should be used in actions only type Optional = { [P in keyof T]?: T[P]; -} +}; + +type PickDeprecated = { + /** + * @deprecated + */ + [P in K]: T[P]; +}; + +export type PartialPartial = { + [P in keyof T]?: T[P] extends Object ? Partial : T[P]; +}; type OptionalBy = Omit & Partial>; @@ -45,7 +57,7 @@ type TypedMap = Record; type TypedKeyMap = { [key: string]: T; [key: number]: T; -} +}; interface ObjectToImmOMap

extends ImmutableOMap { get(name: K): P[K]; @@ -56,19 +68,19 @@ export type AsyncListValues = Array; // for export/import -type MongoValue = any; -type ElasticSearchQueryType = string; +export type MongoValue = any; +export type ElasticSearchQueryType = string; -type JsonLogicResult = { +export type JsonLogicResult = { logic?: JsonLogicTree; data?: Object; errors?: Array; } -type JsonLogicFunction = Object; -type JsonLogicTree = Object; -type JsonLogicValue = any; -type JsonLogicField = { "var": string }; -interface SpelRawValue { +export type JsonLogicFunction = Object; +export type JsonLogicTree = Object; +export type JsonLogicValue = any; +export type JsonLogicField = { "var": string }; +export interface SpelRawValue { type: string; children?: SpelRawValue[]; val?: RuleValue; @@ -79,8 +91,23 @@ interface SpelRawValue { cls: string[]; } +export type ConfigContextUtils = { + SqlString: ExportUtils["SqlString"]; + sqlEmptyValue: ExportUtils["sqlEmptyValue"]; + spelFixList: ExportUtils["spelFixList"]; + wrapWithBrackets: ExportUtils["wrapWithBrackets"]; + stringifyForDisplay: ExportUtils["stringifyForDisplay"]; + mongoEmptyValue: ExportUtils["mongoEmptyValue"]; + spelEscape: ExportUtils["spelEscape"]; + + moment: OtherUtils["moment"]; + escapeRegExp: OtherUtils["escapeRegExp"]; + + getTitleInListValues: ListUtils["getTitleInListValues"]; +}; + export type ConfigContext = { - utils: TypedMap; + utils: ConfigContextUtils; W: TypedMap>; O: TypedMap>; components?: TypedMap>; @@ -376,7 +403,7 @@ interface _AnyRuleI extends _OmitI<_RuleI> { children1?: ImmOMap; } // type _ItemI = _GroupI | _AnyRuleI; -interface _ItemI extends _OmitI<_GroupI>, _OmitI<_AnyRuleI> { +export interface _ItemI extends _OmitI<_GroupI>, _OmitI<_AnyRuleI> { type: "rule" | "rule_group" | "group"; properties: ImmutableItemProperties; children1?: ImmOMap; @@ -388,7 +415,7 @@ interface _ItemOrCaseI extends _OmitI<_ItemI>, _OmitI<_CaseGroupI> { children1?: ImmOMap; } // type _TreeI = _GroupI | _SwitchGroupI; -interface _TreeI extends _OmitI<_GroupI>, _OmitI<_SwitchGroupI> { +export interface _TreeI extends _OmitI<_GroupI>, _OmitI<_SwitchGroupI> { type: "group" | "switch_group"; children1?: ImmOMap>; properties: ImmutableGroupOrSwitchProperties; @@ -531,8 +558,10 @@ interface ConfigUtils { compressConfig(config: Config, baseConfig: Config): ZipConfig; decompressConfig(zipConfig: ZipConfig, baseConfig: Config, ctx?: ConfigContext): Config; compileConfig(config: Config): Config; - extendConfig(config: Config): Config; - getFieldConfig(config: Config, field: AnyFieldValue): FieldConfig; + extendConfig(config: Config, configId?: string, canCompile?: boolean): Config; + getFieldConfig(config: Config, field: AnyFieldValue): FieldConfig | null; + getFieldParts(field: AnyFieldValue, config?: Config): string[]; + getFieldPathParts(field: AnyFieldValue, config: Config): string[]; getFuncConfig(config: Config, func: string): Func | null; getFuncArgConfig(config: Config, func: string, arg: string): FuncArg | null; getOperatorConfig(config: Config, operator: string, field?: AnyFieldValue): Operator | null; @@ -541,6 +570,8 @@ interface ConfigUtils { isDirtyJSX(jsx: any): boolean; cleanJSX(jsx: any): Object; applyJsonLogic(logic: any, data?: any): any; + iterateFuncs(config: Config): Iterable<[funcPath: string, funcConfig: Func]>; + iterateFields(config: Config): Iterable<[fieldPath: string, fieldConfig: Field, fieldKey: string]>; } interface DefaultUtils { getDefaultField(config: Config, canGetFirst?: boolean, parentRuleGroupField?: string): FieldValueI | null; @@ -563,8 +594,17 @@ interface DefaultUtils { // createListFromArray(array: TItem[]): ImmutableList; } interface ExportUtils { - wrapWithBrackets(val: string): string; + wrapWithBrackets(val?: string): string; spelEscape(val: any): string; + spelFixList(listStr: string): string; + sqlEmptyValue(fieldDef?: Field): string; + mongoEmptyValue(fieldDef?: Field): string; + SqlString: { + trim(val?: string): string; + escape(val?: string): string; + escapeLike(val?: string, any_start?: boolean, any_end?: boolean): string; + }, + stringifyForDisplay(val: any): string; /** * @deprecated */ @@ -600,8 +640,55 @@ interface TreeUtils { // case mode getSwitchValues(tree: ImmutableTree): Array; } + +interface MixSymbols { + /** + * Symbols: + * _v?: T | undefined; + * _type?: string; + * _canCreate?: boolean; + * _canChangeType?: boolean; + * _arrayMergeMode?: "join" | "joinMissing" | "joinRespectOrder" | "overwrite" | "merge"; + */ + [key: symbol]: boolean | string | T; +} +type MixObject> = { + [P in keyof T]?: MixType; +} & { + [P in Exclude]: any; +} & MixSymbols; +type MixArray> = (T /* & MixinSymbols */); +type _Opt = T extends Function ? T : T extends Array ? T : T extends Record ? Partial : T; +type Opt = _Opt>; +type _MixType = T extends Function ? T : T extends Array ? MixArray : T extends Record ? MixObject : Opt; +export type MixType = _MixType>; + interface OtherUtils { + logger: typeof console; + clone(obj: any): any; + moment: typeof moment; uuid(): string; + mergeArraysSmart(arr1: any[], arr2: any[]): any[]; + setIn( + obj: O, + path: string[], + newValue: T | undefined | ((old: T) => T), + options?: { + canCreate?: boolean, + canIgnore?: boolean, + canChangeType?: boolean, + } + ): O; + mergeIn( + obj: Record, + mixin: MixType>, + options?: { + canCreate?: boolean, + canChangeType?: boolean, + deepCopyObj?: boolean, + arrayMergeMode?: "join" | "joinMissing" | "joinRespectOrder" | "overwrite" | "merge", + } + ): Record; deepFreeze(obj: any): any; deepEqual(a: any, b: any): boolean; shallowEqual(a: any, b: any, deep?: boolean): boolean; @@ -618,10 +705,11 @@ interface OtherUtils { } export interface Utils extends Import, Export, - Pick, - Pick, - Pick, - Pick + Pick, + Pick, + PickDeprecated, + PickDeprecated, + PickDeprecated { Import: Import; Export: Export; @@ -633,9 +721,8 @@ export interface Utils extends Import, Export, ListUtils: ListUtils; TreeUtils: TreeUtils; OtherUtils: OtherUtils; - // libs + i18n: i18n; - moment: typeof moment; } @@ -656,15 +743,18 @@ export interface Config { export type ZipConfig = Omit; -export interface ConfigMixin { - conjunctions?: Record>; - operators?: Record>>; - widgets?: Record>>; - types?: Record>; - settings?: Partial; - fields?: Record>; - funcs?: Record>; - ctx?: Partial; + +export type ConfigMixinExt = MixType; + +export interface ConfigMixin { + conjunctions?: Record>; + operators?: Record>>; + widgets?: Record>>; + types?: Record>; + settings?: PartialPartial; + fields?: Record>; + funcs?: Record>; + ctx?: PartialPartial; } ///////////////// @@ -871,17 +961,18 @@ export interface FieldProps { // Widgets ///////////////// -type SpelImportValue = (val: any, wgtDef?: Widget, args?: TypedMap) => [any, string[] | string | undefined]; -type JsonLogicImportValue = (val: any, wgtDef?: Widget, args?: TypedMap) => any | undefined; // can throw +type SpelImportValue = (this: ConfigContext, val: any, wgtDef?: Widget, args?: TypedMap) => [any, string[] | string | undefined]; +type JsonLogicImportValue = (this: ConfigContext, val: any, wgtDef?: Widget, args?: TypedMap) => any | undefined; // can throw -type FormatValue = (val: RuleValue, fieldDef: Field, wgtDef: Widget, isForDisplay: boolean, op: string, opDef: Operator, rightFieldDef?: Field) => string; -type SqlFormatValue = (val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator, rightFieldDef?: Field) => string; -type SpelFormatValue = (val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator, rightFieldDef?: Field) => string; -type MongoFormatValue = (val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator) => MongoValue; -type JsonLogicFormatValue = (val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator) => JsonLogicValue; -export type ValidateValue = (val: V, fieldSettings: FieldSettings, op: string, opDef: Operator, rightFieldDef?: Field) => boolean | string | { error: string | {key: string, args?: Object}, fixedValue?: V } | null; -type ElasticSearchFormatValue = (queryType: ElasticSearchQueryType, val: RuleValue, op: string, field: FieldPath, config: Config) => AnyObject | null; +// tip: for multiselect widget `val` is Array, and return type is also Array +type FormatValue = (this: ConfigContext, val: RuleValue, fieldDef: Field, wgtDef: Widget, isForDisplay: boolean, op: string, opDef: Operator, rightFieldDef?: Field) => string | string[]; +type SqlFormatValue = (this: ConfigContext, val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator, rightFieldDef?: Field) => string | string[]; +type SpelFormatValue = (this: ConfigContext, val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator, rightFieldDef?: Field) => string | string[]; +type MongoFormatValue = (this: ConfigContext, val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator) => MongoValue; +type JsonLogicFormatValue = (this: ConfigContext, val: RuleValue, fieldDef: Field, wgtDef: Widget, op: string, opDef: Operator) => JsonLogicValue; +type ElasticSearchFormatValue = (this: ConfigContext, queryType: ElasticSearchQueryType, val: RuleValue, op: string, field: FieldPath, config: Config) => AnyObject | null; +export type ValidateValue = (this: ConfigContext, val: V, fieldSettings: FieldSettings, op: string, opDef: Operator, rightFieldDef?: Field) => boolean | string | { error: string | {key: string, args?: Object}, fixedValue?: V } | null; export interface BaseWidget> { type: string; @@ -891,21 +982,22 @@ export interface BaseWidget> { valuePlaceholder?: string; valueLabel?: string; fullWidth?: boolean; - formatValue?: FormatValue | SerializedFunction; - sqlFormatValue?: SqlFormatValue | SerializedFunction; - spelFormatValue?: SpelFormatValue | SerializedFunction; + formatValue?: SerializableType; + sqlFormatValue?: SerializableType; + spelFormatValue?: SerializableType; spelImportFuncs?: Array; - spelImportValue?: SpelImportValue | SerializedFunction; - mongoFormatValue?: MongoFormatValue | SerializedFunction; - elasticSearchFormatValue?: ElasticSearchFormatValue | SerializedFunction; + spelImportValue?: SerializableType; + sqlImport?: SerializableType; + mongoFormatValue?: SerializableType; + elasticSearchFormatValue?: SerializableType; hideOperator?: boolean; operatorInlineLabel?: string; - jsonLogic?: JsonLogicFormatValue | SerializedFunction; - jsonLogicImport?: JsonLogicImportValue | SerializedFunction; + jsonLogic?: SerializableType; + jsonLogicImport?: SerializableType; //obsolete: - validateValue?: ValidateValue | SerializedFunction; + validateValue?: SerializableType; //@ui - factory: FactoryWithContext | SerializedFunction; + factory: SerializableType>; customProps?: AnyObject; } export interface RangeableWidget> extends BaseWidget { @@ -915,14 +1007,14 @@ export interface RangeableWidget> extends BaseWi interface BaseFieldWidget> { valuePlaceholder?: string; valueLabel?: string; - formatValue: FormatValue | SerializedFunction; // with rightFieldDef - sqlFormatValue?: SqlFormatValue | SerializedFunction; // with rightFieldDef - spelFormatValue?: SpelFormatValue | SerializedFunction; // with rightFieldDef + formatValue?: SerializableType; // with rightFieldDef + sqlFormatValue?: SerializableType; // with rightFieldDef + spelFormatValue?: SerializableType; // with rightFieldDef //obsolete: - validateValue?: ValidateValue | SerializedFunction; + validateValue?: SerializableType; //@ui customProps?: AnyObject; - factory?: FactoryWithContext; + factory: SerializableType>; } export interface FieldWidget> extends BaseFieldWidget { valueSrc: "field"; @@ -945,18 +1037,17 @@ export type TreeMultiSelectWidget */ export type CaseValueWidget> = BaseWidget & CaseValueFieldSettings; -// tip: use generic WidgetProps here, TS can't determine correct factory export type TypedWidget = - TextWidget> - | DateTimeWidget> - | BooleanWidget> - | NumberWidget> - | RangeSliderWidget> - | SelectWidget> - | MultiSelectWidget> - | TreeSelectWidget> - | TreeMultiSelectWidget> - | CaseValueWidget>; + TextWidget + | DateTimeWidget + | BooleanWidget + | NumberWidget + | RangeSliderWidget + | SelectWidget + | MultiSelectWidget + | TreeSelectWidget + | TreeMultiSelectWidget + | CaseValueWidget; export type Widget = FieldWidget @@ -971,15 +1062,15 @@ export type Widgets = TypedMap>; // Conjunctions ///////////////// -type FormatConj = (children: ImmutableList, conj: string, not: boolean, isForDisplay?: boolean) => string; -type SqlFormatConj = (children: ImmutableList, conj: string, not: boolean) => string; -type SpelFormatConj = (children: ImmutableList, conj: string, not: boolean, omitBrackets?: boolean) => string; +type FormatConj = (this: ConfigContext, children: ImmutableList, conj: string, not: boolean, isForDisplay?: boolean) => string; +type SqlFormatConj = (this: ConfigContext, children: ImmutableList, conj: string, not: boolean) => string; +type SpelFormatConj = (this: ConfigContext, children: ImmutableList, conj: string, not: boolean, omitBrackets?: boolean) => string; export interface Conjunction { label: string; - formatConj: FormatConj | SerializedFunction; - sqlFormatConj: SqlFormatConj | SerializedFunction; - spelFormatConj: SpelFormatConj | SerializedFunction; + formatConj: SerializableType; + sqlFormatConj: SerializableType; + spelFormatConj: SerializableType; mongoConj: string; jsonLogicConj?: string; sqlConj?: string; @@ -1023,12 +1114,13 @@ export interface ConjsProps { // Operators ///////////////// -type FormatOperator = (field: FieldPath, op: string, vals: string | ImmutableList, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, isForDisplay?: boolean, fieldDef?: Field) => string; -type MongoFormatOperator = (field: FieldPath, op: string, vals: MongoValue | Array, useExpr?: boolean, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => Object; -type SqlFormatOperator = (field: FieldPath, op: string, vals: string | ImmutableList, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => string; -type SpelFormatOperator = (field: FieldPath, op: string, vals: string | Array, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => string; -type JsonLogicFormatOperator = (field: JsonLogicField, op: string, vals: JsonLogicValue | Array, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => JsonLogicTree; -type ElasticSearchFormatQueryType = (valueType: string) => ElasticSearchQueryType; +// tip: for multiselect widget `vals` is always Array, for between/proximity op `vals` can be Array or ImmutableList (only for sql, simple string - TODO: onvert to []) +type FormatOperator = (this: ConfigContext, field: FieldPath, op: string, vals: string | string[] | ImmutableList, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, isForDisplay?: boolean, fieldDef?: Field) => string | undefined; +type MongoFormatOperator = (this: ConfigContext, field: FieldPath, op: string, vals: MongoValue | Array, useExpr?: boolean, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => Object | undefined; +type SqlFormatOperator = (this: ConfigContext, field: FieldPath, op: string, vals: string | string[] | ImmutableList, valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => string | undefined; +type SpelFormatOperator = (this: ConfigContext, field: FieldPath, op: string, vals: string | string[], valueSrc?: ValueSource, valueType?: string, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => string | undefined; +type JsonLogicFormatOperator = (this: ConfigContext, field: JsonLogicField, op: string, vals: JsonLogicValue | Array, opDef?: Operator, operatorOptions?: OperatorOptionsI, fieldDef?: Field) => JsonLogicTree | undefined; +type ElasticSearchFormatQueryType = (this: ConfigContext, valueType: string) => ElasticSearchQueryType; interface ProximityConfig { optionLabel: string; @@ -1048,7 +1140,7 @@ export interface ProximityProps extends ProximityConfig { } export interface ProximityOptions> extends ProximityConfig { //@ui - factory: FactoryWithContext | SerializedFunction; + factory?: SerializableType>; } export interface BaseOperator { @@ -1056,14 +1148,16 @@ export interface BaseOperator { reversedOp?: string; isNotOp?: boolean; cardinality?: number; - formatOp?: FormatOperator | SerializedFunction; + formatOp?: SerializableType; labelForFormat?: string; - mongoFormatOp?: MongoFormatOperator | SerializedFunction; + mongoFormatOp?: SerializableType; sqlOp?: string; - sqlFormatOp?: SqlFormatOperator | SerializedFunction; + sqlOps?: string[]; + sqlImport?: SerializableType; + sqlFormatOp?: SerializableType; spelOp?: string; spelOps?: string[]; - spelFormatOp?: SpelFormatOperator | SerializedFunction; + spelFormatOp?: SerializableType; jsonLogic?: string | JsonLogicFormatOperator | JsonLogicFunction; jsonLogic2?: string; _jsonLogicIsExclamationOp?: boolean; @@ -1104,7 +1198,7 @@ interface WidgetConfigForType { valuePlaceholder?: string; } -interface Type { +export interface Type { valueSources?: Array; defaultOperator?: string; widgets: TypedMap; @@ -1151,11 +1245,11 @@ export interface AsyncFetchListValuesResult { values: ListItems; hasMore?: boolean; } -export type AsyncFetchListValuesFn = (search: string | null, offset: number) => Promise; +export type AsyncFetchListValuesFn = (this: ConfigContext | void, search: string | null, offset: number) => Promise; export interface BasicFieldSettings { - validateValue?: ValidateValue | SerializedFunction; + validateValue?: SerializableType>; valuePlaceholder?: string; } export interface TextFieldSettings extends BasicFieldSettings { @@ -1181,7 +1275,7 @@ export interface SelectFieldSettings extends BasicFieldSett showSearch?: boolean; searchPlaceholder?: string; showCheckboxes?: boolean; - asyncFetch?: AsyncFetchListValuesFn | SerializedFunction; + asyncFetch?: SerializableType; useLoadMore?: boolean; useAsyncSearch?: boolean; forceAsyncSearch?: boolean; @@ -1285,8 +1379,11 @@ export type Field = SimpleField; export type FieldOrGroup = FieldStruct | FieldGroup | FieldGroupExt | Field; export type Fields = TypedMap; -export type FieldConfig = Field | Func | null; -export type FieldValueOrConfig = FieldConfig | AnyFieldValue; +export type FieldConfig = Field | Func; +export type FieldValueOrConfig = FieldConfig | AnyFieldValue | null; + +export type FieldConfigExt = Field & Type; +export type FuncConfigExt = Func & Type; export type NumberField = SimpleField; export type DateTimeField = SimpleField; @@ -1311,13 +1408,13 @@ type ValueSourcesInfo = {[vs in ValueSource]?: {label: string, widget?: string}} type AntdPosition = "topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight"; type AntdSize = "small" | "large" | "medium"; type ChangeFieldStrategy = "default" | "keep" | "first" | "none"; -type FormatReverse = (q: string, op: string, reversedOp: string, operatorDefinition: Operator, revOperatorDefinition: Operator, isForDisplay: boolean) => string; -type SqlFormatReverse = (q: string) => string; -type SpelFormatReverse = (q: string) => string; -type FormatField = (field: FieldPath, parts: Array, label2: string, fieldDefinition: Field, config: Config, isForDisplay: boolean) => string; -type FormatSpelField = (field: FieldPath, parentField: FieldPath | null, parts: Array, partsExt: Array, fieldDefinition: Field, config: Config) => string; -type CanCompareFieldWithField = (leftField: FieldPath, leftFieldConfig: Field, rightField: FieldPath, rightFieldConfig: Field, op: string) => boolean; -type FormatAggr = (whereStr: string, aggrField: FieldPath, operator: string, value: string | ImmutableList, valueSrc: ValueSource, valueType: string, opDef: Operator, operatorOptions: OperatorOptionsI, isForDisplay: boolean, aggrFieldDef: Field) => string; +type FormatReverse = (this: ConfigContext, q: string, op: string, reversedOp: string, operatorDefinition: Operator, revOperatorDefinition: Operator, isForDisplay: boolean) => string; +type SqlFormatReverse = (this: ConfigContext, q: string) => string; +type SpelFormatReverse = (this: ConfigContext, q: string) => string; +type FormatField = (this: ConfigContext, field: FieldPath, parts: Array, label2: string, fieldDefinition: Field, config: Config, isForDisplay: boolean) => string; +type FormatSpelField = (this: ConfigContext, field: FieldPath, parentField: FieldPath | null, parts: Array, partsExt: Array, fieldDefinition: Field, config: Config) => string; +type CanCompareFieldWithField = (this: ConfigContext, leftField: FieldPath, leftFieldConfig: Field, rightField: FieldPath, rightFieldConfig: Field, op: string) => boolean; +type FormatAggr = (this: ConfigContext, whereStr: string, aggrField: FieldPath, operator: string, value: string | ImmutableList, valueSrc: ValueSource, valueType: string, opDef: Operator, operatorOptions: OperatorOptionsI, isForDisplay: boolean, aggrFieldDef: Field) => string; export interface LocaleSettings { locale?: { @@ -1375,7 +1472,7 @@ export interface BehaviourSettings { defaultConjunction?: string; fieldSources?: Array; valueSourcesInfo?: ValueSourcesInfo; - canCompareFieldWithField?: CanCompareFieldWithField | SerializedFunction; + canCompareFieldWithField?: SerializableType; canReorder?: boolean; canRegroup?: boolean; canRegroupCases?: boolean; @@ -1414,12 +1511,12 @@ export interface OtherSettings { caseValueField?: Field; fieldSeparator?: string; fieldSeparatorDisplay?: string; - formatReverse?: FormatReverse | SerializedFunction; - sqlFormatReverse?: SqlFormatReverse | SerializedFunction; - spelFormatReverse?: SpelFormatReverse | SerializedFunction; - formatField?: FormatField | SerializedFunction; - formatSpelField?: FormatSpelField | SerializedFunction; - formatAggr?: FormatAggr | SerializedFunction; + formatReverse?: SerializableType; + sqlFormatReverse?: SerializableType; + spelFormatReverse?: SerializableType; + formatField?: SerializableType; + formatSpelField?: SerializableType; + formatAggr?: SerializableType; } export interface Settings extends LocaleSettings, BehaviourSettings, OtherSettings { @@ -1430,13 +1527,14 @@ export interface Settings extends LocaleSettings, BehaviourSettings, OtherSettin // Funcs ///////////////// -type SqlFormatFunc = (formattedArgs: TypedMap) => string; -type FormatFunc = (formattedArgs: TypedMap, isForDisplay: boolean) => string; -type MongoFormatFunc = (formattedArgs: TypedMap) => MongoValue; -type JsonLogicFormatFunc = (formattedArgs: TypedMap) => JsonLogicTree; -type JsonLogicImportFunc = (val: JsonLogicValue) => Array | undefined; // can throw -type SpelImportFunc = (spel: SpelRawValue) => Array; -type SpelFormatFunc = (formattedArgs: TypedMap) => string; +export type SqlFormatFunc = (this: ConfigContext, formattedArgs: TypedMap) => string; +export type SqlImportFunc = (this: ConfigContext, sql: Object) => Record | undefined; // can throw, should return {func?, args: {}} or {operator?, children: []} +export type FormatFunc = (this: ConfigContext, formattedArgs: TypedMap, isForDisplay: boolean) => string; +export type MongoFormatFunc = (this: ConfigContext, formattedArgs: TypedMap) => MongoValue; +export type JsonLogicFormatFunc = (this: ConfigContext, formattedArgs: TypedMap) => JsonLogicTree; +export type JsonLogicImportFunc = (this: ConfigContext, val: JsonLogicValue) => Array | undefined; // can throw +export type SpelImportFunc = (this: ConfigContext, spel: SpelRawValue) => Record | undefined; // can throw +export type SpelFormatFunc = (this: ConfigContext, formattedArgs: TypedMap) => string; interface FuncGroup extends BaseField { type: "!struct"; @@ -1456,14 +1554,15 @@ export interface Func extends Omit { // Calling methods on objects was remvoed in JsonLogic 2.x // https://github.com/jwadhams/json-logic-js/issues/86 jsonLogicIsMethod?: boolean; - jsonLogicImport?: JsonLogicImportFunc | SerializedFunction; - spelImport?: SpelImportFunc | SerializedFunction; - formatFunc?: FormatFunc | SerializedFunction; - sqlFormatFunc?: SqlFormatFunc | SerializedFunction; - mongoFormatFunc?: MongoFormatFunc | SerializedFunction; + jsonLogicImport?: SerializableType; + spelImport?: SerializableType; + formatFunc?: SerializableType; + sqlFormatFunc?: SerializableType; + sqlImport?: SerializableType; + mongoFormatFunc?: SerializableType; renderBrackets?: Array; renderSeps?: Array; - spelFormatFunc?: SpelFormatFunc | SerializedFunction; + spelFormatFunc?: SerializableType; allowSelfNesting?: boolean; } export interface FuncArg extends BaseSimpleField { diff --git a/packages/core/modules/stores/tree.js b/packages/core/modules/stores/tree.js index 1804cb621..ba0815d74 100644 --- a/packages/core/modules/stores/tree.js +++ b/packages/core/modules/stores/tree.js @@ -5,19 +5,19 @@ import { } from "../utils/treeUtils"; import { defaultRuleProperties, defaultGroupProperties, getDefaultOperator, - defaultOperatorOptions, defaultItemProperties -} from "../utils/defaultUtils"; + defaultOperatorOptions, defaultItemProperties, +} from "../utils/defaultRuleUtils"; import * as constants from "./constants"; import uuid from "../utils/uuid"; import { - getFuncConfig, getFieldConfig, getOperatorConfig + getFuncConfig, getFieldConfig, getOperatorConfig, selectTypes, getOperatorsForType, getOperatorsForField, getFirstOperator, } from "../utils/configUtils"; import { - getOperatorsForField, getOperatorsForType, getFirstOperator, - isEmptyItem, selectTypes, calculateValueType + isEmptyItem, calculateValueType } from "../utils/ruleUtils"; import {deepEqual, getOpCardinality, applyToJS} from "../utils/stuff"; -import {validateValue, validateRange, getNewValueForFieldOp} from "../utils/validation"; +import {validateValue, validateRange} from "../utils/validation"; +import {getNewValueForFieldOp} from "../utils/getNewValueForFieldOp"; import {translateValidation} from "../i18n"; import omit from "lodash/omit"; import mapValues from "lodash/mapValues"; @@ -588,6 +588,7 @@ const setField = (state, path, newField, config, asyncListValues, _meta = {}) => if (isRuleGroup) { state = state.setIn(expandTreePath(path, "type"), "rule_group"); const {canReuseValue, newValue, newValueSrc, newValueType, operatorCardinality} = getNewValueForFieldOp( + { validateValue, validateRange }, config, config, currentProperties, newField, newOperator, "field", canFix, isEndValue, canDropArgs ); let groupProperties = defaultGroupProperties(config, newFieldConfig, newField).merge({ @@ -616,6 +617,7 @@ const setField = (state, path, newField, config, asyncListValues, _meta = {}) => const { canReuseValue, newValue, newValueSrc, newValueType, newValueError, newFieldError, fixedField } = getNewValueForFieldOp( + { validateValue, validateRange }, config, config, current, newField, newOperator, "field", canFix, isEndValue, canDropArgs ); // const newValueErrorStr = newValueError?.join?.("|"); @@ -684,6 +686,7 @@ const setOperator = (state, path, newOperator, config) => { const _currentOperator = current.get("operator"); const {canReuseValue, newValue, newValueSrc, newValueType, newValueError} = getNewValueForFieldOp( + { validateValue, validateRange }, config, config, current, currentField, newOperator, "operator", canFix ); if (showErrorMessage) { @@ -857,6 +860,7 @@ const setValueSrc = (state, path, delta, srcKey, config, _meta = {}) => { // this call should return canReuseValue = false and provide default value const canFix = true; const {canReuseValue, newValue, newValueSrc, newValueType, newValueError} = getNewValueForFieldOp( + { validateValue, validateRange }, config, config, properties, field, operator, "valueSrc", canFix ); if (!canReuseValue && newValueSrc.get(delta) == srcKey) { diff --git a/packages/core/modules/utils/configAllUtils.js b/packages/core/modules/utils/configAllUtils.js new file mode 100644 index 000000000..72b4bdd01 --- /dev/null +++ b/packages/core/modules/utils/configAllUtils.js @@ -0,0 +1,4 @@ +export * from "./configUtils"; +export * from "./configSerialize"; +export * from "./configExtend"; +export * from "./configMemo"; diff --git a/packages/core/modules/utils/configExtend.js b/packages/core/modules/utils/configExtend.js index 0dacc44b7..4581d4403 100644 --- a/packages/core/modules/utils/configExtend.js +++ b/packages/core/modules/utils/configExtend.js @@ -72,7 +72,7 @@ export const extendConfig = (config, configId, canCompile = true) => { deepFreeze(config); // Save to memo (cache) - const memo = getCommonMemo(); + const memo = getCommonMemo(extendConfig); memo.storeConfigPair(origConfig, config); return config; diff --git a/packages/core/modules/utils/configMemo.js b/packages/core/modules/utils/configMemo.js index d6e272674..c086b5e14 100644 --- a/packages/core/modules/utils/configMemo.js +++ b/packages/core/modules/utils/configMemo.js @@ -1,5 +1,4 @@ import pick from "lodash/pick"; -import { extendConfig } from "./configExtend"; import { configKeys } from "./configUtils"; let memoId = 0; @@ -7,12 +6,13 @@ let configId = 0; let commonMemo; const memos = {}; -export const getCommonMemo = () => { +export const getCommonMemo = (extendConfig) => { if (!commonMemo) { commonMemo = createConfigMemo({ reactIndex: undefined, maxSize: 3, canCompile: undefined, // default is true + extendConfig, }); } return commonMemo; @@ -34,6 +34,7 @@ export const createConfigMemo = (meta = { reactIndex: undefined, maxSize: 2, // current and prev canCompile: true, + extendConfig: undefined, // should be passed! }) => { const configStore = new Map(); const maxSize = meta.maxSize || 2; @@ -46,7 +47,7 @@ export const createConfigMemo = (meta = { }; const extendAndStore = (config) => { - const extendedConfig = extendConfig(config, ++configId, meta.canCompile); + const extendedConfig = meta.extendConfig(config, ++configId, meta.canCompile); storeConfigPair(config, extendedConfig); return extendedConfig; }; diff --git a/packages/core/modules/utils/configSerialize.js b/packages/core/modules/utils/configSerialize.js index 30877eed3..895ea4fd7 100644 --- a/packages/core/modules/utils/configSerialize.js +++ b/packages/core/modules/utils/configSerialize.js @@ -1,11 +1,11 @@ import merge from "lodash/merge"; import pick from "lodash/pick"; -import {isJsonLogic, isJSX, isDirtyJSX, cleanJSX, shallowEqual} from "./stuff"; +import {isJsonLogic, isJSX, isDirtyJSX, cleanJSX, shallowEqual, isObject} from "./stuff"; import clone from "clone"; import JL from "json-logic-js"; import { addRequiredJsonLogicOperations, applyJsonLogic } from "./jsonLogic"; -import { BasicFuncs } from ".."; -import { getFieldRawConfig } from "./configUtils"; +import * as BasicFuncs from "../config/funcs"; +import { getFieldRawConfig, configKeys } from "./configUtils"; // Add new operations for JsonLogic addRequiredJsonLogicOperations(); @@ -34,7 +34,6 @@ function callContextFn(_this, fn, args, path) { return ret; } -export const configKeys = ["conjunctions", "fields", "types", "operators", "widgets", "settings", "funcs", "ctx"]; // type: // x - iterate (with nesting `subfields`) @@ -67,6 +66,7 @@ const compileMetaWidget = { sqlFormatValue: { type: "f", args: ["val", "fieldDef", "wgtDef", "op", "opDef", "rightFieldDef"] }, spelFormatValue: { type: "f", args: ["val", "fieldDef", "wgtDef", "op", "opDef", "rightFieldDef"] }, spelImportValue: { type: "f", args: ["val", "wgtDef", "args"] }, + sqlImport: { type: "f", args: ["sqlObj", "wgtDef"] }, mongoFormatValue: { type: "f", args: ["val", "fieldDef", "wgtDef", "op", "opDef"] }, elasticSearchFormatValue: { type: "f", args: ["queryType", "val", "op", "field", "config"] }, jsonLogic: { type: "f", args: ["val", "fieldDef", "wgtDef", "op", "opDef"] }, @@ -84,6 +84,7 @@ const compileMetaOperator = { sqlFormatOp: { type: "f", args: ["field", "op", "vals", "valueSrc", "valueType", "opDef", "operatorOptions", "fieldDef"] }, spelFormatOp: { type: "f", args: ["field", "op", "vals", "valueSrc", "valueType", "opDef", "operatorOptions", "fieldDef"] }, jsonLogic: { type: "f", ignore: "string", args: ["field", "op", "vals", "opDef", "operatorOptions", "fieldDef"] }, + sqlImport: { type: "f", args: ["sqlObj"] }, elasticSearchQueryType: { type: "f", ignore: "string", args: ["valueType"] }, textSeparators: { type: "r", isArr: true }, }; @@ -106,6 +107,7 @@ const compileMetaFunc = { jsonLogic: { type: "f", ignore: "string", args: ["formattedArgs"] }, jsonLogicImport: { type: "f", args: ["val"] }, spelImport: { type: "f", args: ["spel"] }, + sqlImport: { type: "f", args: ["sqlObj"] }, formatFunc: { type: "f", args: ["formattedArgs", "isForDisplay"] }, sqlFormatFunc: { type: "f", args: ["formattedArgs"] }, mongoFormatFunc: { type: "f", args: ["formattedArgs"] }, @@ -203,8 +205,6 @@ const compileMeta = { settings: compileMetaSettings, }; -const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); - ///////////// export const compressConfig = (config, baseConfig) => { diff --git a/packages/core/modules/utils/configUtils.js b/packages/core/modules/utils/configUtils.js index 4bfe54b66..2c55cc614 100644 --- a/packages/core/modules/utils/configUtils.js +++ b/packages/core/modules/utils/configUtils.js @@ -1,13 +1,17 @@ import pick from "lodash/pick"; import {widgetDefKeysToOmit, omit} from "./stuff"; -import {getWidgetForFieldOp} from "./ruleUtils"; - -export * from "./configSerialize"; -export * from "./configExtend"; -export * from "./configMemo"; export const _widgetDefKeysToOmit = widgetDefKeysToOmit; // for ui +export const configKeys = ["conjunctions", "fields", "types", "operators", "widgets", "settings", "funcs", "ctx"]; + +export const selectTypes = [ + "select", + "multiselect", + "treeselect", + "treemultiselect", +]; + export function* iterateFuncs(config) { yield* _iterateFields(config, config.funcs || {}, []); } @@ -25,7 +29,8 @@ function* _iterateFields(config, subfields, path, subfieldsKey = "subfields") { } else { yield [ [...path, fieldKey].join(fieldSeparator), - fieldConfig + fieldConfig, + fieldKey ]; } } @@ -367,3 +372,168 @@ export const getFirstField = (config, parentRuleGroupField = null) => { } while (firstField.type == "!struct" || firstField.type == "!group"); return (parentPathArr || []).concat(keysPath).join(fieldSeparator); }; + +export function _getWidgetsAndSrcsForFieldOp (config, field, operator = null, valueSrc = null) { + let widgets = []; + let valueSrcs = []; + if (!field) + return {widgets, valueSrcs}; + const fieldCacheKey = getFieldId(field); + const cacheKey = fieldCacheKey ? `${fieldCacheKey}__${operator}__${valueSrc}` : null; + const cached = _getFromConfigCache(config, "_getWidgetsAndSrcsForFieldOp", cacheKey); + if (cached) + return cached; + const isFuncArg = typeof field === "object" && (!!field.func && !!field.arg || field._isFuncArg); + const fieldConfig = getFieldConfig(config, field); + const opConfig = operator ? config.operators[operator] : null; + + if (fieldConfig?.widgets) { + for (const widget in fieldConfig.widgets) { + const widgetConfig = fieldConfig.widgets[widget]; + if (!config.widgets[widget]) { + continue; + } + const widgetValueSrc = config.widgets[widget].valueSrc || "value"; + let canAdd = true; + if (widget === "field") { + canAdd = canAdd && filterValueSourcesForField(config, ["field"], fieldConfig).length > 0; + } + if (widget === "func") { + canAdd = canAdd && filterValueSourcesForField(config, ["func"], fieldConfig).length > 0; + } + // If can't check operators, don't add + // Func args don't have operators + if (valueSrc === "value" && !widgetConfig.operators && !isFuncArg && field !== "!case_value") + canAdd = false; + if (widgetConfig.operators && operator) + canAdd = canAdd && widgetConfig.operators.indexOf(operator) != -1; + if (valueSrc && valueSrc != widgetValueSrc && valueSrc !== "const") + canAdd = false; + if (opConfig && opConfig.cardinality == 0 && (widgetValueSrc !== "value")) + canAdd = false; + if (canAdd) { + widgets.push(widget); + let canAddValueSrc = fieldConfig.valueSources?.indexOf(widgetValueSrc) != -1; + if (opConfig?.valueSources?.indexOf(widgetValueSrc) == -1) + canAddValueSrc = false; + if (canAddValueSrc && !valueSrcs.find(v => v == widgetValueSrc)) + valueSrcs.push(widgetValueSrc); + } + } + } + + const widgetWeight = (w) => { + let wg = 0; + if (fieldConfig.preferWidgets) { + if (fieldConfig.preferWidgets.includes(w)) + wg += (10 - fieldConfig.preferWidgets.indexOf(w)); + } else if (w == fieldConfig.mainWidget) { + wg += 100; + } + if (w === "field") { + wg -= 1; + } + if (w === "func") { + wg -= 2; + } + return wg; + }; + + widgets.sort((w1, w2) => (widgetWeight(w2) - widgetWeight(w1))); + + const res = { widgets, valueSrcs }; + _saveToConfigCache(config, "_getWidgetsAndSrcsForFieldOp", cacheKey, res); + return res; +} + + +export const filterValueSourcesForField = (config, valueSrcs, fieldDefinition) => { + if (!fieldDefinition) + return valueSrcs; + let fieldType = fieldDefinition.type ?? fieldDefinition.returnType; + if (fieldType === "!group") { + // todo: aggregation can be not only number? + fieldType = "number"; + } + // const { _isCaseValue } = fieldDefinition; + if (!valueSrcs) + valueSrcs = Object.keys(config.settings.valueSourcesInfo); + return valueSrcs.filter(vs => { + let canAdd = true; + if (vs === "field") { + if (config.__fieldsCntByType) { + // tip: LHS field can be used as arg in RHS function + const minCnt = fieldDefinition._isFuncArg ? 0 : 1; + canAdd = canAdd && config.__fieldsCntByType[fieldType] > minCnt; + } + } + if (vs === "func") { + if (fieldDefinition.funcs) { + canAdd = canAdd && fieldDefinition.funcs.length > 0; + } + if (config.__funcsCntByType) { + canAdd = canAdd && config.__funcsCntByType[fieldType] > 0; + } + } + return canAdd; + }); +}; + +export const getWidgetForFieldOp = (config, field, operator, valueSrc = null) => { + const {widgets} = _getWidgetsAndSrcsForFieldOp(config, field, operator, valueSrc); + let widget = null; + if (widgets.length) + widget = widgets[0]; + return widget; +}; + +export const getValueSourcesForFieldOp = (config, field, operator, fieldDefinition = null) => { + const {valueSrcs} = _getWidgetsAndSrcsForFieldOp(config, field, operator, null); + const filteredValueSrcs = filterValueSourcesForField(config, valueSrcs, fieldDefinition); + return filteredValueSrcs; +}; + +export const getWidgetsForFieldOp = (config, field, operator, valueSrc = null) => { + const {widgets} = _getWidgetsAndSrcsForFieldOp(config, field, operator, valueSrc); + return widgets; +}; + +export const getOperatorsForType = (config, fieldType) => { + return config.types[fieldType]?.operators || null; +}; + +export const getOperatorsForField = (config, field) => { + const fieldConfig = getFieldConfig(config, field); + const fieldOps = fieldConfig ? fieldConfig.operators : []; + return fieldOps; +}; + +export const getFirstOperator = (config, field) => { + const fieldOps = getOperatorsForField(config, field); + return fieldOps?.[0] ?? null; +}; + +export const getFieldPartsConfigs = (field, config, parentField = null) => { + if (!field) + return null; + const parentFieldDef = parentField && getFieldRawConfig(config, parentField) || null; + const fieldSeparator = config.settings.fieldSeparator; + const parts = getFieldParts(field, config); + const isDescendant = isFieldDescendantOfField(field, parentField, config); + const parentParts = !isDescendant ? [] : getFieldParts(parentField, config); + return parts + .slice(parentParts.length) + .map((_curr, ind, arr) => arr.slice(0, ind+1)) + .map((parts) => ({ + part: [...parentParts, ...parts].join(fieldSeparator), + key: parts[parts.length - 1] + })) + .map(({part, key}) => { + const cnf = getFieldRawConfig(config, part); + return {key, cnf}; + }) + .map(({key, cnf}, ind, arr) => { + const parentCnf = ind > 0 ? arr[ind - 1].cnf : parentFieldDef; + return [key, cnf, parentCnf]; + }); +}; diff --git a/packages/core/modules/utils/defaultRuleUtils.js b/packages/core/modules/utils/defaultRuleUtils.js new file mode 100644 index 000000000..5b05f6f40 --- /dev/null +++ b/packages/core/modules/utils/defaultRuleUtils.js @@ -0,0 +1,92 @@ +import Immutable from "immutable"; +import uuid from "./uuid"; +import {getNewValueForFieldOp} from "./getNewValueForFieldOp"; +import { isImmutable } from "./stuff"; +import { defaultOperatorOptions, defaultGroupProperties, getDefaultField, getDefaultFieldSrc, getDefaultOperator, defaultGroupConjunction } from "./defaultUtils"; +import {validateValue, validateRange} from "./validation"; +import { getFieldConfig } from "./configUtils"; + +export * from "./defaultUtils"; + + + +export const defaultRuleProperties = (config, parentRuleGroupField = null, item = null, canUseDefaultFieldAndOp = true, canGetFirst = false) => { + let field = null, operator = null, fieldSrc = null; + const {showErrorMessage} = config.settings; + if (item) { + fieldSrc = item?.properties?.fieldSrc; + field = item?.properties?.field; + operator = item?.properties?.operator; + } else if (canUseDefaultFieldAndOp) { + field = getDefaultField(config, canGetFirst, parentRuleGroupField); + if (field) { + fieldSrc = isImmutable(field) ? "func" : "field"; + } else { + fieldSrc = getDefaultFieldSrc(config); + } + operator = getDefaultOperator(config, field, true); + } else { + fieldSrc = getDefaultFieldSrc(config); + } + let current = new Immutable.Map({ + fieldSrc: fieldSrc, + field: field, + operator: operator, + value: new Immutable.List(), + valueSrc: new Immutable.List(), + //used for complex operators like proximity + operatorOptions: defaultOperatorOptions(config, operator, field), + }); + if (showErrorMessage) { + current = current.set("valueError", new Immutable.List()); + } + + if (field && operator) { + const canFix = false; + let {newValue, newValueSrc, newValueType, newValueError, newFieldError} = getNewValueForFieldOp( + { validateValue, validateRange }, + config, config, current, field, operator, "operator", canFix + ); + current = current + .set("value", newValue) + .set("valueSrc", newValueSrc) + .set("valueType", newValueType); + if (showErrorMessage) { + current = current + .set("valueError", newValueError) + .set("fieldError", newFieldError); + } + } + + const fieldConfig = getFieldConfig(config, field); + if (fieldConfig?.type === "!group") { + const conjunction = defaultGroupConjunction(config, fieldConfig); + current = current.set("conjunction", conjunction); + } + + return current; +}; + + +export const defaultItemProperties = (config, item) => { + return item?.type == "group" + ? defaultGroupProperties(config) + : defaultRuleProperties(config, null, item); +}; + +export const defaultRule = (id, config) => ({ + [id]: new Immutable.Map({ + type: "rule", + id: id, + properties: defaultRuleProperties(config) + }) +}); + +export const defaultRoot = (config, canAddDefaultRule = true) => { + return new Immutable.Map({ + type: "group", + id: uuid(), + children1: new Immutable.OrderedMap(canAddDefaultRule ? { ...defaultRule(uuid(), config) } : {}), + properties: defaultGroupProperties(config) + }); +}; diff --git a/packages/core/modules/utils/defaultUtils.js b/packages/core/modules/utils/defaultUtils.js index 1d70efd40..22e5764bc 100644 --- a/packages/core/modules/utils/defaultUtils.js +++ b/packages/core/modules/utils/defaultUtils.js @@ -1,12 +1,39 @@ import Immutable from "immutable"; -import uuid from "./uuid"; -import {getFieldConfig, getOperatorConfig, getFieldParts, getFirstField} from "./configUtils"; -import {getFirstOperator} from "../utils/ruleUtils"; -import {getNewValueForFieldOp} from "../utils/validation"; +import {getFieldConfig, getFieldParts, getFirstField, getOperatorConfig, getFirstOperator} from "./configUtils"; import { isImmutable, isImmutableList } from "./stuff"; -import { jsToImmutable } from "../import"; +import { jsToImmutable } from "./treeUtils"; +// @deprecated Use defaultGroupConjunction +export const defaultConjunction = (config) => defaultGroupConjunction(config); + +//used for complex operators like proximity +export const defaultOperatorOptions = (config, operator, field) => { + let operatorConfig = operator ? getOperatorConfig(config, operator, field) : null; + if (!operatorConfig) + return null; //new Immutable.Map(); + return operatorConfig.options ? new Immutable.Map( + operatorConfig.options + && operatorConfig.options.defaults || {} + ) : null; +}; + +export const defaultGroupConjunction = (config, groupFieldConfig = null) => { + groupFieldConfig = getFieldConfig(config, groupFieldConfig); // if `groupFieldConfig` is field name, not config + const conjs = groupFieldConfig?.conjunctions || Object.keys(config.conjunctions); + if (conjs.length == 1) + return conjs[0]; + // todo: config.settings.defaultGroupConjunction is deprecated, defaultConjunction should be used instead + return groupFieldConfig?.defaultConjunction || config.settings.defaultConjunction || config.settings.defaultGroupConjunction || conjs[0]; +}; + +export const defaultGroupProperties = (config, groupFieldConfig = null) => { + return new Immutable.Map({ + conjunction: defaultGroupConjunction(config, groupFieldConfig), + not: false + }); +}; + export const getDefaultField = (config, canGetFirst = true, parentRuleGroupField = null) => { const {defaultField} = config.settings; let f = (!parentRuleGroupField ? defaultField : getDefaultSubField(config, parentRuleGroupField)) @@ -48,115 +75,6 @@ export const getDefaultOperator = (config, field, canGetFirst = true) => { return op; }; -//used for complex operators like proximity -export const defaultOperatorOptions = (config, operator, field) => { - let operatorConfig = operator ? getOperatorConfig(config, operator, field) : null; - if (!operatorConfig) - return null; //new Immutable.Map(); - return operatorConfig.options ? new Immutable.Map( - operatorConfig.options - && operatorConfig.options.defaults || {} - ) : null; -}; - -export const defaultRuleProperties = (config, parentRuleGroupField = null, item = null, canUseDefaultFieldAndOp = true, canGetFirst = false) => { - let field = null, operator = null, fieldSrc = null; - const {showErrorMessage} = config.settings; - if (item) { - fieldSrc = item?.properties?.fieldSrc; - field = item?.properties?.field; - operator = item?.properties?.operator; - } else if (canUseDefaultFieldAndOp) { - field = getDefaultField(config, canGetFirst, parentRuleGroupField); - if (field) { - fieldSrc = isImmutable(field) ? "func" : "field"; - } else { - fieldSrc = getDefaultFieldSrc(config); - } - operator = getDefaultOperator(config, field, true); - } else { - fieldSrc = getDefaultFieldSrc(config); - } - let current = new Immutable.Map({ - fieldSrc: fieldSrc, - field: field, - operator: operator, - value: new Immutable.List(), - valueSrc: new Immutable.List(), - //used for complex operators like proximity - operatorOptions: defaultOperatorOptions(config, operator, field), - }); - if (showErrorMessage) { - current = current.set("valueError", new Immutable.List()); - } - - if (field && operator) { - const canFix = false; - let {newValue, newValueSrc, newValueType, newValueError, newFieldError} = getNewValueForFieldOp( - config, config, current, field, operator, "operator", canFix - ); - current = current - .set("value", newValue) - .set("valueSrc", newValueSrc) - .set("valueType", newValueType); - if (showErrorMessage) { - current = current - .set("valueError", newValueError) - .set("fieldError", newFieldError); - } - } - - const fieldConfig = getFieldConfig(config, field); - if (fieldConfig?.type === "!group") { - const conjunction = defaultGroupConjunction(config, fieldConfig); - current = current.set("conjunction", conjunction); - } - - return current; -}; - - -export const defaultGroupConjunction = (config, groupFieldConfig = null) => { - groupFieldConfig = getFieldConfig(config, groupFieldConfig); // if `groupFieldConfig` is field name, not config - const conjs = groupFieldConfig?.conjunctions || Object.keys(config.conjunctions); - if (conjs.length == 1) - return conjs[0]; - // todo: config.settings.defaultGroupConjunction is deprecated, defaultConjunction should be used instead - return groupFieldConfig?.defaultConjunction || config.settings.defaultConjunction || config.settings.defaultGroupConjunction || conjs[0]; -}; - -// @deprecated Use defaultGroupConjunction -export const defaultConjunction = (config) => defaultGroupConjunction(config); - -export const defaultGroupProperties = (config, groupFieldConfig = null) => { - return new Immutable.Map({ - conjunction: defaultGroupConjunction(config, groupFieldConfig), - not: false - }); -}; - -export const defaultItemProperties = (config, item) => { - return item?.type == "group" - ? defaultGroupProperties(config) - : defaultRuleProperties(config, null, item); -}; - -export const defaultRule = (id, config) => ({ - [id]: new Immutable.Map({ - type: "rule", - id: id, - properties: defaultRuleProperties(config) - }) -}); - -export const defaultRoot = (config, canAddDefaultRule = true) => { - return new Immutable.Map({ - type: "group", - id: uuid(), - children1: new Immutable.OrderedMap(canAddDefaultRule ? { ...defaultRule(uuid(), config) } : {}), - properties: defaultGroupProperties(config) - }); -}; export const createListWithOneElement = (el) => { if (isImmutableList(el)) @@ -171,3 +89,4 @@ export const createListFromArray = (arr) => { }; export const emptyProperties = () => new Immutable.Map(); + diff --git a/packages/core/modules/utils/export.js b/packages/core/modules/utils/export.js index 7424a1473..45423b766 100644 --- a/packages/core/modules/utils/export.js +++ b/packages/core/modules/utils/export.js @@ -3,13 +3,16 @@ import SqlStringOrig from "sqlstring"; export const SqlString = SqlStringOrig; SqlString.trim = (val) => { - if (val.charAt(0) == "'") + if (val?.charAt(0) == "'") return val.substring(1, val.length-1); else return val; }; SqlString.escapeLike = (val, any_start = true, any_end = true) => { + if (typeof val !== "string") { + return val; + } // normal escape let res = SqlString.escape(val); // unwrap '' diff --git a/packages/core/modules/utils/funcUtils.js b/packages/core/modules/utils/funcUtils.js index 66c5e531b..1b0d40b7e 100644 --- a/packages/core/modules/utils/funcUtils.js +++ b/packages/core/modules/utils/funcUtils.js @@ -1,60 +1,11 @@ -import {getFieldConfig, getFuncConfig, getFuncSignature} from "../utils/configUtils"; -import {filterValueSourcesForField, completeValue, selectTypes} from "../utils/ruleUtils"; +import {getFieldConfig, getFuncConfig, getFuncSignature, selectTypes} from "../utils/configUtils"; +import {getDefaultArgValue, setArgValue, setFuncDefaultArgs, setFuncDefaultArg} from "../utils/ruleUtils"; import {validateValue} from "../utils/validation"; import Immutable from "immutable"; -// helpers -const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); - -/** - * @param {Immutable.Map} value - * @param {object} config - * @return {Immutable.Map | undefined} - undefined if func value is not complete (missing required arg vals); can return completed value != value - */ -export const completeFuncValue = (value, config) => { - if (!value) - return undefined; - const funcKey = value.get("func"); - const funcConfig = funcKey && getFuncConfig(config, funcKey); - if (!funcConfig) - return undefined; - let complValue = value; - let tmpHasOptional = false; - for (const argKey in funcConfig.args) { - const argConfig = funcConfig.args[argKey]; - const {valueSources, isOptional, defaultValue} = argConfig; - const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); - const args = complValue.get("args"); - const argDefaultValueSrc = filteredValueSources.length == 1 ? filteredValueSources[0] : undefined; - const argVal = args ? args.get(argKey) : undefined; - const argValue = argVal ? argVal.get("value") : undefined; - const argValueSrc = (argVal ? argVal.get("valueSrc") : undefined) || argDefaultValueSrc; - if (argValue !== undefined) { - const completeArgValue = completeValue(argValue, argValueSrc, config); - if (completeArgValue === undefined) { - return undefined; - } else if (completeArgValue !== argValue) { - complValue = complValue.setIn(["args", argKey, "value"], completeArgValue); - } - if (tmpHasOptional) { - // has gap - return undefined; - } - } else if (defaultValue !== undefined && !isObject(defaultValue)) { - complValue = complValue.setIn(["args", argKey, "value"], getDefaultArgValue(argConfig)); - complValue = complValue.setIn(["args", argKey, "valueSrc"], "value"); - } else if (isOptional) { - // optional - tmpHasOptional = true; - } else { - // missing value - return undefined; - } - } - return complValue; -}; +export { setArgValue, setFuncDefaultArgs, setFuncDefaultArg, getDefaultArgValue }; /** @@ -126,68 +77,10 @@ export const setFunc = (value, funcKey, config, canFixArgs) => { return value; }; -const setFuncDefaultArgs = (config, funcValue, funcConfig) => { - if (funcConfig) { - for (const argKey in funcConfig.args) { - funcValue = setFuncDefaultArg(config, funcValue, funcConfig, argKey); - } - } - return funcValue; -}; -export const setFuncDefaultArg = (config, funcValue, funcConfig, argKey) => { - const argConfig = funcConfig.args[argKey]; - const {valueSources, defaultValue} = argConfig; - const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); - const firstValueSrc = filteredValueSources.length ? filteredValueSources[0] : undefined; - const defaultValueSrc = defaultValue ? (isObject(defaultValue) && !!defaultValue.func ? "func" : "value") : undefined; - const argDefaultValueSrc = defaultValueSrc || firstValueSrc; - const hasValue = funcValue.getIn(["args", argKey]); - if (!hasValue) { - if (defaultValue !== undefined) { - funcValue = funcValue.setIn(["args", argKey, "value"], getDefaultArgValue(argConfig)); - } - if (argDefaultValueSrc) { - funcValue = funcValue.setIn(["args", argKey, "valueSrc"], argDefaultValueSrc); - } - } - return funcValue; -}; -const getDefaultArgValue = ({defaultValue: value}) => { - if (isObject(value) && !Immutable.Map.isMap(value) && value.func) { - return Immutable.fromJS(value, function (k, v) { - return Immutable.Iterable.isIndexed(v) ? v.toList() : v.toOrderedMap(); - }); - } - return value; -}; -/** -* Used @ FuncWidget -* @param {Immutable.Map} value -* @param {string} argKey -* @param {*} argVal -* @param {object} argConfig -*/ -export const setArgValue = (value, argKey, argVal, argConfig, config) => { - if (value && value.get("func")) { - value = value.setIn(["args", argKey, "value"], argVal); - - // set default arg value source - const valueSrc = value.getIn(["args", argKey, "valueSrc"]); - const {valueSources} = argConfig; - const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); - let argDefaultValueSrc = filteredValueSources.length == 1 ? filteredValueSources[0] : undefined; - if (!argDefaultValueSrc && filteredValueSources.includes("value")) { - argDefaultValueSrc = "value"; - } - if (!valueSrc && argDefaultValueSrc) { - value = value.setIn(["args", argKey, "valueSrc"], argDefaultValueSrc); - } - } - return value; -}; + /** * Used @ FuncWidget diff --git a/packages/core/modules/utils/getNewValueForFieldOp.js b/packages/core/modules/utils/getNewValueForFieldOp.js new file mode 100644 index 000000000..548c9dc02 --- /dev/null +++ b/packages/core/modules/utils/getNewValueForFieldOp.js @@ -0,0 +1,326 @@ +import Immutable from "immutable"; +import { + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getWidgetForFieldOp, getValueSourcesForFieldOp, selectTypes, +} from "./configUtils"; +import { getOpCardinality, getFirstDefined } from "./stuff"; +import { translateValidation } from "../i18n"; + +/** + * @param {Immutable.Map} current + * @param {string} changedProp + * @param {boolean} canFix (default: false) true - eg. set value to max if it > max or revert or drop + * @param {boolean} isEndValue (default: false) true - if value is in process of editing by user + * @param {boolean} canDropArgs (default: false) + * @return {{canReuseValue, newValue, newValueSrc, newValueType, fixedField, operatorCardinality, newValueError, newFieldError, validationErrors}} + */ +export const getNewValueForFieldOp = function ( + { + // DO NOT import { validateValue, validateRange } from "./validation" + // it will create import loop + validateValue, + validateRange, + }, + config, oldConfig = null, current, newField, newOperator, changedProp = null, + canFix = false, isEndValue = false, canDropArgs = false +) { + //const isValidatingTree = (changedProp === null); + if (!oldConfig) + oldConfig = config; + const { + keepInputOnChangeFieldSrc, convertableWidgets, clearValueOnChangeField, clearValueOnChangeOp, + } = config.settings; + const isCase = newField == "!case_value"; + let currentField = current.get("field"); + if (!currentField && isCase) { + currentField = newField; + } + const currentFieldType = current.get("fieldType"); + const currentFieldSrc = current.get("fieldSrc"); + const currentOperator = current.get("operator"); + const currentValue = current.get("value"); + const currentValueSrc = current.get("valueSrc", new Immutable.List()); + const currentValueType = current.get("valueType", new Immutable.List()); + const currentValueError = current.get("valueError", new Immutable.List()); + const asyncListValues = current.get("asyncListValues"); + + const isOkWithoutOperator = isCase; + const currentOperatorConfig = getOperatorConfig(oldConfig, currentOperator); + const newOperatorConfig = getOperatorConfig(config, newOperator, newField); + const currentOperatorCardinality = isCase ? 1 : currentOperator ? getOpCardinality(currentOperatorConfig) : null; + const operatorCardinality = isCase ? 1 : newOperator ? getOpCardinality(newOperatorConfig) : null; + const currentFieldConfig = getFieldConfig(oldConfig, currentField); + const newFieldConfig = getFieldConfig(config, newField); + const isOkWithoutField = !currentField && currentFieldType && keepInputOnChangeFieldSrc; + const currentType = currentFieldConfig?.type || currentFieldType; + const newType = newFieldConfig?.type || !newField && isOkWithoutField && currentType; + const currentListValuesType = currentFieldConfig?.listValuesType; + const newListValuesType = newFieldConfig?.listValuesType; + const currentFieldSimpleValue = currentField?.get?.("func") || currentField; + const newFieldSimpleValue = newField?.get?.("func") || newField; + const hasFieldChanged = newFieldSimpleValue != currentFieldSimpleValue; + + let validationErrors = []; + + let canReuseValue = (currentField || isOkWithoutField) + && (currentOperator && newOperator || isOkWithoutOperator) + && currentValue != undefined; + if ( + !(currentType && newType && currentType == newType) + || changedProp === "field" && hasFieldChanged && clearValueOnChangeField + || changedProp === "operator" && clearValueOnChangeOp + ) { + canReuseValue = false; + } + if (hasFieldChanged && selectTypes.includes(newType)) { + if (newListValuesType && newListValuesType === currentListValuesType) { + // ok + } else { + // different fields of select types has different listValues + canReuseValue = false; + } + } + if (!isOkWithoutOperator && (!currentValue?.size && operatorCardinality || currentValue?.size && !operatorCardinality)) { + canReuseValue = false; + } + + // validate func LHS + let newFieldError; + if (currentFieldSrc === "func" && newField) { + const [fixedField, fieldErrors] = validateValue( + config, null, null, newOperator, newField, newType, currentFieldSrc, asyncListValues, canFix, isEndValue, canDropArgs + ); + const isValid = !fieldErrors?.length; + const willFix = fixedField !== newField; + const willFixAllErrors = !isValid && willFix && !fieldErrors.find(e => !e.fixed); + const willRevert = canFix && !isValid && !willFixAllErrors && !!changedProp && newField !== currentField; + const willDrop = false; //canFix && !isValid && !willFixAllErrors && !willRevert && !changedProp; + if (willDrop) { + newField = null; + } else if (willRevert) { + newField = currentField; + } else if (willFix) { + newField = fixedField; + } + if (!isValid) { + const showError = !isValid && !willFixAllErrors && !willDrop && !willRevert; + const firstError = fieldErrors.find(e => !e.fixed && !e.ignore); + if (showError && firstError) { + newFieldError = translateValidation(firstError); + } + // tip: even if we don't show errors, but revert LHS, put the reason of revert + fieldErrors.map(e => validationErrors.push({ + side: "lhs", + ...e, + fixed: e.fixed || willRevert || willDrop, + })); + } + } + + // compare old & new widgets + for (let i = 0 ; i < operatorCardinality ; i++) { + const vs = currentValueSrc.get(i) || null; + const currentWidget = getWidgetForFieldOp(oldConfig, currentField, currentOperator, vs); + const newWidget = getWidgetForFieldOp(config, newField, newOperator, vs); + // need to also check value widgets if we changed operator and current value source was 'field' + // cause for select type op '=' requires single value and op 'in' requires array value + const currentValueWidget = vs === "value" ? currentWidget + : getWidgetForFieldOp(oldConfig, currentField, currentOperator, "value"); + const newValueWidget = vs === "value" ? newWidget + : getWidgetForFieldOp(config, newField, newOperator, "value"); + + const canReuseWidget = newValueWidget == currentValueWidget + || (convertableWidgets[currentValueWidget] || []).includes(newValueWidget) + || !currentValueWidget && isOkWithoutField; + if (!canReuseWidget) { + canReuseValue = false; + } + } + + if (currentOperator != newOperator && [currentOperator, newOperator].includes("proximity")) { + canReuseValue = false; + } + + const firstValueSrc = currentValueSrc.first(); + const firstWidgetConfig = getFieldWidgetConfig(config, newField, newOperator, null, firstValueSrc); + let valueSources = getValueSourcesForFieldOp(config, newField, newOperator, null); + if (!newField && isOkWithoutField) { + valueSources = Object.keys(config.settings.valueSourcesInfo); + } + const defaultValueSrc = valueSources[0]; + let defaultValueType; + if (operatorCardinality === 1 && firstWidgetConfig && firstWidgetConfig.type !== undefined) { + defaultValueType = firstWidgetConfig.type; + } else if (operatorCardinality === 1 && newFieldConfig && newFieldConfig.type !== undefined) { + defaultValueType = newFieldConfig.type === "!group" ? "number" : newFieldConfig.type; + } + + // changed operator from '==' to 'between' + let canExtendValueToRange = canReuseValue && changedProp === "operator" + && currentOperatorCardinality === 1 && operatorCardinality === 2; + + let valueFixes = []; + let valueSrcFixes = []; + let valueTypeFixes = []; + let valueErrors = Array.from({length: operatorCardinality}, () => null); + if (canReuseValue) { + for (let i = 0 ; i < operatorCardinality ; i++) { + let v = currentValue.get(i); + let vType = currentValueType.get(i) || null; + let vSrc = currentValueSrc.get(i) || null; + if (canExtendValueToRange && i === 1) { + v = valueFixes[0] !== undefined ? valueFixes[0] : currentValue.get(0); + valueFixes[i] = v; + vType = currentValueType.get(0) || null; + vSrc = currentValueSrc.get(0) || null; + } + const isValidSrc = vSrc ? (valueSources.find(v => v == vSrc) !== undefined) : true; + const [fixedValue, allErrors] = validateValue( + config, newField, newField, newOperator, v, vType, vSrc, asyncListValues, canFix, isEndValue, canDropArgs + ); + const isValid = !allErrors?.length; + // Allow bad value with error message + // But not on field change - in that case just drop bad value that can't be reused + // ? Maybe we should also drop bad value on op change? + // For bad multiselect value we have both error message + fixed value. + // If we show error message, it will gone on next tree validation + const willFix = fixedValue !== v; + const willFixAllErrors = !isValid && willFix && !allErrors?.find(e => !e.fixed); + const allErrorsHandled = !allErrors?.find(e => !e.fixed && !e.ignore); + + // tip: is value src is invalid, drop ANYWAY + // tip: Edge case in demo: + // Given "login = LOWER(?)", change config to not show errors -> "LOWER(?)" will be dropped + // We don't want to drop func completely, so need to add `allErrorsAheHandled` or `vSrc !== "func"` + // todo: `hasFieldChanged` is not needed ? + const willDrop = !isValidSrc + || canFix && !isValid && !willFixAllErrors && (!allErrorsHandled || hasFieldChanged); + if (!isValid) { + // tip: even if we don't show errors, but drop bad values, put the reason of removal + allErrors?.map(e => validationErrors.push({ + side: "rhs", + delta: i, + ...e, + fixed: e.fixed || willDrop, + })); + } + if (willDrop) { + valueFixes[i] = null; + if (i === 0) { + delete valueFixes[1]; + } + } + const showError = !isValid && !willFix; + const firstError = allErrors?.find(e => !e.fixed && !e.ignore); + if (showError && firstError) { + valueErrors[i] = translateValidation(firstError); + } + if (willFix) { + valueFixes[i] = fixedValue; + } + if (canExtendValueToRange && i === 0 && !isValid && !willFix) { + // don't extend bad value to range + canExtendValueToRange = false; + } + if (canExtendValueToRange && i === 0 && ["func", "field"].includes(vSrc)) { + // don't extend func/field value, only primitive value + canExtendValueToRange = false; + } + } + } + + // if can't reuse, get defaultValue + if (!canReuseValue) { + for (let i = 0 ; i < operatorCardinality ; i++) { + if (operatorCardinality === 1) { + // tip: default range values (for cardinality > 1) are not supported yet, todo + const dv = getFirstDefined([ + newFieldConfig?.defaultValue, + newFieldConfig?.fieldSettings?.defaultValue, + firstWidgetConfig?.defaultValue + ]); + valueFixes[i] = dv; + if (dv?.func) { + valueSrcFixes[i] = "func"; + //tip: defaultValue of src "field" is not supported, todo + } + } + } + } + + // set default valueSrc and valueType + for (let i = 0 ; i < operatorCardinality ; i++) { + let vs = canReuseValue && currentValueSrc.get(i) || null; + let vt = canReuseValue && currentValueType.get(i) || null; + if (canReuseValue && canExtendValueToRange && i === 1) { + vs = valueSrcFixes[i] ?? currentValueSrc.get(0); + vt = valueTypeFixes[i] ?? currentValueType.get(0); + valueSrcFixes[i] = vs; + valueTypeFixes[i] = vt; + } + const isValidSrc = valueSources.includes(vs); + if (!isValidSrc) { + valueSrcFixes[i] = defaultValueSrc; + } + if (!vt) { + valueTypeFixes[i] = defaultValueType; + } + } + + // build new values + let newValue = currentValue; + if (valueFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { + newValue = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { + return valueFixes[i] !== undefined ? valueFixes[i] : (canReuseValue ? currentValue.get(i) : undefined); + })); + } + let newValueSrc = currentValueSrc; + if (valueSrcFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { + newValueSrc = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { + return valueSrcFixes[i] ?? (canReuseValue && currentValueSrc.get(i) || null); + })); + } + let newValueType = currentValueType; + if (valueTypeFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { + newValueType = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { + return valueTypeFixes[i] ?? (canReuseValue && currentValueType.get(i) || null); + })); + } + + // Validate range + const rangeErrorObj = validateRange(config, newField, newOperator, newValue, newValueSrc); + if (rangeErrorObj) { + // last element in `valueError` list is for range validation error + const rangeValidationError = translateValidation(rangeErrorObj); + const willFix = canFix && operatorCardinality >= 2; + const badValue = newValue; + if (willFix) { + valueFixes[1] = newValue.get(0); + newValue = newValue.set(1, valueFixes[1]); + valueErrors[1] = valueErrors[0]; + } + const showError = !willFix; + if (showError) { + valueErrors.push(rangeValidationError); + } + validationErrors.push({ + side: "rhs", + delta: -1, + ...rangeErrorObj, + fixed: willFix, + fixedFrom: willFix ? [badValue.get(0), badValue.get(1)] : undefined, + fixedTo: willFix ? [newValue.get(0), newValue.get(1)] : undefined + }); + } + + let newValueError = currentValueError; + const hasValueErrorChanged = currentValueError?.size !== valueErrors.length + || valueErrors.filter((v, i) => (v != currentValueError.get(i))).length > 0; + if (hasValueErrorChanged) { + newValueError = new Immutable.List(valueErrors); + } + + return { + canReuseValue, newValue, newValueSrc, newValueType, operatorCardinality, fixedField: newField, + newValueError, newFieldError, validationErrors, + }; +}; diff --git a/packages/core/modules/utils/index.js b/packages/core/modules/utils/index.js index 6566c88b1..2dc36a553 100644 --- a/packages/core/modules/utils/index.js +++ b/packages/core/modules/utils/index.js @@ -1,21 +1,23 @@ -export {default as clone} from "clone"; -export {default as moment} from "moment"; -export {default as i18n} from "../i18n"; -export * as ConfigUtils from "./configUtils"; +export * as ConfigUtils from "./configAllUtils"; export * as RuleUtils from "./ruleUtils"; export * as FuncUtils from "./funcUtils"; -export * as DefaultUtils from "./defaultUtils"; +export * as DefaultUtils from "./defaultRuleUtils"; export * as TreeUtils from "./treeUtils"; export * as ExportUtils from "./export"; export * as ListUtils from "./listValues"; export * as Autocomplete from "./autocomplete"; export * as Validation from "./validation"; export * as OtherUtils from "./stuff"; +export {default as i18n} from "../i18n"; +// expose +export {default as moment} from "moment"; // in OtherUtils +export {default as clone} from "clone"; // in OtherUtils +export {default as uuid} from "./uuid"; // in OtherUtils // expose validation api to top level for convenience export {validateTree, sanitizeTree, isValidTree} from "./validation"; // deprecated export {checkTree, validateAndFixTree} from "./validation"; -export {default as uuid} from "./uuid"; -export {getSwitchValues} from "./treeUtils"; -export {compressConfig, decompressConfig} from "./configSerialize"; +// expose, deprecated +export {getSwitchValues} from "./treeUtils"; // in TreeUtils +export {compressConfig, decompressConfig} from "./configSerialize"; // in ConfigUtils diff --git a/packages/core/modules/utils/listValues.js b/packages/core/modules/utils/listValues.js index 82c6b8245..2dc653e6c 100644 --- a/packages/core/modules/utils/listValues.js +++ b/packages/core/modules/utils/listValues.js @@ -1,5 +1,4 @@ - -const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); +import { isObject } from "./stuff"; export const toListValue = (v, title) => { if (v == null || v == "") { diff --git a/packages/core/modules/utils/ruleUtils.js b/packages/core/modules/utils/ruleUtils.js index b8406b353..e6bf12696 100644 --- a/packages/core/modules/utils/ruleUtils.js +++ b/packages/core/modules/utils/ruleUtils.js @@ -1,33 +1,13 @@ +import Immutable from "immutable"; import { getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFieldRawConfig, getFuncConfig, getFieldParts, - isFieldDescendantOfField, getFieldId, _getFromConfigCache, _saveToConfigCache, + isFieldDescendantOfField, _getFromConfigCache, _saveToConfigCache, _getWidgetsAndSrcsForFieldOp, filterValueSourcesForField, } from "./configUtils"; +import {isObject} from "./stuff"; import last from "lodash/last"; -import {completeFuncValue} from "./funcUtils"; -export const selectTypes = [ - "select", - "multiselect", - "treeselect", - "treemultiselect", -]; -export const getOperatorsForType = (config, fieldType) => { - return config.types[fieldType]?.operators || null; -}; - -export const getOperatorsForField = (config, field) => { - const fieldConfig = getFieldConfig(config, field); - const fieldOps = fieldConfig ? fieldConfig.operators : []; - return fieldOps; -}; - -export const getFirstOperator = (config, field) => { - const fieldOps = getOperatorsForField(config, field); - return fieldOps?.[0] ?? null; -}; - export const calculateValueType = (value, valueSrc, config) => { let calculatedValueType = null; if (value) { @@ -71,31 +51,6 @@ export const getFieldPathLabels = (field, config, parentField = null, fieldsKey return res; }; -export const getFieldPartsConfigs = (field, config, parentField = null) => { - if (!field) - return null; - const parentFieldDef = parentField && getFieldRawConfig(config, parentField) || null; - const fieldSeparator = config.settings.fieldSeparator; - const parts = getFieldParts(field, config); - const isDescendant = isFieldDescendantOfField(field, parentField, config); - const parentParts = !isDescendant ? [] : getFieldParts(parentField, config); - return parts - .slice(parentParts.length) - .map((_curr, ind, arr) => arr.slice(0, ind+1)) - .map((parts) => ({ - part: [...parentParts, ...parts].join(fieldSeparator), - key: parts[parts.length - 1] - })) - .map(({part, key}) => { - const cnf = getFieldRawConfig(config, part); - return {key, cnf}; - }) - .map(({key, cnf}, ind, arr) => { - const parentCnf = ind > 0 ? arr[ind - 1].cnf : parentFieldDef; - return [key, cnf, parentCnf]; - }); -}; - export const getValueLabel = (config, field, operator, delta, valueSrc = null, isSpecialRange = false) => { // const isFuncArg = field && typeof field == "object" && !!field.func && !!field.arg; // const {showLabels} = config.settings; @@ -137,129 +92,7 @@ export const getValueLabel = (config, field, operator, delta, valueSrc = null, i return ret; }; -function _getWidgetsAndSrcsForFieldOp (config, field, operator = null, valueSrc = null) { - let widgets = []; - let valueSrcs = []; - if (!field) - return {widgets, valueSrcs}; - const fieldCacheKey = getFieldId(field); - const cacheKey = fieldCacheKey ? `${fieldCacheKey}__${operator}__${valueSrc}` : null; - const cached = _getFromConfigCache(config, "_getWidgetsAndSrcsForFieldOp", cacheKey); - if (cached) - return cached; - const isFuncArg = typeof field === "object" && (!!field.func && !!field.arg || field._isFuncArg); - const fieldConfig = getFieldConfig(config, field); - const opConfig = operator ? config.operators[operator] : null; - - if (fieldConfig?.widgets) { - for (const widget in fieldConfig.widgets) { - const widgetConfig = fieldConfig.widgets[widget]; - if (!config.widgets[widget]) { - continue; - } - const widgetValueSrc = config.widgets[widget].valueSrc || "value"; - let canAdd = true; - if (widget === "field") { - canAdd = canAdd && filterValueSourcesForField(config, ["field"], fieldConfig).length > 0; - } - if (widget === "func") { - canAdd = canAdd && filterValueSourcesForField(config, ["func"], fieldConfig).length > 0; - } - // If can't check operators, don't add - // Func args don't have operators - if (valueSrc === "value" && !widgetConfig.operators && !isFuncArg && field !== "!case_value") - canAdd = false; - if (widgetConfig.operators && operator) - canAdd = canAdd && widgetConfig.operators.indexOf(operator) != -1; - if (valueSrc && valueSrc != widgetValueSrc && valueSrc !== "const") - canAdd = false; - if (opConfig && opConfig.cardinality == 0 && (widgetValueSrc !== "value")) - canAdd = false; - if (canAdd) { - widgets.push(widget); - let canAddValueSrc = fieldConfig.valueSources?.indexOf(widgetValueSrc) != -1; - if (opConfig?.valueSources?.indexOf(widgetValueSrc) == -1) - canAddValueSrc = false; - if (canAddValueSrc && !valueSrcs.find(v => v == widgetValueSrc)) - valueSrcs.push(widgetValueSrc); - } - } - } - - const widgetWeight = (w) => { - let wg = 0; - if (fieldConfig.preferWidgets) { - if (fieldConfig.preferWidgets.includes(w)) - wg += (10 - fieldConfig.preferWidgets.indexOf(w)); - } else if (w == fieldConfig.mainWidget) { - wg += 100; - } - if (w === "field") { - wg -= 1; - } - if (w === "func") { - wg -= 2; - } - return wg; - }; - - widgets.sort((w1, w2) => (widgetWeight(w2) - widgetWeight(w1))); - - const res = { widgets, valueSrcs }; - _saveToConfigCache(config, "_getWidgetsAndSrcsForFieldOp", cacheKey, res); - return res; -} - -export const getWidgetsForFieldOp = (config, field, operator, valueSrc = null) => { - const {widgets} = _getWidgetsAndSrcsForFieldOp(config, field, operator, valueSrc); - return widgets; -}; - -export const filterValueSourcesForField = (config, valueSrcs, fieldDefinition) => { - if (!fieldDefinition) - return valueSrcs; - let fieldType = fieldDefinition.type ?? fieldDefinition.returnType; - if (fieldType === "!group") { - // todo: aggregation can be not only number? - fieldType = "number"; - } - // const { _isCaseValue } = fieldDefinition; - if (!valueSrcs) - valueSrcs = Object.keys(config.settings.valueSourcesInfo); - return valueSrcs.filter(vs => { - let canAdd = true; - if (vs === "field") { - if (config.__fieldsCntByType) { - // tip: LHS field can be used as arg in RHS function - const minCnt = fieldDefinition._isFuncArg ? 0 : 1; - canAdd = canAdd && config.__fieldsCntByType[fieldType] > minCnt; - } - } - if (vs === "func") { - if (fieldDefinition.funcs) { - canAdd = canAdd && fieldDefinition.funcs.length > 0; - } - if (config.__funcsCntByType) { - canAdd = canAdd && config.__funcsCntByType[fieldType] > 0; - } - } - return canAdd; - }); -}; - -export const getValueSourcesForFieldOp = (config, field, operator, fieldDefinition = null) => { - const {valueSrcs} = _getWidgetsAndSrcsForFieldOp(config, field, operator, null); - const filteredValueSrcs = filterValueSourcesForField(config, valueSrcs, fieldDefinition); - return filteredValueSrcs; -}; -export const getWidgetForFieldOp = (config, field, operator, valueSrc = null) => { - const {widgets} = _getWidgetsAndSrcsForFieldOp(config, field, operator, valueSrc); - let widget = null; - if (widgets.length) - widget = widgets[0]; - return widget; -}; // can use alias (fieldName) // even if `parentField` is provided, `field` is still a full path @@ -452,6 +285,54 @@ export const completeValue = (value, valueSrc, config) => { return value; }; +/** + * @param {Immutable.Map} value + * @param {object} config + * @return {Immutable.Map | undefined} - undefined if func value is not complete (missing required arg vals); can return completed value != value + */ +export const completeFuncValue = (value, config) => { + if (!value) + return undefined; + const funcKey = value.get("func"); + const funcConfig = funcKey && getFuncConfig(config, funcKey); + if (!funcConfig) + return undefined; + let complValue = value; + let tmpHasOptional = false; + for (const argKey in funcConfig.args) { + const argConfig = funcConfig.args[argKey]; + const {valueSources, isOptional, defaultValue} = argConfig; + const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); + const args = complValue.get("args"); + const argDefaultValueSrc = filteredValueSources.length == 1 ? filteredValueSources[0] : undefined; + const argVal = args ? args.get(argKey) : undefined; + const argValue = argVal ? argVal.get("value") : undefined; + const argValueSrc = (argVal ? argVal.get("valueSrc") : undefined) || argDefaultValueSrc; + if (argValue !== undefined) { + const completeArgValue = completeValue(argValue, argValueSrc, config); + if (completeArgValue === undefined) { + return undefined; + } else if (completeArgValue !== argValue) { + complValue = complValue.setIn(["args", argKey, "value"], completeArgValue); + } + if (tmpHasOptional) { + // has gap + return undefined; + } + } else if (defaultValue !== undefined && !isObject(defaultValue)) { + complValue = complValue.setIn(["args", argKey, "value"], getDefaultArgValue(argConfig)); + complValue = complValue.setIn(["args", argKey, "valueSrc"], "value"); + } else if (isOptional) { + // optional + tmpHasOptional = true; + } else { + // missing value + return undefined; + } + } + return complValue; +}; + // item - Immutable export const getOneChildOrDescendant = (item) => { const children = item.get("children1"); @@ -465,3 +346,70 @@ export const getOneChildOrDescendant = (item) => { } return null; }; + + +///// Func utils + + +export const getDefaultArgValue = ({defaultValue: value}) => { + if (isObject(value) && !Immutable.Map.isMap(value) && value.func) { + return Immutable.fromJS(value, function (k, v) { + return Immutable.Iterable.isIndexed(v) ? v.toList() : v.toOrderedMap(); + }); + } + return value; +}; + +/** +* Used @ FuncWidget +* @param {Immutable.Map} value +* @param {string} argKey +* @param {*} argVal +* @param {object} argConfig +*/ +export const setArgValue = (value, argKey, argVal, argConfig, config) => { + if (value && value.get("func")) { + value = value.setIn(["args", argKey, "value"], argVal); + + // set default arg value source + const valueSrc = value.getIn(["args", argKey, "valueSrc"]); + const {valueSources} = argConfig; + const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); + let argDefaultValueSrc = filteredValueSources.length == 1 ? filteredValueSources[0] : undefined; + if (!argDefaultValueSrc && filteredValueSources.includes("value")) { + argDefaultValueSrc = "value"; + } + if (!valueSrc && argDefaultValueSrc) { + value = value.setIn(["args", argKey, "valueSrc"], argDefaultValueSrc); + } + } + return value; +}; + +export const setFuncDefaultArgs = (config, funcValue, funcConfig) => { + if (funcConfig) { + for (const argKey in funcConfig.args) { + funcValue = setFuncDefaultArg(config, funcValue, funcConfig, argKey); + } + } + return funcValue; +}; + +export const setFuncDefaultArg = (config, funcValue, funcConfig, argKey) => { + const argConfig = funcConfig.args[argKey]; + const {valueSources, defaultValue} = argConfig; + const filteredValueSources = filterValueSourcesForField(config, valueSources, argConfig); + const firstValueSrc = filteredValueSources.length ? filteredValueSources[0] : undefined; + const defaultValueSrc = defaultValue ? (isObject(defaultValue) && !!defaultValue.func ? "func" : "value") : undefined; + const argDefaultValueSrc = defaultValueSrc || firstValueSrc; + const hasValue = funcValue.getIn(["args", argKey]); + if (!hasValue) { + if (defaultValue !== undefined) { + funcValue = funcValue.setIn(["args", argKey, "value"], getDefaultArgValue(argConfig)); + } + if (argDefaultValueSrc) { + funcValue = funcValue.setIn(["args", argKey, "valueSrc"], argDefaultValueSrc); + } + } + return funcValue; +}; diff --git a/packages/core/modules/utils/stuff.js b/packages/core/modules/utils/stuff.js index afa18502b..3b92075ec 100644 --- a/packages/core/modules/utils/stuff.js +++ b/packages/core/modules/utils/stuff.js @@ -1,7 +1,9 @@ import Immutable, { Map } from "immutable"; import {default as uuid} from "./uuid"; +import {default as clone} from "clone"; +import {default as moment} from "moment"; -export {uuid}; +export {uuid, clone, moment}; export const widgetDefKeysToOmit = [ "formatValue", "mongoFormatValue", "sqlFormatValue", "jsonLogic", "elasticSearchFormatValue", "spelFormatValue", "spelImportFuncs", "spelImportValue" @@ -14,6 +16,24 @@ export const opDefKeysToOmit = [ export const isObject = (v) => { return typeof v === "object" && v !== null && Object.prototype.toString.call(v) === "[object Object]"; }; +// export const isObject = (v) => (typeof v == "object" && v !== null && !Array.isArray(v)); +export const isObjectOrArray = (v) => (typeof v === "object" && v !== null); + +export const typeOf = (v) => { + const t = (typeof v); + if (t && v !== null && Array.isArray(v)) + return "array"; + else + return t; +}; + +export const isTypeOf = (v, type) => { + if (typeOf(v) === type) + return true; + if (type === "number" && !isNaN(v)) + return true; //can be casted + return false; +}; export const shallowCopy = (v) => { if (typeof v === "object" && v !== null) { @@ -26,6 +46,187 @@ export const shallowCopy = (v) => { return v; }; +export const setIn = (obj, path, newValue, opts) => { + const defaultOpts = { + canCreate: false, canIgnore: false, canChangeType: false, + }; + opts = { ...defaultOpts, ...(opts ?? {}) }; + const { canCreate, canIgnore, canChangeType } = opts; + if (!Array.isArray(path)) { + throw new Error("path is not an array"); + } + if (!path.length) { + throw new Error("path is empty"); + } + const expectedObjType = typeof path[0] === "number" ? "array" : "object"; + if (!isTypeOf(obj, expectedObjType)) { + throw new Error(`obj is not of type ${expectedObjType}`); + } + + let newObj = shallowCopy(obj); + + let target = newObj; + const pathToTarget = [...path]; + const targetKey = pathToTarget.pop(); + const goodPath = []; + for (const k of pathToTarget) { + const nextKey = path[goodPath.length]; + const expectedType = typeof nextKey === "number" ? "array" : "object"; + if (!isTypeOf(target[k], expectedType)) { + // value at path has another type + if (target[k] ? canChangeType : canCreate) { + target[k] = expectedType === "array" ? [] : {}; + } else if (canIgnore) { + target = undefined; + newObj = obj; // return initial obj as-is + break; + } else { + throw new Error(`Value by path ${goodPath.join(".")} should have type ${expectedType} but got ${typeOf(target[k])}`); + } + } + goodPath.push(k); + target[k] = shallowCopy(target[k]); + target = target[k]; + } + + if (target) { + if (newValue === undefined) { + delete target[targetKey]; + } else { + const oldValue = target[targetKey]; + if (typeof newValue === "function") { + target[targetKey] = newValue(oldValue); + } else { + target[targetKey] = newValue; + } + } + } + + return newObj; +}; + +export const mergeIn = (obj, mixin, opts) => { + const defaultOpts = { + canCreate: true, canChangeType: true, + deepCopyObj: false, deepCopyMixin: false, + arrayMergeMode: "merge", // "merge" | "join" | "joinMissing" | "joinRespectOrder" | "overwrite" + circularRefs: false, + specialSymbols: true, + }; + opts = { ...defaultOpts, ...(opts ?? {}) }; + const { deepCopyObj, deepCopyMixin, circularRefs, specialSymbols } = opts; + if (!isTypeOf(obj, "object")) { + throw new Error("obj is not an object"); + } + if (!isTypeOf(mixin, "object")) { + throw new Error("mixin is not an object"); + } + + // symbols + const $v = Symbol.for("_v"); + const $type = Symbol.for("_type"); + const $canCreate = Symbol.for("_canCreate"); + const $canChangeType = Symbol.for("_canChangeType"); + const $arrayMergeMode = Symbol.for("_arrayMergeMode"); + + const newObj = deepCopyObj ? clone(obj, circularRefs) : shallowCopy(obj); + let newObjChanged = false; + const _process = (path, targetMix, target, { + isMixingArray, isMixingRealArray, + } = {}) => { + let indexDelta = 0; + for (const mk in targetMix) { + const k = isMixingArray ? Number(mk) + indexDelta : mk; + const useSymbols = specialSymbols && isObjectOrArray(targetMix[mk]); + let canCreate = opts.canCreate, canChangeType = opts.canChangeType, arrayMergeMode = opts.arrayMergeMode; + let targetMixValue = targetMix[mk]; + let isMixValueExplicit = false; + let expectedType = typeOf(targetMixValue); + if (useSymbols) { + if ($v in targetMix[mk]) { + isMixValueExplicit = true; + targetMixValue = targetMix[mk][$v]; + } + expectedType = targetMix[mk]?.[$type] || typeOf(targetMixValue); + canCreate = targetMix[mk]?.[$canCreate] ?? canCreate; + canChangeType = targetMix[mk]?.[$canChangeType] ?? canChangeType; + arrayMergeMode = targetMix[mk]?.[$arrayMergeMode] ?? arrayMergeMode; + if (expectedType === "array" && arrayMergeMode === "overwrite") { + isMixValueExplicit = true; + } + } + if (expectedType !== "array") { + arrayMergeMode = undefined; + } + if (!isTypeOf(target[k], expectedType)) { + // value at path has another type + if (target[k] ? canChangeType : canCreate) { + if (expectedType === "array" || expectedType === "object") { + target[k] = expectedType === "array" ? [] : {}; + newObjChanged = true; + } else { + // primitive + } + } else { + continue; + } + } + if (expectedType === "array" || expectedType === "object") { + if (isMixValueExplicit) { + // deep copy from mix to target + newObjChanged = true; + target[k] = deepCopyMixin ? clone(targetMixValue, circularRefs) : shallowCopy(targetMixValue); + } else { + if (arrayMergeMode && ["join", "joinMissing", "joinRespectOrder"].includes(arrayMergeMode)) { + // join 2 arrays + newObjChanged = true; + const left = (deepCopyObj ? target[k] : clone(target[k], circularRefs)); + let right = (deepCopyMixin ? clone(targetMixValue, circularRefs) : targetMixValue); + if (arrayMergeMode === "joinRespectOrder") { + target[k] = mergeArraysSmart(left, right); + } else { + if (arrayMergeMode === "joinMissing") { + right = right.filter(v => !left.includes(v)); + } + target[k] = [ ...left, ...right ]; + } + } else { + // recursive merge + if (!deepCopyObj) { + target[k] = shallowCopy(target[k]); + } + _process([...path, mk], targetMixValue, target[k], { + isMixingArray: expectedType === "array", + isMixingRealArray: expectedType === "array" && !targetMix[mk]?.[$type], + }); + } + } + } else { + const needDelete = targetMixValue === undefined && !isMixingRealArray && !isMixValueExplicit; + const valueExists = (k in target); + if (needDelete) { + if (valueExists) { + newObjChanged = true; + if (Array.isArray(target)) { + target.splice(k, 1); + indexDelta--; + } else { + delete target[k]; + } + } + } else { + newObjChanged = true; + target[k] = targetMixValue; + } + } + } + }; + + _process([], mixin, newObj); + + return newObjChanged ? newObj : obj; +}; + export const omit = (obj, keys) => { return Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k))); }; @@ -237,30 +438,42 @@ export function mergeArraysSmart(arr1, arr2) { .map(([op, ind], i, orig) => { if (ind == -1) { const next = orig.slice(i+1); - const prev = orig.slice(0, i); - const after = prev.reverse().find(([_cop, ci]) => ci != -1); + const prevs = orig.slice(0, i); + const after = [...prevs].reverse().find(([_cop, ci]) => ci != -1); + const prev = prevs[prevs.length - 1]; const before = next.find(([_cop, ci]) => ci != -1); - if (before) + const isAfterDirectly = after && after === prevs[prevs.length-1]; + const isBeforeDirectly = before && next === next[0]; + if (isAfterDirectly) { + return [op, "after", after[0]]; + } else if (isBeforeDirectly) { return [op, "before", before[0]]; - else if (after) + } else if (after) { + if (prev) { + return [op, "after", prev[0]]; + } return [op, "after", after[0]]; - else + } else if (before) { + return [op, "before", before[0]]; + } else { return [op, "append", null]; + } } else { - // already exists + // already exists return null; } }) .filter(x => x !== null) .reduce((acc, [newOp, rel, relOp]) => { const ind = acc.indexOf(relOp); - if (acc.indexOf(newOp) == -1) { + if (acc.indexOf(newOp) === -1) { if (ind > -1) { - // insert after or before - acc.splice( ind + (rel == "after" ? 1 : 0), 0, newOp ); + // insert after or before + const offset = (rel === "after" ? 1 : 0); + acc.splice( ind + offset, 0, newOp ); } else { - // insert to end or start - acc.splice( (rel == "append" ? Infinity : 0), 0, newOp ); + // insert to end or start + acc.splice( (rel === "append" ? Infinity : 0), 0, newOp ); } } return acc; @@ -308,10 +521,19 @@ export const isJsonCompatible = (tpl, obj, bag = {}, path = []) => { } }; -const isDev = () => (typeof process !== "undefined" && process.env && process.env.NODE_ENV == "development"); - -export const getLogger = (devMode = false) => { - const verbose = devMode != undefined ? devMode : isDev(); +const isDev = () => (process?.env?.NODE_ENV == "development"); +const isTest = () => (process?.env?.NODE_ENV_TEST == "true"); + +export const getLogger = (devMode) => { + if (isTest()) { + return { + ...console, + log: () => {}, + debug: () => {}, + info: () => {}, + }; + } + const verbose = devMode != undefined ? devMode : isDev(); return verbose ? console : { error: () => {}, log: () => {}, diff --git a/packages/core/modules/utils/treeUtils.js b/packages/core/modules/utils/treeUtils.js index 30231f195..9ba5db7d6 100644 --- a/packages/core/modules/utils/treeUtils.js +++ b/packages/core/modules/utils/treeUtils.js @@ -1,11 +1,10 @@ -import Immutable from "immutable"; +import Immutable, { fromJS } from "immutable"; import {toImmutableList, isImmutable, applyToJS as immutableToJs} from "./stuff"; import {getFieldConfig} from "./configUtils"; -import {jsToImmutable} from "../import/tree"; import uuid from "./uuid"; export { - toImmutableList, jsToImmutable, immutableToJs, isImmutable, + toImmutableList, immutableToJs, isImmutable, }; /** @@ -545,3 +544,43 @@ export const _fixImmutableValue = (v) => { return v; } }; + +export function jsToImmutable(tree) { + const imm = fromJS(tree, function (key, value, path) { + const isFuncArg = path + && path.length > 3 + && path[path.length-1] === "value" + && path[path.length-3] === "args"; + const isRuleValue = path + && path.length > 3 + && path[path.length-1] === "value" + && path[path.length-2] === "properties"; + + let outValue; + if (key == "properties") { + outValue = value.toOrderedMap(); + + // `value` should be undefined instead of null + // JSON doesn't support undefined and replaces undefined -> null + // So fix: null -> undefined + for (let i = 0 ; i < 2 ; i++) { + if (outValue.get("value")?.get?.(i) === null) { + outValue = outValue.setIn(["value", i], undefined); + } + } + } else if (isFuncArg) { + outValue = _fixImmutableValue(value); + } else if ((path ? isRuleValue : key == "value") && Immutable.Iterable.isIndexed(value)) { + outValue = value.map(_fixImmutableValue).toList(); + } else if (key == "asyncListValues") { + // keep in JS format + outValue = value.toJS(); + } else if (key == "children1" && Immutable.Iterable.isIndexed(value)) { + outValue = new Immutable.OrderedMap(value.map(child => [child?.get("id") || uuid(), child])); + } else { + outValue = Immutable.Iterable.isIndexed(value) ? value.toList() : value.toOrderedMap(); + } + return outValue; + }); + return imm; +} diff --git a/packages/core/modules/utils/validation.js b/packages/core/modules/utils/validation.js index 32782f7a9..81181190c 100644 --- a/packages/core/modules/utils/validation.js +++ b/packages/core/modules/utils/validation.js @@ -1,38 +1,24 @@ import omit from "lodash/omit"; import Immutable from "immutable"; import { - getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldSrc, - extendConfig} from "./configUtils"; + getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldSrc, getWidgetForFieldOp, + getOperatorsForField, +} from "./configUtils"; +import {extendConfig} from "./configExtend"; import { - getOperatorsForField, getWidgetForFieldOp, whatRulePropertiesAreCompleted, - selectTypes, getValueSourcesForFieldOp, -} from "../utils/ruleUtils"; -import {getOpCardinality, getFirstDefined, deepEqual} from "../utils/stuff"; -import {getItemInListValues} from "../utils/listValues"; -import {defaultOperatorOptions} from "../utils/defaultUtils"; -import {fixPathsInTree, getItemByPath, getFlatTree} from "../utils/treeUtils"; -import {setFuncDefaultArg} from "../utils/funcUtils"; + whatRulePropertiesAreCompleted, setFuncDefaultArg, +} from "./ruleUtils"; +import { getNewValueForFieldOp } from "./getNewValueForFieldOp"; +import {getOpCardinality, deepEqual, isTypeOf, typeOf} from "./stuff"; +import {getItemInListValues} from "./listValues"; +import {defaultOperatorOptions} from "./defaultUtils"; +import {fixPathsInTree, getItemByPath, getFlatTree} from "./treeUtils"; import {queryString} from "../export/queryString"; import * as constants from "../i18n/validation/constains"; import { translateValidation } from "../i18n"; export { translateValidation }; -const typeOf = (v) => { - if (typeof v === "object" && v !== null && Array.isArray(v)) - return "array"; - else - return (typeof v); -}; - -const isTypeOf = (v, type) => { - if (typeOf(v) === type) - return true; - if (type === "number" && !isNaN(v)) - return true; //can be casted - return false; -}; - // tip: If showErrorMessage is false, this function will always return true export const isValidTree = (tree, config) => { return getTreeBadFields(tree, config).length === 0; @@ -619,7 +605,10 @@ function validateRule (item, path, itemId, meta, c) { const isEndValue = true; let { newValue, newValueSrc, newValueError, validationErrors, newFieldError, fixedField, - } = getNewValueForFieldOp(config, oldConfig, properties, field, operator, null, canFix, isEndValue); + } = getNewValueForFieldOp( + { validateValue, validateRange }, + config, oldConfig, properties, field, operator, null, canFix, isEndValue + ); value = newValue; valueSrc = newValueSrc; valueError = newValueError; @@ -1124,318 +1113,3 @@ export const validateRange = (config, field, operator, values, valueSrcs) => { return rangeError; }; - - -/** - * @param {Immutable.Map} current - * @param {string} changedProp - * @param {boolean} canFix (default: false) true - eg. set value to max if it > max or revert or drop - * @param {boolean} isEndValue (default: false) true - if value is in process of editing by user - * @param {boolean} canDropArgs (default: false) - * @return {{canReuseValue, newValue, newValueSrc, newValueType, fixedField, operatorCardinality, newValueError, newFieldError, validationErrors}} - */ -export const getNewValueForFieldOp = function ( - config, oldConfig = null, current, newField, newOperator, changedProp = null, - canFix = false, isEndValue = false, canDropArgs = false -) { - //const isValidatingTree = (changedProp === null); - if (!oldConfig) - oldConfig = config; - const { - keepInputOnChangeFieldSrc, convertableWidgets, clearValueOnChangeField, clearValueOnChangeOp, - } = config.settings; - const isCase = newField == "!case_value"; - let currentField = current.get("field"); - if (!currentField && isCase) { - currentField = newField; - } - const currentFieldType = current.get("fieldType"); - const currentFieldSrc = current.get("fieldSrc"); - const currentOperator = current.get("operator"); - const currentValue = current.get("value"); - const currentValueSrc = current.get("valueSrc", new Immutable.List()); - const currentValueType = current.get("valueType", new Immutable.List()); - const currentValueError = current.get("valueError", new Immutable.List()); - const asyncListValues = current.get("asyncListValues"); - - const isOkWithoutOperator = isCase; - const currentOperatorConfig = getOperatorConfig(oldConfig, currentOperator); - const newOperatorConfig = getOperatorConfig(config, newOperator, newField); - const currentOperatorCardinality = isCase ? 1 : currentOperator ? getOpCardinality(currentOperatorConfig) : null; - const operatorCardinality = isCase ? 1 : newOperator ? getOpCardinality(newOperatorConfig) : null; - const currentFieldConfig = getFieldConfig(oldConfig, currentField); - const newFieldConfig = getFieldConfig(config, newField); - const isOkWithoutField = !currentField && currentFieldType && keepInputOnChangeFieldSrc; - const currentType = currentFieldConfig?.type || currentFieldType; - const newType = newFieldConfig?.type || !newField && isOkWithoutField && currentType; - const currentListValuesType = currentFieldConfig?.listValuesType; - const newListValuesType = newFieldConfig?.listValuesType; - const currentFieldSimpleValue = currentField?.get?.("func") || currentField; - const newFieldSimpleValue = newField?.get?.("func") || newField; - const hasFieldChanged = newFieldSimpleValue != currentFieldSimpleValue; - - let validationErrors = []; - - let canReuseValue = (currentField || isOkWithoutField) - && (currentOperator && newOperator || isOkWithoutOperator) - && currentValue != undefined; - if ( - !(currentType && newType && currentType == newType) - || changedProp === "field" && hasFieldChanged && clearValueOnChangeField - || changedProp === "operator" && clearValueOnChangeOp - ) { - canReuseValue = false; - } - if (hasFieldChanged && selectTypes.includes(newType)) { - if (newListValuesType && newListValuesType === currentListValuesType) { - // ok - } else { - // different fields of select types has different listValues - canReuseValue = false; - } - } - if (!isOkWithoutOperator && (!currentValue?.size && operatorCardinality || currentValue?.size && !operatorCardinality)) { - canReuseValue = false; - } - - // validate func LHS - let newFieldError; - if (currentFieldSrc === "func" && newField) { - const [fixedField, fieldErrors] = validateValue( - config, null, null, newOperator, newField, newType, currentFieldSrc, asyncListValues, canFix, isEndValue, canDropArgs - ); - const isValid = !fieldErrors?.length; - const willFix = fixedField !== newField; - const willFixAllErrors = !isValid && willFix && !fieldErrors.find(e => !e.fixed); - const willRevert = canFix && !isValid && !willFixAllErrors && !!changedProp && newField !== currentField; - const willDrop = false; //canFix && !isValid && !willFixAllErrors && !willRevert && !changedProp; - if (willDrop) { - newField = null; - } else if (willRevert) { - newField = currentField; - } else if (willFix) { - newField = fixedField; - } - if (!isValid) { - const showError = !isValid && !willFixAllErrors && !willDrop && !willRevert; - const firstError = fieldErrors.find(e => !e.fixed && !e.ignore); - if (showError && firstError) { - newFieldError = translateValidation(firstError); - } - // tip: even if we don't show errors, but revert LHS, put the reason of revert - fieldErrors.map(e => validationErrors.push({ - side: "lhs", - ...e, - fixed: e.fixed || willRevert || willDrop, - })); - } - } - - // compare old & new widgets - for (let i = 0 ; i < operatorCardinality ; i++) { - const vs = currentValueSrc.get(i) || null; - const currentWidget = getWidgetForFieldOp(oldConfig, currentField, currentOperator, vs); - const newWidget = getWidgetForFieldOp(config, newField, newOperator, vs); - // need to also check value widgets if we changed operator and current value source was 'field' - // cause for select type op '=' requires single value and op 'in' requires array value - const currentValueWidget = vs === "value" ? currentWidget - : getWidgetForFieldOp(oldConfig, currentField, currentOperator, "value"); - const newValueWidget = vs === "value" ? newWidget - : getWidgetForFieldOp(config, newField, newOperator, "value"); - - const canReuseWidget = newValueWidget == currentValueWidget - || (convertableWidgets[currentValueWidget] || []).includes(newValueWidget) - || !currentValueWidget && isOkWithoutField; - if (!canReuseWidget) { - canReuseValue = false; - } - } - - if (currentOperator != newOperator && [currentOperator, newOperator].includes("proximity")) { - canReuseValue = false; - } - - const firstValueSrc = currentValueSrc.first(); - const firstWidgetConfig = getFieldWidgetConfig(config, newField, newOperator, null, firstValueSrc); - let valueSources = getValueSourcesForFieldOp(config, newField, newOperator, null); - if (!newField && isOkWithoutField) { - valueSources = Object.keys(config.settings.valueSourcesInfo); - } - const defaultValueSrc = valueSources[0]; - let defaultValueType; - if (operatorCardinality === 1 && firstWidgetConfig && firstWidgetConfig.type !== undefined) { - defaultValueType = firstWidgetConfig.type; - } else if (operatorCardinality === 1 && newFieldConfig && newFieldConfig.type !== undefined) { - defaultValueType = newFieldConfig.type === "!group" ? "number" : newFieldConfig.type; - } - - // changed operator from '==' to 'between' - let canExtendValueToRange = canReuseValue && changedProp === "operator" - && currentOperatorCardinality === 1 && operatorCardinality === 2; - - let valueFixes = []; - let valueSrcFixes = []; - let valueTypeFixes = []; - let valueErrors = Array.from({length: operatorCardinality}, () => null); - if (canReuseValue) { - for (let i = 0 ; i < operatorCardinality ; i++) { - let v = currentValue.get(i); - let vType = currentValueType.get(i) || null; - let vSrc = currentValueSrc.get(i) || null; - if (canExtendValueToRange && i === 1) { - v = valueFixes[0] !== undefined ? valueFixes[0] : currentValue.get(0); - valueFixes[i] = v; - vType = currentValueType.get(0) || null; - vSrc = currentValueSrc.get(0) || null; - } - const isValidSrc = vSrc ? (valueSources.find(v => v == vSrc) !== undefined) : true; - const [fixedValue, allErrors] = validateValue( - config, newField, newField, newOperator, v, vType, vSrc, asyncListValues, canFix, isEndValue, canDropArgs - ); - const isValid = !allErrors?.length; - // Allow bad value with error message - // But not on field change - in that case just drop bad value that can't be reused - // ? Maybe we should also drop bad value on op change? - // For bad multiselect value we have both error message + fixed value. - // If we show error message, it will gone on next tree validation - const willFix = fixedValue !== v; - const willFixAllErrors = !isValid && willFix && !allErrors?.find(e => !e.fixed); - const allErrorsHandled = !allErrors?.find(e => !e.fixed && !e.ignore); - - // tip: is value src is invalid, drop ANYWAY - // tip: Edge case in demo: - // Given "login = LOWER(?)", change config to not show errors -> "LOWER(?)" will be dropped - // We don't want to drop func completely, so need to add `allErrorsAheHandled` or `vSrc !== "func"` - // todo: `hasFieldChanged` is not needed ? - const willDrop = !isValidSrc - || canFix && !isValid && !willFixAllErrors && (!allErrorsHandled || hasFieldChanged); - if (!isValid) { - // tip: even if we don't show errors, but drop bad values, put the reason of removal - allErrors?.map(e => validationErrors.push({ - side: "rhs", - delta: i, - ...e, - fixed: e.fixed || willDrop, - })); - } - if (willDrop) { - valueFixes[i] = null; - if (i === 0) { - delete valueFixes[1]; - } - } - const showError = !isValid && !willFix; - const firstError = allErrors?.find(e => !e.fixed && !e.ignore); - if (showError && firstError) { - valueErrors[i] = translateValidation(firstError); - } - if (willFix) { - valueFixes[i] = fixedValue; - } - if (canExtendValueToRange && i === 0 && !isValid && !willFix) { - // don't extend bad value to range - canExtendValueToRange = false; - } - if (canExtendValueToRange && i === 0 && ["func", "field"].includes(vSrc)) { - // don't extend func/field value, only primitive value - canExtendValueToRange = false; - } - } - } - - // if can't reuse, get defaultValue - if (!canReuseValue) { - for (let i = 0 ; i < operatorCardinality ; i++) { - if (operatorCardinality === 1) { - // tip: default range values (for cardinality > 1) are not supported yet, todo - const dv = getFirstDefined([ - newFieldConfig?.defaultValue, - newFieldConfig?.fieldSettings?.defaultValue, - firstWidgetConfig?.defaultValue - ]); - valueFixes[i] = dv; - if (dv?.func) { - valueSrcFixes[i] = "func"; - //tip: defaultValue of src "field" is not supported, todo - } - } - } - } - - // set default valueSrc and valueType - for (let i = 0 ; i < operatorCardinality ; i++) { - let vs = canReuseValue && currentValueSrc.get(i) || null; - let vt = canReuseValue && currentValueType.get(i) || null; - if (canReuseValue && canExtendValueToRange && i === 1) { - vs = valueSrcFixes[i] ?? currentValueSrc.get(0); - vt = valueTypeFixes[i] ?? currentValueType.get(0); - valueSrcFixes[i] = vs; - valueTypeFixes[i] = vt; - } - const isValidSrc = valueSources.includes(vs); - if (!isValidSrc) { - valueSrcFixes[i] = defaultValueSrc; - } - if (!vt) { - valueTypeFixes[i] = defaultValueType; - } - } - - // build new values - let newValue = currentValue; - if (valueFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { - newValue = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { - return valueFixes[i] !== undefined ? valueFixes[i] : (canReuseValue ? currentValue.get(i) : undefined); - })); - } - let newValueSrc = currentValueSrc; - if (valueSrcFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { - newValueSrc = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { - return valueSrcFixes[i] ?? (canReuseValue && currentValueSrc.get(i) || null); - })); - } - let newValueType = currentValueType; - if (valueTypeFixes.length > 0 || !canReuseValue || operatorCardinality < currentOperatorCardinality) { - newValueType = new Immutable.List(Array.from({length: operatorCardinality}, (_ignore, i) => { - return valueTypeFixes[i] ?? (canReuseValue && currentValueType.get(i) || null); - })); - } - - // Validate range - const rangeErrorObj = validateRange(config, newField, newOperator, newValue, newValueSrc); - if (rangeErrorObj) { - // last element in `valueError` list is for range validation error - const rangeValidationError = translateValidation(rangeErrorObj); - const willFix = canFix && operatorCardinality >= 2; - const badValue = newValue; - if (willFix) { - valueFixes[1] = newValue.get(0); - newValue = newValue.set(1, valueFixes[1]); - valueErrors[1] = valueErrors[0]; - } - const showError = !willFix; - if (showError) { - valueErrors.push(rangeValidationError); - } - validationErrors.push({ - side: "rhs", - delta: -1, - ...rangeErrorObj, - fixed: willFix, - fixedFrom: willFix ? [badValue.get(0), badValue.get(1)] : undefined, - fixedTo: willFix ? [newValue.get(0), newValue.get(1)] : undefined - }); - } - - let newValueError = currentValueError; - const hasValueErrorChanged = currentValueError?.size !== valueErrors.length - || valueErrors.filter((v, i) => (v != currentValueError.get(i))).length > 0; - if (hasValueErrorChanged) { - newValueError = new Immutable.List(valueErrors); - } - - return { - canReuseValue, newValue, newValueSrc, newValueType, operatorCardinality, fixedField: newField, - newValueError, newFieldError, validationErrors, - }; -}; diff --git a/packages/core/package.json b/packages/core/package.json index 31cd0ae25..4668b08db 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,10 +38,11 @@ "scripts": { "build": "sh ./scripts/build-npm.sh", "eslint": "eslint --ext .js --ext .ts ./modules/", - "lint": "npm run eslint && npm run tsc", + "lint": "npm run eslint && npm run tsc && npm run circular", "lint-fix": "eslint --ext .js --ext .ts --fix ./modules/", "prepare": "npm run build", - "tsc": "tsc -p . --noEmit" + "tsc": "tsc -p . --noEmit", + "circular": "madge --circular ./modules" }, "dependencies": { "@babel/runtime": "^7.24.5", diff --git a/packages/core/scripts/build-npm.sh b/packages/core/scripts/build-npm.sh index 75024596d..2abd4d707 100755 --- a/packages/core/scripts/build-npm.sh +++ b/packages/core/scripts/build-npm.sh @@ -4,5 +4,8 @@ rm -rf ./esm babel -d ./cjs ./modules ESM=1 babel -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5bddb433b..0c097586b 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "target": "es6", "module": "esnext", - "jsx": "preserve", "lib": [ "es2015" ], diff --git a/packages/examples/package.json b/packages/examples/package.json index cf017ca5d..c1a7b1b65 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -44,6 +44,7 @@ "@react-awesome-query-builder/antd": "workspace:^", "@react-awesome-query-builder/bootstrap": "workspace:^", "@react-awesome-query-builder/core": "workspace:^", + "@react-awesome-query-builder/sql": "workspace:^", "@react-awesome-query-builder/fluent": "workspace:^", "@react-awesome-query-builder/material": "workspace:^", "@react-awesome-query-builder/mui": "workspace:^", diff --git a/packages/examples/src/demo/blocks/initFiles.tsx b/packages/examples/src/demo/blocks/initFiles.tsx index 193431a73..6e530ff1b 100644 --- a/packages/examples/src/demo/blocks/initFiles.tsx +++ b/packages/examples/src/demo/blocks/initFiles.tsx @@ -6,6 +6,9 @@ import type { DemoQueryBuilderState } from "../types"; import { importFromInitFile } from "../utils"; import { initFiles } from "../init_data"; +const stringify = JSON.stringify; +const preErrorStyle = { backgroundColor: "lightpink", margin: "10px", padding: "10px" }; + export const useInitFiles = ( state: DemoQueryBuilderState, @@ -13,20 +16,22 @@ export const useInitFiles = ( ) => { const changeInitFile = (e: React.ChangeEvent) => { const newInitFile = e.target.value; - const importedTree: ImmutableTree = importFromInitFile(newInitFile, state.config); + const {tree: importedTree, errors} = importFromInitFile(newInitFile, state.config); setState({ ...state, initFile: newInitFile, tree: importedTree, + initErrors: errors, }); window._initFile = newInitFile; }; const loadFromInitFile = () => { - const importedTree: ImmutableTree = importFromInitFile(state.initFile, state.config); + const {tree: importedTree, errors} = importFromInitFile(state.initFile, state.config); setState({ ...state, tree: importedTree, + initErrors: errors, }); }; @@ -45,7 +50,20 @@ export const useInitFiles = ( ); }; + const renderInitErrors = () => { + return ( + <> + { state.initErrors.length > 0 + &&

+            {stringify(state.initErrors, undefined, 2)}
+          
+ } + + ); + }; + return { renderInitFilesHeader, + renderInitErrors, }; }; diff --git a/packages/examples/src/demo/blocks/input.tsx b/packages/examples/src/demo/blocks/input.tsx index 71fc7c83e..4181c07bf 100644 --- a/packages/examples/src/demo/blocks/input.tsx +++ b/packages/examples/src/demo/blocks/input.tsx @@ -2,11 +2,13 @@ import React, { Dispatch, SetStateAction } from "react"; import { Utils, } from "@react-awesome-query-builder/ui"; +import { SqlUtils } from "@react-awesome-query-builder/sql"; import type { DemoQueryBuilderState } from "../types"; import { validationTranslateOptions } from "../options"; const stringify = JSON.stringify; const preErrorStyle = { backgroundColor: "lightpink", margin: "10px", padding: "10px" }; +const preWarningStyle = { backgroundColor: "lightyellow", margin: "10px", padding: "10px" }; export const useInput = ( @@ -21,6 +23,14 @@ export const useInput = ( }); }; + const onChangeSqlStr = (e: React.ChangeEvent) => { + const sqlStr = e.target.value; + setState({ + ...state, + sqlStr + }); + }; + const importFromSpel = () => { const [tree, spelErrors] = Utils.loadFromSpel(state.spelStr, state.config); const {fixedTree, fixedErrors} = Utils.sanitizeTree(tree!, state.config, validationTranslateOptions); @@ -34,6 +44,20 @@ export const useInput = ( }); }; + const importFromSql = () => { + const {tree, errors: sqlErrors, warnings: sqlWarnings} = SqlUtils.loadFromSql(state.sqlStr, state.config); + const {fixedTree, fixedErrors} = Utils.sanitizeTree(tree!, state.config, validationTranslateOptions); + if (fixedErrors.length) { + console.warn("Fixed errors after import from SQL:", fixedErrors); + } + setState({ + ...state, + tree: fixedTree ?? state.tree, + sqlErrors, + sqlWarnings, + }); + }; + const renderSpelInputBlock = () => { if (!state.renderBocks.spel) { return null; @@ -54,7 +78,42 @@ export const useInput = ( ); }; + const renderSqlInputBlock = () => { + if (!state.renderBocks.sql) { + return null; + } + + return ( +
+ SQL:   + + +
+ { state.sqlErrors.length > 0 + &&
+              {stringify(state.sqlErrors, undefined, 2)}
+            
+ } + { state.sqlWarnings.length > 0 + &&
+              {stringify(state.sqlWarnings, undefined, 2)}
+            
+ } +
+ ); + }; + + const renderInputs = () => { + return ( +
+
+ {renderSpelInputBlock()} + {renderSqlInputBlock()} +
+ ); + }; + return { - renderSpelInputBlock, + renderInputs, }; }; diff --git a/packages/examples/src/demo/config/index.tsx b/packages/examples/src/demo/config/index.tsx index fdaaaad8d..ec315f495 100644 --- a/packages/examples/src/demo/config/index.tsx +++ b/packages/examples/src/demo/config/index.tsx @@ -31,7 +31,8 @@ const { simulateAsyncFetch } = Utils.Autocomplete; export default (skin: string) => { - const InitialConfig = skinToConfig[skin] as BasicConfig; + const originalConfig = skinToConfig[skin] as BasicConfig; + const InitialConfig = originalConfig as BasicConfig; const demoListValues = [ { title: "A", value: "a" }, diff --git a/packages/examples/src/demo/index.tsx b/packages/examples/src/demo/index.tsx index 00a3c6d6c..3a172eb83 100644 --- a/packages/examples/src/demo/index.tsx +++ b/packages/examples/src/demo/index.tsx @@ -18,7 +18,7 @@ import "./i18n"; // Load config and initial tree const loadedConfig = loadConfig(window._initialSkin || initialSkin); -const initTree = initTreeWithValidation(window._initFile || defaultInitFile, loadedConfig, validationTranslateOptions); +const {tree: initTree, errors: initErrors} = initTreeWithValidation(window._initFile || defaultInitFile, loadedConfig, validationTranslateOptions); // Trick for HMR: triggers callback put in useHmrUpdate on every update from HMR dispatchHmrUpdate(loadedConfig, initTree); @@ -30,11 +30,15 @@ const DemoQueryBuilder: React.FC = () => { const memo: React.MutableRefObject = useRef({}); const [state, setState] = useState({ - tree: initTree, + tree: initTree, + initErrors: initErrors, config: loadedConfig, skin: initialSkin, spelStr: "", + sqlStr: "", spelErrors: [] as Array, + sqlErrors: [] as Array, + sqlWarnings: [] as Array, renderBocks: defaultRenderBlocks, initFile: defaultInitFile, }); @@ -48,9 +52,9 @@ const DemoQueryBuilder: React.FC = () => { const { renderValidationHeader, renderValidationBlock } = useValidation(state, setState); const { renderBenchmarkHeader } = useBenchmark(state, setState, memo); const { renderOutput } = useOutput(state); - const { renderSpelInputBlock } = useInput(state, setState); + const { renderInputs } = useInput(state, setState); const { renderConfigChangeHeader } = useConfigChange(state, setState); - const { renderInitFilesHeader } = useInitFiles(state, setState); + const { renderInitFilesHeader, renderInitErrors } = useInitFiles(state, setState); const { renderSkinSelector } = useSkins(state, setState); const { renderBlocksSwitcher } = useBlocksSwitcher(state, setState); @@ -88,6 +92,7 @@ const DemoQueryBuilder: React.FC = () => { setState({ ...state, tree: Utils.loadTree(emptyTree), + initErrors: [], }); }; @@ -107,6 +112,7 @@ const DemoQueryBuilder: React.FC = () => { Data:   {renderInitFilesHeader()} + {renderInitErrors()} {renderRunActions()}
@@ -118,8 +124,7 @@ const DemoQueryBuilder: React.FC = () => { {renderBenchmarkHeader()}
-
- {renderSpelInputBlock()} + {renderInputs()} diff --git a/packages/examples/src/demo/init_data/index.tsx b/packages/examples/src/demo/init_data/index.tsx index 01b067adb..00ae24a44 100644 --- a/packages/examples/src/demo/init_data/index.tsx +++ b/packages/examples/src/demo/init_data/index.tsx @@ -13,6 +13,10 @@ const { uuid } = Utils; export const emptyTree: JsonTree = {id: uuid(), type: "group"}; export const emptySwitchTree: JsonTree = {id: uuid(), type: "switch_group"}; + +const initSqlSimple = "LOWER(user.firstName) = UPPER('gg') AND (DATE_ADD(NOW(), INTERVAL 2 DAY) > TO_DATE('2024-07-26 00:00:00.000', 'yyyy-mi-dd hh:mm:ss.mmm') OR DATE_SUB(NOW(), INTERVAL 4 MONTH ) > '2024-05-10 00:00:00.000') AND num <> 2 AND bio = 'Long\\nText'" ; + + export const initFiles: Record = { "logic/complex": initLogicComplex, "logic/simple": initLogicSimple, @@ -24,4 +28,6 @@ export const initFiles: Record = { "tree/complex": initTreeComplex, "tree/empty": emptyTree, "tree/empty_switch": emptySwitchTree, + + "sql/simple": initSqlSimple, }; diff --git a/packages/examples/src/demo/types.tsx b/packages/examples/src/demo/types.tsx index 1a49ed92c..5ef4f5029 100644 --- a/packages/examples/src/demo/types.tsx +++ b/packages/examples/src/demo/types.tsx @@ -5,11 +5,15 @@ import { export interface DemoQueryBuilderState { tree: ImmutableTree; + initErrors: Array; config: Config; skin: string; renderBocks: Record; spelStr: string; + sqlStr: string; spelErrors: Array; + sqlErrors: Array; + sqlWarnings: Array; initFile: string; } diff --git a/packages/examples/src/demo/utils.tsx b/packages/examples/src/demo/utils.tsx index fd622f5ea..feb1fc730 100644 --- a/packages/examples/src/demo/utils.tsx +++ b/packages/examples/src/demo/utils.tsx @@ -3,6 +3,7 @@ import { Utils, ImmutableTree, Config, JsonTree, JsonLogicTree, SanitizeOptions, Actions } from "@react-awesome-query-builder/ui"; +import { SqlUtils } from "@react-awesome-query-builder/sql"; import { initFiles } from "./init_data"; @@ -39,34 +40,47 @@ export const useHmrUpdate = (callback: (detail: CustomEventDetail) => void) => { export const importFromInitFile = (fileKey: string, config?: Config) => { const fileType = fileKey.split("/")[0]; - let importedTree: ImmutableTree | undefined; + let tree: ImmutableTree | undefined; + let errors: string[] = []; if (fileType === "logic") { const initLogic = initFiles[fileKey] as JsonLogicTree; - importedTree = Utils.loadFromJsonLogic(initLogic, config); + tree = Utils.loadFromJsonLogic(initLogic, config); + } else if (fileType === "sql") { + const initValue = initFiles[fileKey] as string; + ({tree, errors} = SqlUtils.loadFromSql(initValue, config)); + } else if (fileType === "spel") { + const initValue = initFiles[fileKey] as string; + [tree, errors] = Utils.loadFromSpel(initValue, config); } else if (fileType === "tree") { const initValue = initFiles[fileKey] as JsonTree; - importedTree = Utils.loadTree(initValue); + tree = Utils.loadTree(initValue); } else { throw new Error(`Unknown file type ${fileType}`); } - return importedTree; + if (errors.length) { + console.warn(`Errors while importing from ${fileKey} as ${fileType}:`, errors); + } + return {tree, errors}; }; export const initTreeWithValidation = (initFileKey: string, config: Config, validationOptions?: Partial) => { - let initTree: ImmutableTree = importFromInitFile(initFileKey, config); - const {fixedTree, fixedErrors, nonFixedErrors} = Utils.sanitizeTree(initTree, config, { + let tree: ImmutableTree; + let errors: string[]; + // eslint-disable-next-line prefer-const + ({tree, errors} = importFromInitFile(initFileKey, config)); + const {fixedTree, fixedErrors, nonFixedErrors} = Utils.sanitizeTree(tree, config, { ...(validationOptions ?? {}), removeEmptyGroups: false, removeEmptyRules: false, removeIncompleteRules: false, }); - initTree = fixedTree; + tree = fixedTree; if (fixedErrors.length) { console.warn("Fixed tree errors on load: ", fixedErrors); } if (nonFixedErrors.length) { console.warn("Validation errors on load:", nonFixedErrors); } - return initTree; + return {tree, errors}; }; diff --git a/packages/examples/src/demo_switch/index.tsx b/packages/examples/src/demo_switch/index.tsx index 1f5d13c86..7b592e474 100644 --- a/packages/examples/src/demo_switch/index.tsx +++ b/packages/examples/src/demo_switch/index.tsx @@ -138,7 +138,7 @@ const Demo: React.FC = () => { Values:
-            {JSON.stringify(QbUtils.getSwitchValues(state.tree), undefined, 2)}
+            {JSON.stringify(QbUtils.TreeUtils.getSwitchValues(state.tree), undefined, 2)}
           

diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index 02ce94e6b..39382b221 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], @@ -22,6 +22,7 @@ "baseUrl": ".", "paths": { "@react-awesome-query-builder/core": ["../core/modules"], + "@react-awesome-query-builder/sql": ["../sql/modules"], "@react-awesome-query-builder/ui": ["../ui/modules"], "@react-awesome-query-builder/antd": ["../antd/modules"], "@react-awesome-query-builder/mui": ["../mui/modules"], diff --git a/packages/examples/webpack.config.js b/packages/examples/webpack.config.js index c6aa1af3f..adedeeb07 100644 --- a/packages/examples/webpack.config.js +++ b/packages/examples/webpack.config.js @@ -14,6 +14,7 @@ const isDev = (MODE == "development"); const isAnalyze = process.env.ANALYZE == "1"; const isSeparateCss = process.env.CSS == "1"; const EXAMPLES = __dirname; + const CORE_MODULES = path.resolve(EXAMPLES, '../core/modules/'); const UI_MODULES = path.resolve(EXAMPLES, '../ui/modules/'); const ANTD_MODULES = path.resolve(EXAMPLES, '../antd/modules/'); @@ -21,12 +22,15 @@ const MUI_MODULES = path.resolve(EXAMPLES, '../mui/modules/'); const MATERIAL_MODULES = path.resolve(EXAMPLES, '../material/modules/'); const BOOTSTRAP_MODULES = path.resolve(EXAMPLES, '../bootstrap/modules/'); const FLUENT_MODULES = path.resolve(EXAMPLES, '../fluent/modules/'); +const SQL_MODULES = path.resolve(EXAMPLES, '../sql/modules/'); + const UI_CSS = path.resolve(EXAMPLES, '../ui/styles/'); const ANTD_CSS = path.resolve(EXAMPLES, '../antd/styles/'); const MUI_CSS = path.resolve(EXAMPLES, '../mui/styles/'); const MATERIAL_CSS = path.resolve(EXAMPLES, '../material/styles/'); const BOOTSTRAP_CSS = path.resolve(EXAMPLES, '../bootstrap/styles/'); const FLUENT_CSS = path.resolve(EXAMPLES, '../fluent/styles/'); + const DIST = path.resolve(EXAMPLES, './build/'); const NODE_MODULES = path.resolve(EXAMPLES, './node_modules/'); const isMono = fs.existsSync(CORE_MODULES); @@ -58,6 +62,8 @@ let aliases = isMono ? { '@react-awesome-query-builder/material': MATERIAL_MODULES, '@react-awesome-query-builder/bootstrap': BOOTSTRAP_MODULES, '@react-awesome-query-builder/fluent': FLUENT_MODULES, + '@react-awesome-query-builder/sql': SQL_MODULES, + 'react': path.resolve(NODE_MODULES, 'react'), 'react-dom': path.resolve(NODE_MODULES, 'react-dom'), diff --git a/packages/fluent/scripts/build-npm.sh b/packages/fluent/scripts/build-npm.sh index e2fbcd373..5137aee5a 100755 --- a/packages/fluent/scripts/build-npm.sh +++ b/packages/fluent/scripts/build-npm.sh @@ -4,8 +4,11 @@ rm -rf ./esm babel -d ./cjs ./modules ESM=1 babel -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} rm -rf ./css sass -I node_modules -I ../../node_modules styles/:css/ --no-source-map --style=expanded diff --git a/packages/fluent/tsconfig.json b/packages/fluent/tsconfig.json index 9d6384be7..246aa4e6b 100644 --- a/packages/fluent/tsconfig.json +++ b/packages/fluent/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], diff --git a/packages/material/scripts/build-npm.sh b/packages/material/scripts/build-npm.sh index e2fbcd373..5137aee5a 100755 --- a/packages/material/scripts/build-npm.sh +++ b/packages/material/scripts/build-npm.sh @@ -4,8 +4,11 @@ rm -rf ./esm babel -d ./cjs ./modules ESM=1 babel -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} rm -rf ./css sass -I node_modules -I ../../node_modules styles/:css/ --no-source-map --style=expanded diff --git a/packages/material/tsconfig.json b/packages/material/tsconfig.json index 9d6384be7..246aa4e6b 100644 --- a/packages/material/tsconfig.json +++ b/packages/material/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], diff --git a/packages/mui/scripts/build-npm.sh b/packages/mui/scripts/build-npm.sh index e2fbcd373..4a2f47dd5 100755 --- a/packages/mui/scripts/build-npm.sh +++ b/packages/mui/scripts/build-npm.sh @@ -4,9 +4,13 @@ rm -rf ./esm babel -d ./cjs ./modules ESM=1 babel -d ./esm ./modules -cp ./modules/index.d.ts ./cjs/index.d.ts -cp ./modules/index.d.ts ./esm/index.d.ts + +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./cjs/ +# rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./modules/ ./esm/ +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} cjs/{} +find modules/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp modules/{} esm/{} rm -rf ./css sass -I node_modules -I ../../node_modules styles/:css/ --no-source-map --style=expanded -cp ./styles/* ./css +cp -r ./styles/* ./css/ + diff --git a/packages/mui/tsconfig.json b/packages/mui/tsconfig.json index 9d6384be7..246aa4e6b 100644 --- a/packages/mui/tsconfig.json +++ b/packages/mui/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "preserve", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json index 0b7588891..1dfdb6258 100644 --- a/packages/sandbox/tsconfig.json +++ b/packages/sandbox/tsconfig.json @@ -4,7 +4,7 @@ "module": "esnext", "jsx": "react-jsx", "lib": [ - "es2015", + "es2016", "dom", "dom.iterable" ], @@ -27,6 +27,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@react-awesome-query-builder/core": ["../core/modules"], + "@react-awesome-query-builder/sql": ["../sql/modules"], "@react-awesome-query-builder/ui": ["../ui/modules"], "@react-awesome-query-builder/antd": ["../antd/modules"], "@react-awesome-query-builder/mui": ["../mui/modules"], diff --git a/packages/sandbox_next/README.md b/packages/sandbox_next/README.md index d64a75aa5..7340feaf5 100644 --- a/packages/sandbox_next/README.md +++ b/packages/sandbox_next/README.md @@ -56,7 +56,7 @@ Feel free to play with code in `components/demo`, `lib`, `pages`. #### Session data Session data contains: - `jsonTree` - query value in JSON format, got from [`Utils.getTree()`](/README.md#gettree-immutablevalue-light--true-children1asarray--true---object) -- `zipConfig` - compressed query config in JSON format, got from [`Utils.compressConfig()`](/README.md#compressconfigconfig-baseconfig---zipconfig) +- `zipConfig` - compressed query config in JSON format, got from [`Utils.ConfigUtils.compressConfig()`](/README.md#compressconfigconfig-baseconfig---zipconfig) Session data is saved to Redis (for deploying to Vercel with Upstash integration) or tmp json file (for local run), see [lib/withSession.ts](lib/withSession.ts) if you're interested in session implementation. @@ -71,7 +71,7 @@ Initial `zipConfig` (if missing in session data) is generated on server-side as - based on `CoreConfig` (imported from `@react-awesome-query-builder/core`) - added fields, funcs and some overrides in [`lib/config_base`](lib/config_base.ts) - added UI mixins (`asyncFetch`, custom React components, `factory` overrides) in [`lib/config`](lib/config.tsx) -- compressed with [`Utils.compressConfig()`](/README.md#compressconfigconfig-baseconfig---zipconfig) +- compressed with [`Utils.ConfigUtils.compressConfig()`](/README.md#compressconfigconfig-baseconfig---zipconfig) See [getInitialZipConfig()](pages/api/config.ts). @@ -82,7 +82,7 @@ With `POST /api/config` compressed config can be saved to session data. #### DemoQueryBuilder `DemoQueryBuilder` component can use server-side props: -- It uses [`Utils.decompressConfig(zipConfig, MuiConfig, ctx)`](/README.md#decompressconfigzipconfig-baseconfig-ctx---config) to create initial config to be passed to ``. [`ctx`](/README.md#ctx) is imported from [`config_ctx`](components/demo/config_ctx.tsx) +- It uses [`Utils.ConfigUtils.decompressConfig(zipConfig, MuiConfig, ctx)`](/README.md#decompressconfigzipconfig-baseconfig-ctx---config) to create initial config to be passed to ``. [`ctx`](/README.md#ctx) is imported from [`config_ctx`](components/demo/config_ctx.tsx) - Initial tree (to be passed as `value` prop for ``) is a result of [`Utils.loadTree(jsonTree)`](/README.md#loadtree-jsvalue---immutable) On `onChange` callback it calls `POST /api/tree` to update tree on backend and also export tree to various formats on server-side. diff --git a/packages/sandbox_next/components/demo/index.tsx b/packages/sandbox_next/components/demo/index.tsx index c3725d617..5cbd2d417 100644 --- a/packages/sandbox_next/components/demo/index.tsx +++ b/packages/sandbox_next/components/demo/index.tsx @@ -32,7 +32,7 @@ export default class DemoQueryBuilder extends Component { (async () => { const config = updateConfigWithSomeChanges(this.state.config); - const zipConfig = Utils.compressConfig(config, MuiConfig); + const zipConfig = Utils.ConfigUtils.compressConfig(config, MuiConfig); const response = await fetch("/api/config", { method: "POST", body: JSON.stringify({ diff --git a/packages/sandbox_next/lib/config.tsx b/packages/sandbox_next/lib/config.tsx index aa90a0027..0fc90def8 100644 --- a/packages/sandbox_next/lib/config.tsx +++ b/packages/sandbox_next/lib/config.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { - Config, FieldOrGroup, Operator, Settings, Widget, ConfigMixin + Config, FieldOrGroup, Operator, Settings, Widget, ConfigMixin, PartialPartial, SerializedFunction, } from "@react-awesome-query-builder/ui"; import merge from "lodash/merge"; import pureServerConfig from "./config_base"; @@ -35,12 +35,14 @@ const fieldsMixin: Record> = { autocomplete: { fieldSettings: { // Real implementation of `autocompleteFetch` should be in `ctx` - asyncFetch: "autocompleteFetch", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + asyncFetch: "autocompleteFetch" as SerializedFunction as any, }, }, autocompleteMultiple: { fieldSettings: { - asyncFetch: "autocompleteFetch", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + asyncFetch: "autocompleteFetch" as SerializedFunction as any, }, }, }; @@ -48,6 +50,7 @@ const fieldsMixin: Record> = { // (Advanced) Demostrates how you can use JsonLogic function to customize `factory` with some logic const widgetsMixin: Record> = { multiselect: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment factory: { if: [ { or: [ { var: "props.asyncFetch" }, { var: "props.showSearch" } ] }, @@ -57,16 +60,17 @@ const widgetsMixin: Record> = { ]}] }, { JSX: ["MuiMultiSelectWidget", {var: "props"}] } ] - } + } as SerializedFunction as any }, select: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment factory: { if: [ { or: [ { var: "props.asyncFetch" }, { var: "props.showSearch" } ] }, { JSX: ["MuiAutocompleteWidget", {var: "props"}] }, { JSX: ["MuiSelectWidget", {var: "props"}] } ] - } + } as SerializedFunction as any }, }; @@ -83,32 +87,33 @@ const operatorsMixin: Record> = { }, }; -const renderSettings: Partial = { - renderField: "myRenderField", - renderConfirm: "W.MuiConfirm", - useConfirm: "W.MuiUseConfirm", +const settingsMixin: PartialPartial = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + renderField: "myRenderField" as SerializedFunction as any, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + renderConfirm: "W.MuiConfirm" as SerializedFunction as any, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + useConfirm: "W.MuiUseConfirm" as SerializedFunction as any, + locale: { + mui: { var: "ctx.ukUA" }, + }, }; -const configMixin: ConfigMixin = { +const configMixin: ConfigMixin = { fields: fieldsMixin, widgets: widgetsMixin, operators: operatorsMixin, - settings: { - ...renderSettings, - locale: { - mui: { var: "ctx.ukUA" }, - }, - } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + settings: settingsMixin, }; - -const mixinConfig = (baseConfig: Config) => { +const mixinConfig = (baseConfig: Config): Config => { return merge( {}, baseConfig, configMixin, - ) as Config; + ); }; export default mixinConfig(pureServerConfig); diff --git a/packages/sandbox_next/lib/config_base.ts b/packages/sandbox_next/lib/config_base.ts index ff9dae6a0..6a7b2b428 100644 --- a/packages/sandbox_next/lib/config_base.ts +++ b/packages/sandbox_next/lib/config_base.ts @@ -3,7 +3,7 @@ import merge from "lodash/merge"; import { BasicFuncs, CoreConfig, // types: - Settings, Operators, Widgets, Fields, Config, Types, Conjunctions, LocaleSettings, Funcs, OperatorProximity, Func, + Settings, Operators, Widgets, Fields, Config, Types, Conjunctions, LocaleSettings, Funcs, OperatorProximity, Func, SerializedFunction, } from "@react-awesome-query-builder/core"; // Create a config for demo app based on CoreConfig - add fields, funcs, some overrides. @@ -31,7 +31,8 @@ function createConfig(InitialConfig: CoreConfig): Config { valuePlaceholder: "Enter name", }, fieldSettings: { - validateValue: "validateFirstName", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + validateValue: "validateFirstName" as SerializedFunction as any, // -or- // validateValue: { // "<": [ {strlen: {var: "val"}}, 10 ] @@ -46,6 +47,7 @@ function createConfig(InitialConfig: CoreConfig): Config { type: "text", excludeOperators: ["proximity"], fieldSettings: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment validateValue: { and: [ { "<": [ {strlen: {var: "val"}}, 10 ] }, @@ -54,7 +56,7 @@ function createConfig(InitialConfig: CoreConfig): Config { { regexTest: [ {var: "val"}, "^[A-Za-z0-9_-]+$" ] } ]} ] - } + } as SerializedFunction as any // -incorrect- // (val: string) => { // return (val.length < 10 && (val === "" || val.match(/^[A-Za-z0-9_-]+$/) !== null)); diff --git a/packages/sandbox_next/package.json b/packages/sandbox_next/package.json index 077291996..3d319cde4 100644 --- a/packages/sandbox_next/package.json +++ b/packages/sandbox_next/package.json @@ -42,6 +42,7 @@ "@mui/system": "^5.15.15", "@mui/x-date-pickers": "^7.9.0", "@react-awesome-query-builder/core": "workspace:^", + "@react-awesome-query-builder/sql": "workspace:^", "@react-awesome-query-builder/mui": "workspace:^", "@upstash/redis": "^1.31.1", "iron-session": "^6.3.1", diff --git a/packages/sandbox_next/pages/api/config.ts b/packages/sandbox_next/pages/api/config.ts index e520f0ce5..db3905206 100644 --- a/packages/sandbox_next/pages/api/config.ts +++ b/packages/sandbox_next/pages/api/config.ts @@ -9,7 +9,7 @@ import { withSessionRoute, getSessionData, saveSessionData } from "../../lib/wit import serverConfig from "../../lib/config"; // API to get/save `zipConfig` to session -// Initial config is created in `lib/config` and compressed with `Utils.compressConfig()` +// Initial config is created in `lib/config` and compressed with `Utils.ConfigUtils.compressConfig()` export type GetConfigQuery = { initial?: string; @@ -25,7 +25,7 @@ export interface GetConfigResult { export async function decompressSavedConfig(req: NextApiRequest): Promise { const zipConfig = await getSavedZipConfig(req); - const config = Utils.decompressConfig(zipConfig, serverConfig); + const config = Utils.ConfigUtils.decompressConfig(zipConfig, serverConfig as Config); return config; } @@ -34,7 +34,7 @@ export async function getSavedZipConfig(req: NextApiRequest): Promise } export function getInitialZipConfig() { - return Utils.compressConfig(serverConfig, CoreConfig); + return Utils.ConfigUtils.compressConfig(serverConfig as Config, CoreConfig); } async function saveZipConfig(req: NextApiRequest, zipConfig: ZipConfig) { diff --git a/packages/sandbox_next/pages/index.tsx b/packages/sandbox_next/pages/index.tsx index ea9cb0869..25828026d 100644 --- a/packages/sandbox_next/pages/index.tsx +++ b/packages/sandbox_next/pages/index.tsx @@ -8,7 +8,7 @@ export default Demo; // Get current `jsonTree` and `zipConfig` from session // If `jsonTree` is missing, will be loaded from `data` dir -// If `zipConfig` is missing, will be created in `lib/config` and compressed with `Utils.compressConfig()` +// If `zipConfig` is missing, will be created in `lib/config` and compressed with `Utils.ConfigUtils.compressConfig()` export const getServerSideProps = withSessionSsr( async function getServerSideProps({ req }) { const sessionData = await getSessionData(req); diff --git a/packages/sandbox_next/tsconfig.json b/packages/sandbox_next/tsconfig.json index 73361e94e..1d6fdc350 100644 --- a/packages/sandbox_next/tsconfig.json +++ b/packages/sandbox_next/tsconfig.json @@ -29,6 +29,9 @@ "@react-awesome-query-builder/core": [ "../core/modules" ], + "@react-awesome-query-builder/sql": [ + "../sql/modules" + ], "@react-awesome-query-builder/ui": [ "../ui/modules" ], diff --git a/packages/sql/.babelrc.js b/packages/sql/.babelrc.js new file mode 100644 index 000000000..dd9fd6b4b --- /dev/null +++ b/packages/sql/.babelrc.js @@ -0,0 +1,25 @@ +const presetEnvOptions = {}; +if (process.env.ESM === "1") { + presetEnvOptions.modules = false; +} +const config = { + "presets": [ + ["@babel/preset-env", presetEnvOptions], + "@babel/preset-typescript" + ], + "plugins": [ + ["@babel/plugin-transform-class-properties", { "loose": true }], + ["@babel/plugin-transform-private-methods", { "loose": true }], + ["@babel/plugin-transform-runtime", { "loose": true }], + ["@babel/plugin-transform-private-property-in-object", { "loose": true }] + ], + "env": { + "production": { + "compact": true, + "comments": false, + "minified": true + } + } +}; + +module.exports = config; diff --git a/packages/sql/README.md b/packages/sql/README.md new file mode 100644 index 000000000..1b98f3aef --- /dev/null +++ b/packages/sql/README.md @@ -0,0 +1,32 @@ +# @react-awesome-query-builder/sql + +[![npm](https://img.shields.io/npm/v/@react-awesome-query-builder/sql.svg)](https://www.npmjs.com/package/@react-awesome-query-builder/sql) + +This packages provides import from SQL using [node-sql-parser](https://www.npmjs.com/package/node-sql-parser) + +## Installation + +Install: + +```sh +npm i @react-awesome-query-builder/sql --save +``` + +## Usage + +```js +import { Utils } from '@react-awesome-query-builder/core'; +import { SqlUtils } from "@react-awesome-query-builder/sql"; + +const importFromSql = (sqlStr) => { + const {tree, errors: sqlErrors, warnings: sqlWarnings} = SqlUtils.loadFromSql(sqlStr, state.config); + if (sqlErrors.length) { + console.log("Import errors: ", sqlErrors); + } + const {fixedTree} = Utils.sanitizeTree(tree, state.config); + setState({ + ...state, + tree: fixedTree, + }); +}; +``` diff --git a/packages/sql/modules/import/ast.ts b/packages/sql/modules/import/ast.ts new file mode 100644 index 000000000..e7f562634 --- /dev/null +++ b/packages/sql/modules/import/ast.ts @@ -0,0 +1,292 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + +import type { Meta, OutLogic, OutSelect } from "./types"; +import { SqlPrimitiveTypes } from "./conv"; +import { + // ext + ConjExpr, BinaryExpr, UnaryExpr, AnyExpr, Logic, BaseExpr, + // orig + AST, Select, Function as SqlFunction, ExpressionValue, ExprList, LocationRange, + ColumnRef, ValueExpr, Value, Case, Interval, Column, +} from "node-sql-parser"; + + +export const processAst = (sqlAst: AST, meta: Meta): OutSelect => { + if (sqlAst.type !== "select") { + meta.errors.push(`Expected SELECT, but got ${sqlAst.type}`); + } + const select = sqlAst as Select; + const pWhere = processLogic(select.where, meta); + const selectExpr = ((select.columns ?? []) as Column[]).find(c => c.type === "expr"); + const pSelectExpr = processLogic(selectExpr?.expr as Logic, meta); + const hasLogic = select.where || selectExpr?.expr; + if (!hasLogic) { + meta.errors.push("No logic found in WHERE/SELECT"); + } + return { + where: pWhere, + select: pSelectExpr, + }; +}; + +const processLogic = (logic: Logic | null | undefined, meta: Meta, not = false): OutLogic | undefined => { + if (!logic) { + return undefined; + } + let ret: OutLogic | undefined; + if (logic.type === "function") { + ret = processFunc(logic as SqlFunction, meta, not); + } else if (logic.type === "binary_expr") { + if (["AND", "OR"].includes((logic as BinaryExpr).operator)) { + ret = processConj(logic as ConjExpr, meta, not); + } else { + ret = processBinaryOp(logic as BinaryExpr, meta, not); + } + } else if (logic.type === "unary_expr") { + if ((logic as UnaryExpr).operator === "NOT") { + const subFilter = (logic as UnaryExpr).expr; + ret = processLogic(subFilter, meta, !not); + if (ret) { + ret.parentheses = true; + } + } else { + meta.errors.push(`Unexpected unary operator ${(logic as UnaryExpr).operator}`); + } + } else if (logic.type === "expr_list") { + ret = processExprList(logic as ExprList, meta, not); + } else if (logic.type === "case") { + ret = processCase(logic as Case, meta, not); + } else if (logic.type === "column_ref") { + ret = processField(logic as ColumnRef, meta, not); + } else if (logic.type === "interval") { + ret = processInterval(logic as Interval, meta, not); + } else if ((logic as Value).value !== undefined) { + ret = processValue(logic as Value, meta, not); + } else { + // todo: aggr_func (like COUNT) + meta.errors.push(`Unexpected logic type ${logic.type}`); + } + return ret; +}; + +const processConj = (expr: ConjExpr, meta: Meta, not = false): OutLogic | undefined => { + const parentheses = expr.parentheses; + const conj = expr.operator; + // flatize OR/AND + const childrenChain = [ + processLogic(expr.left, meta), + processLogic(expr.right, meta), + ].filter(c => !!c) as OutLogic[]; + const children = childrenChain.reduce((acc, child) => { + const canFlatize = child?.conj === conj && !child.parentheses && !child.not; + const flat = canFlatize ? (child.children ?? []) : [child]; + return [...acc, ...flat]; + }, [] as OutLogic[]); + return { + parentheses, + not, + conj, + children, + }; +}; + +const processCase = (expr: Case, meta: Meta, not = false): OutLogic | undefined => { + const parentheses = (expr as BaseExpr).parentheses; + const children = expr.args + .map(arg => { + if (!["when", "else"].includes(arg.type)) { + meta.errors.push(`Unexpected type ${arg.type} inside CASE`); + } + return { + cond: arg.type === "when" ? processLogic(arg.cond, meta) : undefined, + result: processLogic(arg.result, meta), + }; + }) + .filter(a => !!a) as OutLogic[]; + return { + parentheses, + children, + _type: "case", + not, + }; +}; + +const processBinaryOp = (expr: BinaryExpr, meta: Meta, not = false): OutLogic | undefined => { + const parentheses = expr.parentheses; + const operator = expr.operator; + let children = [ + processLogic(expr.left, meta), + processLogic(expr.right, meta), + ].filter(c => !!c) as OutLogic[]; + if (operator === "BETWEEN" || operator === "NOT BETWEEN") { + // flatize BETWEEN + children = [ + children[0], + ...(children[1].children ?? []), + ].filter(c => !!c); + } + return { + parentheses, + not, + children, + operator, + }; +}; + +const getExprValue = (expr: ValueExpr, meta: Meta, not = false): string | number | boolean => { + let value = expr.value; + if (expr.type === "boolean" && not) { + value = !value; + } + // todo: date literals? + return value; +}; + +const getExprStringValue = (expr: ValueExpr, meta: Meta, not = false): string => { + const v = getExprValue(expr, meta, not); + return String(v); +}; + +const processExprList = (expr: ExprList, meta: Meta, not = false): OutLogic | undefined => { + const children = expr.value + .map(ev => processLogic(ev, meta, false)) + .filter(ev => !!ev) as OutLogic[]; + const valueTypes = children.map(ch => ch.valueType).filter(ev => !!ev); + const uniqValueTypes = Array.from(new Set(valueTypes)); + const oneValueType = valueTypes.length === children.length && uniqValueTypes.length === 1 ? uniqValueTypes[0] : undefined; + let values; + if (oneValueType && SqlPrimitiveTypes[oneValueType]) { + // it's list of primitive values + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + values = children.map(ch => ch.value); + } + return { + children, + not, + _type: "expr_list", + oneValueType, + values, + }; +}; + +const processInterval = (expr: Interval, meta: Meta, not = false): OutLogic | undefined => { + return { + _type: "interval", + ...(processValue(expr.expr, meta) ?? {}), + unit: expr.unit, + }; +}; + +const processValue = (expr: Value, meta: Meta, not = false): OutLogic | undefined => { + const {type: valueType} = expr; + const value = getExprValue(expr as ValueExpr, meta, not); + return { + valueType, + value, + }; +}; + +const processField = (expr: ColumnRef, meta: Meta, not = false): OutLogic | undefined => { + const parentheses = (expr as BaseExpr).parentheses; + const field = typeof expr.column === "string" ? expr.column : getExprStringValue(expr.column.expr, meta, not); + const table = expr.table ?? undefined; + if (field === "") { + // fix for empty string + return { + valueType: "single_quote_string", + value: "", + }; + } else { + return { + parentheses, + field, + table, + }; + } +}; + +const flatizeTernary = (children: OutLogic[], meta: Meta) => { + const flat: [OutLogic | undefined, OutLogic][] = []; + function _processTernaryChildren(tern: OutLogic[]) { + const [cond, if_val, else_val] = tern; + if (if_val.func === "IF") { + meta.errors.push("Unexpected IF inside IF"); + } + flat.push([cond, if_val]); + if (else_val?.func === "IF") { + _processTernaryChildren(else_val?.children!); + } else { + flat.push([undefined, else_val]); + } + } + _processTernaryChildren(children); + return flat; +}; + +const processFunc = (expr: SqlFunction, meta: Meta, not = false): OutLogic | undefined => { + const nameType = expr.name.name[0].type as string; + const firstName = expr.name.name[0].value; + const getArgs = (useNot: boolean): OutLogic[] => { + if (expr.args) { + if (expr.args.type === "expr_list") { + return expr.args.value + .map(arg => processLogic(arg, meta, useNot ? not : undefined)) + .filter(a => !!a) as OutLogic[]; + } else { + meta.errors.push(`Unexpected args type for func (${JSON.stringify(expr.name.name)}): ${JSON.stringify(expr.args.type)}`); + } + } + return []; + }; + let ret: OutLogic | undefined; + + if (nameType === "default" && firstName === "NOT") { + if (expr.args?.value.length === 1) { + const args = getArgs(false); + ret = args[0]; + // ret.parentheses = true; + ret.not = !ret.not; + if (not) { + ret.not = !ret.not; + } + } else { + meta.errors.push(`Unexpected args for func NOT: ${JSON.stringify(expr.args?.value)}`); + } + } else if (nameType === "default" && firstName === "IFNULL") { + const args = getArgs(true); + // const defaultValue = args[1].value; + ret = args[0]; + } else if (nameType === "default") { + const flatizeArgs = firstName === "IF"; + const args = getArgs(false); + ret = { + func: firstName, + children: args, + not: not, + }; + if (flatizeArgs) { + ret.ternaryChildren = flatizeTernary(args, meta); + } + return ret; + } else { + meta.errors.push(`Unexpected function name ${JSON.stringify(expr.name.name)}`); + } + return ret; +}; + + +export const getLogicDescr = (logic?: OutLogic) => { + if (logic?._type === "case") { + // const cases = logic.children as {cond: OutLogic, result: OutLogic}[]; + return "CASE"; + } else if (logic?._type === "interval") { + return `INTERVAL ${JSON.stringify(logic.value)} ${logic.unit}`; + } else if (logic?.func) { + return `${logic.func}()`; + } else if (logic?.operator) { + return `operator ${logic.operator}`; + } else if (logic?._type === "expr_list") { + return JSON.stringify(logic.values); + } + return JSON.stringify(logic); +}; diff --git a/packages/sql/modules/import/conv.ts b/packages/sql/modules/import/conv.ts new file mode 100644 index 000000000..1cc92f03c --- /dev/null +++ b/packages/sql/modules/import/conv.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + +import type { Conv, Meta, OutLogic } from "./types"; +import { + Config, SqlImportFunc, Utils, ConfigContext, DateTimeWidget, + BaseWidget, MomentInput +} from "@react-awesome-query-builder/core"; +import { ValueExpr } from "node-sql-parser"; + +const logger = Utils.OtherUtils.logger; + +export const manuallyImportedOps: string[] = [ +]; +export const unsupportedOps: string[] = [ + "some", "all", "none", +]; +// sql type => raqb type +export const SqlPrimitiveTypes: Record = { + single_quote_string: "text", + double_quote_string: "text", + backticks_quote_string: "text", + var_string: "text", + natural_string: "text", + hex_string: "text", + full_hex_string: "text", + bit_string: "text", + string: "text", + regex_string: "text", // ? + number: "number", + null: "null", + bool: "boolean", + boolean: "boolean", +}; + + +export const buildConv = (config: Config, meta: Meta): Conv => { + const operators: Record = {}; + const opFuncs: Record = {}; + const valueFuncs: Record = { + datetime: [sqlImportDate], + date: [sqlImportDate], + time: [sqlImportDate], + }; + + for (const opKey in config.operators) { + const opConfig = config.operators[opKey]; + // const isGroupOp = config.settings.groupOperators?.includes(opKey); + const sqlOps = opConfig.sqlOps ? opConfig.sqlOps : opConfig.sqlOp ? [opConfig.sqlOp] : undefined; + if (opConfig.sqlImport) { + if (!opFuncs[opKey]) + opFuncs[opKey] = [] as SqlImportFunc[]; + opFuncs[opKey].push(opConfig.sqlImport as SqlImportFunc); + } + if (sqlOps) { + // examples of 2+: "==", "eq", ".contains", "matches" (can be used for starts_with, ends_with) + sqlOps?.forEach(sqlOp => { + if (!operators[sqlOp]) + operators[sqlOp] = []; + operators[sqlOp].push(opKey); + }); + } else { + const revOpConfig = config.operators?.[opConfig.reversedOp!]; + const canUseRev = revOpConfig?.sqlOp || revOpConfig?.sqlOps || revOpConfig?.sqlImport; + const canIgnore = canUseRev || opConfig.sqlImport + || manuallyImportedOps.includes(opKey) || manuallyImportedOps.includes(opConfig.reversedOp!) + || unsupportedOps.includes(opKey); + if (!canIgnore) { + logger.warn(`[sql] No sqlOp/sqlImport for operator ${opKey}`); + } + } + } + + const conjunctions: Record = {}; + for (const conjKey in config.conjunctions) { + const conjunctionDefinition = config.conjunctions[conjKey]; + const ck = conjunctionDefinition.sqlConj || conjKey.toLowerCase(); + conjunctions[ck] = conjKey; + } + + for (const w in config.widgets) { + const widgetDef = config.widgets[w] as BaseWidget; + const {sqlImport} = widgetDef; + if (sqlImport) { + if (!valueFuncs[w]) + valueFuncs[w] = [] as SqlImportFunc[]; + valueFuncs[w].push(sqlImport); + } + } + + return { + operators, + conjunctions, + opFuncs, + valueFuncs, + }; +}; + + +const sqlImportDate: SqlImportFunc = function (this: ConfigContext, sqlObj: OutLogic, wgtDef?: DateTimeWidget) { + if (sqlObj?.children && [ + "TO_DATE", "TO_TIMESTAMP", "TO_TIMESTAMP_TZ", "TO_UTC_TIMESTAMP_TZ" + ].includes(sqlObj.func!) && sqlObj.children.length >= 1) { + const [valArg, _patternArg] = sqlObj!.children!; + if (valArg?.valueType?.endsWith("_quote_string")) { + // tip: moment doesn't support SQL date format, so ignore patternArg + const dateVal = this.utils.moment(valArg.value as MomentInput); + if (dateVal.isValid()) { + return { + value: dateVal.format(wgtDef?.valueFormat), + }; + } else { + return { + value: null, + error: "Invalid date", + }; + } + } + } + return undefined; +}; diff --git a/packages/sql/modules/import/convert.ts b/packages/sql/modules/import/convert.ts new file mode 100644 index 000000000..732135e63 --- /dev/null +++ b/packages/sql/modules/import/convert.ts @@ -0,0 +1,489 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + +import type { Conv, FuncArgsObj, Meta, OperatorObj, FuncWithArgsObj, OutLogic, ValueObj } from "./types"; +import { + Config, JsonRule, JsonGroup, JsonSwitchGroup, JsonCaseGroup, CaseGroupProperties, FieldConfigExt, + BaseWidget, Utils, ValueSource, RuleProperties, SqlImportFunc, JsonAnyRule, + FuncArg, + Func, + FuncValue, + Field, + JsonRuleGroup, +} from "@react-awesome-query-builder/core"; +import { getLogicDescr } from "./ast"; +import { SqlPrimitiveTypes } from "./conv"; +import { ValueExpr } from "node-sql-parser"; + + +export const convertToTree = ( + logic: OutLogic | undefined, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic, returnGroup = false +): JsonRule | JsonGroup | JsonRuleGroup | JsonSwitchGroup | JsonCaseGroup | undefined => { + if (!logic) return undefined; + + let res; + if (logic.operator) { + res = convertOp(logic, conv, config, meta, parentLogic); + } else if (logic.conj) { + res = convertConj(logic, conv, config, meta, parentLogic); + } else if (logic.ternaryChildren) { + res = convertTernary(logic, conv, config, meta, parentLogic); + } else if (logic.func) { + // try to use `sqlImport` in operator definitions + res = convertOp(logic, conv, config, meta, parentLogic); + } else { + meta.errors.push(`Unexpected logic: ${getLogicDescr(logic)}`); + } + + if (res?.type === "group") { + res = groupToMaybeRuleGroup(res, config); + } + + if (returnGroup && res && res.type != "group" && res.type != "switch_group") { + res = wrapInDefaultConj(res, config, logic.not); + } + + return res; +}; + +const convertTernary = (logic: OutLogic, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): JsonSwitchGroup => { + const { ternaryChildren } = logic; + const cases = (ternaryChildren ?? []).map(([cond, val]) => { + return buildCase(cond, val, conv, config, meta, logic); + }) as JsonCaseGroup[]; + return { + type: "switch_group", + children1: cases, + }; +}; + +const buildCase = (cond: OutLogic | undefined, val: OutLogic, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): JsonCaseGroup | undefined => { + const valProperties = buildCaseValProperties(config, meta, conv, val, parentLogic); + + let caseI: JsonCaseGroup | undefined; + if (cond) { + caseI = convertToTree(cond, conv, config, meta, parentLogic, true) as JsonCaseGroup; + if (caseI && caseI.type) { + caseI.type = "case_group"; + } else { + meta.errors.push(`Unexpected case: ${JSON.stringify(caseI)}`); + caseI = undefined; + } + } else { + caseI = { + type: "case_group", + properties: {} + }; + } + + if (caseI) { + caseI.properties = { + ...caseI.properties, + ...valProperties, + }; + } + + return caseI; +}; + +const buildCaseValProperties = (config: Config, meta: Meta, conv: Conv, val: OutLogic, parentLogic?: OutLogic): CaseGroupProperties => { + const caseValueFieldConfig = Utils.ConfigUtils.getFieldConfig(config, "!case_value") as FieldConfigExt; + const widget = caseValueFieldConfig?.mainWidget!; + const widgetConfig = config.widgets[widget] as BaseWidget; + const convVal = convertArg(val, conv, config, meta, parentLogic)!; + if (convVal && convVal.valueSrc === "value") { + // @ts-ignore + convVal.valueType = (widgetConfig.type || caseValueFieldConfig.type || convVal.valueType); + } + const valProperties = { + value: [convVal.value], + valueSrc: [convVal.valueSrc as ValueSource], + valueType: [convVal.valueType! as string], + field: "!case_value", + }; + return valProperties; +}; + +const convertConj = (logic: OutLogic, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): JsonGroup => { + const { conj, children } = logic; + const conjunction = conv.conjunctions[conj!]; + const convChildren = (children || []).map(a => convertToTree(a, conv, config, meta, logic)).filter(c => !!c) as JsonGroup[]; + const res: JsonGroup = { + type: "group", + properties: { + conjunction, + not: logic.not, + }, + children1: convChildren, + }; + return res; +}; + +const getCommonGroupField = (fields: string[], config: Config): string | undefined => { + if (fields.length > 0) { + const closestGroupFields = fields.map(f => { + const paths = Utils.ConfigUtils.getFieldParts(f, config); + const groupFields = paths.filter(path => (Utils.ConfigUtils.getFieldConfig(config, path) as Field)?.type === "!group"); + const closestGroupField = groupFields.reverse()?.[0]; + return closestGroupField; + }).filter(gf => !!gf); + const isSameGroupField = Array.from(new Set(closestGroupFields)).length === 1; + const allFieldsAreInsideGroup = closestGroupFields.length === fields.length; + if (allFieldsAreInsideGroup && isSameGroupField) { + return closestGroupFields[0]; + } + } + return undefined; +}; + +const groupToMaybeRuleGroup = (grp: JsonGroup, config: Config): JsonRuleGroup | JsonGroup => { + const fields = (grp.children1 ?? []) + .filter(ch => ch.type === "rule" || ch.type === "rule_group") + .map(rule => (rule as JsonRule | JsonRuleGroup).properties?.field) + .filter(f => !!f) as string[]; + if (fields?.length === grp.children1?.length) { + const commonGroupField = getCommonGroupField(fields, config); + if (commonGroupField) { + const rgr = grp as any as JsonRuleGroup; + rgr.type = "rule_group"; + rgr.properties!.field = commonGroupField; + (rgr.properties as any).fieldSrc = "field"; + return rgr; + } + } + return grp; +}; + +const convertOp = (logic: OutLogic, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): JsonRule | undefined => { + let opKeys = conv.operators[logic.operator!]; + let opKey = opKeys?.[0]; + let convChildren; + let operatorOptions; + + // tip: Even if opKeys.length === 1, still try to use `convertOpFunc` to let `sqlImport` be applied first (eg. `not_like` op) + const convFuncOp = convertOpFunc(logic, conv, config, meta, parentLogic)!; + if (convFuncOp) { + opKey = convFuncOp.operator!; + convChildren = convFuncOp.children; + operatorOptions = convFuncOp.operatorOptions; + } else if (logic.operator) { + convChildren = (logic.children || []).map(a => convertArg(a, conv, config, meta, logic)); + const isMultiselect = convChildren.filter(ch => ch?.valueType === "multiselect").length > 0; + const isSelect = convChildren.filter(ch => ch?.valueType === "select").length > 0; + if (opKeys?.length > 1) { + if (isMultiselect) { + opKeys = opKeys.filter(op => !["equal", "not_equal", "select_equals", "select_not_equals"].includes(op)); + } else if (isSelect) { + opKeys = opKeys.filter(op => !["equal", "not_equal"].includes(op)); + } + opKey = opKeys?.[0]; + } + + if (!opKeys?.length) { + meta.errors.push(`Can't convert ${getLogicDescr(logic)}`); + return undefined; + } else if (opKeys.length > 1 && !["=", "!=", "<>"].includes(logic.operator!)) { + meta.warnings.push(`SQL operator "${logic.operator}" can be converted to several operators: ${opKeys.join(", ")}`); + } + } else { + meta.errors.push(`Can't convert ${getLogicDescr(logic)}`); + return undefined; + } + + const [left, ...right] = (convChildren || []).filter(c => !!c); + const properties: RuleProperties = { + operator: opKey, + value: [], + valueSrc: [], + valueType: [], + valueError: [], + field: undefined, + }; + if (operatorOptions) { + properties.operatorOptions = operatorOptions; + } + if (left?.valueSrc === "field") { + properties.field = left.value; + properties.fieldSrc = "field"; + } else if (left?.valueSrc === "func") { + properties.field = left.value; + properties.fieldSrc = left.valueSrc; + } + + const opDef = Utils.ConfigUtils.getOperatorConfig(config, opKey, properties.field ?? undefined); + // const opCardinality = opDef?.cardinality; + const opValueTypes = opDef?.valueTypes; + + right.forEach((v, i) => { + if (v) { + properties.valueSrc![i] = v?.valueSrc as ValueSource; + let valueType: string = v?.valueType; + let finalVal = v?.value; + if (valueType && opValueTypes && !opValueTypes.includes(valueType)) { + meta.warnings.push(`Operator "${opKey}" supports value types [${opValueTypes.join(", ")}] but got ${valueType}`); + } + if (opValueTypes?.length === 1) { + valueType = opValueTypes[0]; + } + if (valueType && v?.valueSrc === "value") { + finalVal = checkSimpleValueType(finalVal, valueType); + } + properties.valueType![i] = valueType; + properties.value[i] = finalVal; + if (v?.valueError) { + properties.valueError![i] = v.valueError; + } + } + }); + return { + type: "rule", + properties, + }; +}; + +const convertArg = (logic: OutLogic | undefined, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): ValueObj | undefined => { + const { fieldSeparator } = config.settings; + if (logic?.valueType) { + const sqlType = logic?.valueType; + let valueType: string | undefined = SqlPrimitiveTypes[sqlType]; + if (!valueType) { + meta.warnings.push(`Unexpected value type ${sqlType}`); + } + const value = logic.value; // todo: convert ? + if (valueType === "text") { + // fix issues with date/time values + valueType = undefined; + } + return { + valueSrc: "value", + valueType, + value, + }; + } else if (logic?.field) { + let field = [logic.table, logic.field].filter(v => !!v).join(fieldSeparator); + for (const [fieldPath, fieldConfig, fieldKey] of Utils.ConfigUtils.iterateFields(config)) { + if (fieldConfig.tableName === logic.table && fieldKey === logic.field) { + field = fieldPath; + break; + } + } + const fieldConfig = Utils.ConfigUtils.getFieldConfig(config, field) as Field | undefined; + const valueType = fieldConfig?.type; + return { + valueSrc: "field", + valueType, + value: field, + }; + } else if (logic?.children && logic._type === "expr_list") { + return { + valueSrc: "value", + valueType: "multiselect", + value: logic.values, + }; + } else if (logic?.value) { + const value = logic.value; // todo: convert ? + return { + valueSrc: "value", + value, + }; + } else { + const maybeFunc = convertValueFunc(logic, conv, config, meta, parentLogic) || convertFunc(logic, conv, config, meta, parentLogic); + if (maybeFunc) { + return maybeFunc; + } + } + + meta.errors.push(`Unexpected arg: ${getLogicDescr(logic)}`); + return undefined; +}; + +const convertFuncArg = (logic: any, argConfig: FuncArg | undefined, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): ValueObj => { + if (typeof logic === "object" && logic !== null && !Array.isArray(logic)) { + return convertArg(logic as OutLogic, conv, config, meta, parentLogic)!; + } + return { + valueSrc: "value", + value: logic, + //valueType: // todo: see SpelPrimitiveTypes[spel.type] or argConfig + }; +}; + +// `meta` should contain either `funcKey` or `opKey` +const useImportFunc = ( + sqlImport: SqlImportFunc, logic: OutLogic | undefined, conv: Conv, config: Config, meta: Meta +): FuncWithArgsObj | OperatorObj | ValueObj | undefined => { + let parsed: Record | undefined; + const args: any[] = [config.ctx, logic!]; + let widgetConfig: BaseWidget | undefined; + if (meta.widgetKey) { + widgetConfig = config.widgets[meta.widgetKey] as BaseWidget; + args.push(widgetConfig); + } + try { + parsed = (sqlImport as any).call(...args); + } catch(_e) { + // can't be parsed + } + + if (parsed) { + const funcKey = parsed?.func as string ?? meta.funcKey; + const sqlFunc = logic?.func; + const parseKey = meta.opKey ?? meta.funcKey ?? meta.widgetKey ?? meta.outType ?? "?"; + if (parsed?.children) { + return { + operator: parsed.operator ?? meta.opKey, + children: (parsed as OutLogic).children?.map(ch => convertArg(ch, conv, config, meta, logic)), + operatorOptions: parsed.operatorOptions, + } as OperatorObj; + } else if (parsed?.args) { + const funcConfig = Utils.ConfigUtils.getFuncConfig(config, funcKey); + const args: FuncArgsObj = {}; + for (const argKey in parsed.args) { + const argLogic = parsed.args[argKey] as OutLogic; + const argConfig = funcConfig?.args[argKey]; + args[argKey] = convertFuncArg(argLogic, argConfig, conv, config, meta, logic); + } + return { + func: funcKey, + funcConfig, + args, + }; + } else if (Object.keys(parsed).includes("value")) { + const { value, error } = parsed; + if (error) { + meta.errors.push(`Error while parsing ${parseKey} with func ${sqlFunc ?? "?"}: ${error}`); + } + return { + value, + valueType: widgetConfig?.type, + valueSrc: "value", + valueError: error, + }; + } else { + meta.errors.push(`Result of parsing as ${parseKey} should contain either 'children' or 'args' or 'value'`); + } + } + + return undefined; +}; + + +const convertOpFunc = (logic: OutLogic, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): OperatorObj | undefined => { + for (const opKey in conv.opFuncs) { + for (const f of conv.opFuncs[opKey]) { + const parsed = useImportFunc(f, logic, conv, config, {...meta, opKey: opKey, outType: "op"}); + if (parsed) { + return parsed as OperatorObj; + } + } + } + return undefined; +}; + +const convertValueFunc = (logic: OutLogic | undefined, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic): ValueObj | undefined => { + for (const widgetKey in conv.valueFuncs) { + for (const f of conv.valueFuncs[widgetKey]) { + const parsed = useImportFunc(f, logic, conv, config, {...meta, outType: "value", widgetKey}); + if (parsed) { + return parsed as ValueObj; + } + } + } + return undefined; +}; + +const convertFunc = ( + logic: OutLogic | undefined, conv: Conv, config: Config, meta: Meta, parentLogic?: OutLogic +): ValueObj | undefined => { + let funcKey: string | undefined, argsObj: FuncArgsObj | undefined, funcConfig: Func | undefined | null; + + for (const [f, fc] of Utils.ConfigUtils.iterateFuncs(config)) { + const { sqlFunc, sqlImport } = fc; + if (sqlImport) { + const parsed = useImportFunc(sqlImport, logic, conv, config, {...meta, funcKey: f, outType: "func"}) as FuncWithArgsObj; + if (parsed) { + funcKey = parsed.func; + funcConfig = parsed.funcConfig; + argsObj = parsed.args; + break; + } + } + if (sqlFunc && sqlFunc === logic?.func) { + funcKey = f; + funcConfig = Utils.ConfigUtils.getFuncConfig(config, funcKey); + argsObj = {}; + let argIndex = 0; + for (const argKey in funcConfig!.args) { + const argLogic = logic.children?.[argIndex]; + const argConfig = funcConfig?.args[argKey]; + argsObj[argKey] = convertFuncArg(argLogic, argConfig, conv, config, meta, logic); + argIndex++; + } + break; + } + } + + if (funcKey) { + const funcArgs: FuncArgsObj = {}; + for (const argKey in funcConfig?.args) { + const argConfig = funcConfig.args[argKey]; + let argVal = argsObj?.[argKey]; + if (argVal === undefined) { + argVal = argConfig?.defaultValue; + if (argVal === undefined) { + if (argConfig?.isOptional) { + //ignore + } else { + meta.errors.push(`No value for arg ${argKey} of func ${funcKey}`); + return undefined; + } + } else { + argVal = { + value: argVal, + valueSrc: (argVal as any)["func"] ? "func" : "value", + valueType: argConfig.type, + }; + } + } + if (argVal) + funcArgs[argKey] = argVal; + } + + return { + valueSrc: "func", + value: { + func: funcKey, + args: funcArgs + } as FuncValue, + valueType: funcConfig!.returnType, + }; + } else { + meta.errors.push(`Unexpected func: ${getLogicDescr(logic)}`); + return undefined; + } +}; + +const wrapInDefaultConj = (rule: JsonAnyRule, config: Config, not = false): JsonGroup => { + return { + type: "group", + id: Utils.uuid(), + children1: [ + rule, + ], + properties: { + conjunction: Utils.DefaultUtils.defaultConjunction(config), + not: not || false + } + }; +}; + +const checkSimpleValueType = (val: any, valueType: string) => { + if (val != null && val?.func === undefined) { + if (valueType === "text") { + val = "" + val; + } else if (valueType === "multiselect" && val?.map === undefined) { + val = [val]; + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return val; +}; diff --git a/packages/sql/modules/import/index.ts b/packages/sql/modules/import/index.ts new file mode 100644 index 000000000..6fdf2941e --- /dev/null +++ b/packages/sql/modules/import/index.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + +import { + Utils, Config, JsonTree, ImmutableTree, +} from "@react-awesome-query-builder/core"; +import { + Parser as NodeSqlParser, Option as SqlParseOption, AST, +} from "node-sql-parser"; +import type { Meta, OutSelect } from "./types"; +import { processAst } from "./ast"; +import { buildConv } from "./conv"; +import { convertToTree } from "./convert"; + +const logger = Utils.OtherUtils.logger; + + +export const loadFromSql = ( + sqlStr: string, config: Config, options?: SqlParseOption +): {tree: ImmutableTree | undefined, errors: string[], warnings: string[]} => { + const meta: Meta = { + errors: [], // mutable + warnings: [], // mutable + }; + const extendedConfig = Utils.ConfigUtils.extendConfig(config, undefined, false); + const conv = buildConv(extendedConfig, meta); + let jsTree: JsonTree | undefined; + let sqlAst: AST | undefined; + let convertedObj: OutSelect | undefined; + + // Normalize + if (!options) { + options = {}; + } + if (!options?.database) { + // todo + options.database = "Postgresql"; + } + if (!sqlStr.startsWith("SELECT ")) { + sqlStr = "SELECT * FROM t WHERE " + sqlStr; + } + + const sqlParser = new NodeSqlParser(); + try { + sqlAst = sqlParser.astify(sqlStr, options) as AST; + logger.debug("sqlAst:", sqlAst); + } catch (exc) { + const e = exc as Error; + meta.errors.push(e.message); + } + + if (sqlAst) { + convertedObj = processAst(sqlAst, meta); + logger.debug("convertedObj:", convertedObj, meta); + meta.convertedObj = convertedObj; + + jsTree = convertToTree(convertedObj?.where ?? convertedObj?.select, conv, extendedConfig, meta, undefined, true) as JsonTree; + logger.debug("jsTree:", jsTree); + } + + const immTree = jsTree ? Utils.Import.loadTree(jsTree) : undefined; + + return { + tree: immTree, + errors: meta.errors, + warnings: meta.warnings, + }; +}; + +// export const _loadFromSqlAndPrintErrors = (sqlStr: string, config: Config): ImmutableTree => { +// const {tree, errors} = loadFromSql(sqlStr, config); +// if (errors.length) +// console.warn("Errors while importing from SQL:", errors); +// return tree; +// }; + +// todo: +// funcs: LENGTH("sql"), LCASE(""), CONCAT("1", "2"), CONCAT_WS(",", "1", "2"), SUBSTRING("", 1, 1), SUBSTR(), ADDDATE/DATEADD +// json funcs: JSON_VALUE(a, "$.info.address.town") +// CASE mode: https://www.w3schools.com/sql/sql_case.asp +// diff --git a/packages/sql/modules/import/types.ts b/packages/sql/modules/import/types.ts new file mode 100644 index 000000000..96864dc24 --- /dev/null +++ b/packages/sql/modules/import/types.ts @@ -0,0 +1,95 @@ +import { Func, FuncValue, RuleValue, SimpleValue, SqlImportFunc, ValueSource } from "@react-awesome-query-builder/core"; +import type { + ExpressionValue, ExprList, LocationRange, ValueExpr, +} from "node-sql-parser"; + +declare module "node-sql-parser" { + export interface BaseExpr { + parentheses?: boolean; + } + export interface BinaryExpr extends BaseExpr { + type: "binary_expr"; + operator: string; + left: Logic; + right: Logic; + loc?: LocationRange; + } + export interface ConjExpr extends BaseExpr { + type: "binary_expr"; + operator: "AND" | "OR"; + left: Logic; + right: Logic; + loc?: LocationRange; + } + export interface UnaryExpr extends BaseExpr { + type: "unary_expr"; + operator: string; + expr: Logic; + } + export type AnyExpr = BinaryExpr | ConjExpr | UnaryExpr | ExprList; + export type Logic = AnyExpr | ExpressionValue; +} + +export interface Conv { + conjunctions: Record; + operators: Record; + opFuncs: Record; + valueFuncs: Record; +} + +export interface Meta { + // out meta + errors: string[]; + warnings: string[]; + convertedObj?: OutSelect; + + // call meta + opKey?: string; + funcKey?: string; + widgetKey?: string; + outType?: "op" | "func" | "value"; +} + +export interface OutLogic { + parentheses?: boolean; + not?: boolean; + conj?: string; + children?: OutLogic[]; + ternaryChildren?: [OutLogic | undefined, OutLogic][]; + field?: string; + table?: string; + value?: any; + values?: any[]; // for expr_list + valueType?: /* ValueExpr["type"] */ string; + oneValueType?: string; + operator?: string; + func?: string; + _type?: string; + unit?: string; +} + +export interface OutSelect { + where?: OutLogic; + select?: OutLogic; +} + +/////// + +export interface ValueObj { + valueType?: string; + value: RuleValue; + valueSrc: ValueSource; + valueError?: string; +} + +export type FuncArgsObj = Record; +export interface FuncWithArgsObj { + func: string; + funcConfig?: Func | null; + args: FuncArgsObj; +} +export interface OperatorObj { + operator: string; + children: Array; + operatorOptions?: Record; +} diff --git a/packages/sql/modules/index.ts b/packages/sql/modules/index.ts new file mode 100644 index 000000000..539b9a1ce --- /dev/null +++ b/packages/sql/modules/index.ts @@ -0,0 +1,5 @@ +import * as Import from "./import"; +const SqlUtils = { + ...Import, +}; +export { SqlUtils }; diff --git a/packages/sql/package.json b/packages/sql/package.json new file mode 100644 index 000000000..a29056efd --- /dev/null +++ b/packages/sql/package.json @@ -0,0 +1,68 @@ +{ + "name": "@react-awesome-query-builder/sql", + "version": "6.6.2", + "description": "User-friendly query builder for React. SQL support", + "keywords": [ + "query-builder", + "query", + "builder", + "query builder", + "sql" + ], + "homepage": "https://github.com/ukrbublik/react-awesome-query-builder", + "bugs": "https://github.com/ukrbublik/react-awesome-query-builder/issues", + "repository": { + "type": "git", + "url": "https://github.com/ukrbublik/react-awesome-query-builder.git", + "directory": "packages/sql" + }, + "license": "MIT", + "author": "Denis Oblogin (https://github.com/ukrbublik)", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./esm/index.js", + "require": "./cjs/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./cjs/index.js", + "module": "./esm/index.js", + "types": "./types/index.d.ts", + "scripts": { + "build": "sh ./scripts/build-npm.sh", + "eslint": "eslint --ext .js --ext .ts ./modules/", + "lint": "npm run eslint && npm run tsc-lint", + "lint-fix": "eslint --ext .js --ext .ts --fix ./modules/", + "prepare": "npm run build", + "tsc": "npm run tsc-lint", + "tsc-lint": "tsc -p . --noEmit", + "tsc-emit-types": "tsc -p . --declaration --emitDeclarationOnly --declarationDir types", + "tsc-emit-esm": "tsc -p . --outDir esm", + "tsc-emit-esm-with-dts": "tsc -p . --outDir esm --declaration" + }, + "dependencies": { + "clone": "^2.1.2", + "sqlstring": "^2.3.3", + "node-sql-parser": "^5.2.0" + }, + "devDependencies": { + "@react-awesome-query-builder/core": "workspace:^", + "@babel/cli": "^7.24.7", + "@babel/core": "^7.24.5", + "@babel/plugin-transform-class-properties": "^7.24.1", + "@babel/plugin-transform-modules-commonjs": "^7.24.1", + "@babel/plugin-transform-private-methods": "^7.24.1", + "@babel/plugin-transform-private-property-in-object": "^7.24.5", + "@babel/plugin-transform-runtime": "^7.24.3", + "@babel/preset-env": "^7.24.5", + "@babel/preset-typescript": "^7.24.1", + "@babel/runtime": "^7.24.5", + "@types/clone": "^2.1.1", + "@types/lodash": "^4.14.170", + "@types/node": "^18.15.0" + }, + "peerDependencies": { + "@react-awesome-query-builder/core": "workspace:^" + } +} diff --git a/packages/sql/scripts/build-npm.sh b/packages/sql/scripts/build-npm.sh new file mode 100755 index 000000000..adc21ff92 --- /dev/null +++ b/packages/sql/scripts/build-npm.sh @@ -0,0 +1,19 @@ +rm -rf ./cjs +rm -rf ./esm +rm -rf ./types + +# types +npm run tsc-emit-types + +# cjs +babel --extensions ".ts,.js" -d ./cjs ./modules +#rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./types/ ./cjs/ +find types/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp types/{} cjs/{} + +# esm with babel +ESM=1 babel --extensions ".ts,.js" -d ./esm ./modules +#rsync -ma --include '*/' --include '*.d.ts' --exclude '*' ./types/ ./esm/ +find types/ -type f -name "*.d.ts" | cut -d'/' -f2- | xargs -I{} cp types/{} esm/{} + +# esm with tsc -- don't use, it's not es6 compatible +#npm run tsc-emit-esm-with-dts diff --git a/packages/sql/tsconfig.json b/packages/sql/tsconfig.json new file mode 100644 index 000000000..4779818e4 --- /dev/null +++ b/packages/sql/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "esnext", + "lib": [ + "es2016" + ], + "allowJs": true, + + "outDir": "ts_out", + // "noEmit": true, + + "strict": true, + "alwaysStrict": true, + "noImplicitReturns": true, + + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": false, + + "paths": { + "@react-awesome-query-builder/core": ["../core/modules"], + } + }, + "include": [ + "modules/**/*" + ] +} diff --git a/packages/tests/karma.conf.js b/packages/tests/karma.conf.js index 31a7ef411..b84acfd0c 100644 --- a/packages/tests/karma.conf.js +++ b/packages/tests/karma.conf.js @@ -88,7 +88,7 @@ module.exports = function(config) { browsers: isDebug ? ["ChromeWithDebugging"] : ["ChromeHeadlessNoSandbox"], customLaunchers: { ChromeWithDebugging: { - base: 'Chrome', + base: "Chrome", flags: [ "--no-sandbox", "--remote-debugging-port=9333", diff --git a/packages/tests/karma.tests.js b/packages/tests/karma.tests.js index fdef6fb0c..39a649292 100644 --- a/packages/tests/karma.tests.js +++ b/packages/tests/karma.tests.js @@ -6,12 +6,16 @@ Enzyme.configure({adapter: new Adapter()}); // FILTER YOUR TESTS HERE const testsFilter = [ + // "QueryWithOperators", + // "OtherUtils", ]; const specFilter = [ + // "@sql", + // "OtherUtils" ]; const origDescribe = describe; -// eslint-disable-next-line no-global-assign +// eslint-disable-next-line no-global-assign, no-import-assign describe = function (suiteName, fn) { setCurrentTestName(suiteName); return origDescribe.call(this, suiteName, fn); diff --git a/packages/tests/package.json b/packages/tests/package.json index 944d4ecfc..d69b45cb2 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -44,6 +44,7 @@ "@react-awesome-query-builder/antd": "workspace:^", "@react-awesome-query-builder/bootstrap": "workspace:^", "@react-awesome-query-builder/core": "workspace:^", + "@react-awesome-query-builder/sql": "workspace:^", "@react-awesome-query-builder/fluent": "workspace:^", "@react-awesome-query-builder/material": "workspace:^", "@react-awesome-query-builder/mui": "workspace:^", diff --git a/packages/tests/specs/Basic.test.ts b/packages/tests/specs/Basic.test.ts index 891670a78..d63822726 100644 --- a/packages/tests/specs/Basic.test.ts +++ b/packages/tests/specs/Basic.test.ts @@ -1,11 +1,12 @@ -import { Query, Builder, BasicConfig } from "@react-awesome-query-builder/ui"; +import { Query, Builder, BasicConfig, Utils } from "@react-awesome-query-builder/ui"; import { AntdConfig } from "@react-awesome-query-builder/antd"; import * as configs from "../support/configs"; import * as inits from "../support/inits"; import { with_qb, empty_value, export_checks } from "../support/utils"; import { expect } from "chai"; // warning: don't put `export_checks` inside `it` - +import deepEqualInAnyOrder from "deep-equal-in-any-order"; +chai.use(deepEqualInAnyOrder); describe("library", () => { it("should be imported correctly", () => { @@ -16,7 +17,6 @@ describe("library", () => { }); }); - describe("basic query", () => { describe("strict mode", () => { diff --git a/packages/tests/specs/FuncAtLhs.test.ts b/packages/tests/specs/FuncAtLhs.test.ts index 3b5f4bdf7..49c9e25dd 100644 --- a/packages/tests/specs/FuncAtLhs.test.ts +++ b/packages/tests/specs/FuncAtLhs.test.ts @@ -17,7 +17,16 @@ const { // warning: don't put `export_checks` inside `it` describe("LHS func", () => { - describe("load forom SpEL", () => { + describe("@sql load from SQL", () => { + describe("LOWER(..) LIKE ..", () => { + export_checks([with_fieldSources, with_all_types, with_funcs], inits.sql_with_lhs_toLowerCase, "SQL", { + "query": "LOWER(str) Starts with \"aaa\"", + "sql": "LOWER(str) LIKE 'aaa%'", + }); + }); + }); + + describe("load from SpEL", () => { describe(".toLowerCase().startsWith()", () => { export_checks([with_fieldSources, with_all_types, with_funcs], inits.spel_with_lhs_toLowerCase, "SpEL", { "query": "LOWER(str) Starts with \"aaa\"", diff --git a/packages/tests/specs/OtherUtils.test.ts b/packages/tests/specs/OtherUtils.test.ts new file mode 100644 index 000000000..249ec7dfc --- /dev/null +++ b/packages/tests/specs/OtherUtils.test.ts @@ -0,0 +1,304 @@ +import { Utils } from "@react-awesome-query-builder/ui"; +import { expect } from "chai"; +// warning: don't put `export_checks` inside `it` +import deepEqualInAnyOrder from "deep-equal-in-any-order"; +chai.use(deepEqualInAnyOrder); + + +describe("OtherUtils", () => { + describe("mergeArraysSmart()", () => { + it("works 1", () => { + expect(Utils.OtherUtils.mergeArraysSmart( + [1, 4, 9], + [3, 5, 9] + )).to.eql( + [1, 4, 3, 5, 9] + ); + }); + it("works 2", () => { + expect(Utils.OtherUtils.mergeArraysSmart( + [1, 3, 50, 60], + [2, 3, 5, 6, 60, 7, 8], + )).to.eql( + [1, 2, 3, 5, 6, 50, 60, 7, 8] + ); + }); + it("works 3", () => { + expect(Utils.OtherUtils.mergeArraysSmart( + [1, 4, 11], + [3, 3.5, 4, 5, 9, 10, 11] + )).to.eql( + [1, 3, 3.5, 4, 5, 9, 10, 11] + ); + }); + }); + + describe("setIn()", () => { + it("throws if path is incorrect", () => { + expect(() => Utils.OtherUtils.setIn({}, ["a", "b"], 1)).to.throw(); + expect(() => Utils.OtherUtils.setIn({}, ["a"], 1)).not.to.throw(); + }); + + it("can create", () => { + const bef = {}; + const aft = Utils.OtherUtils.setIn(bef, ["x", "y"], 11, {canCreate: true}); + expect(aft).to.eql({x: {y: 11}}); + }); + + it("can rewrite", () => { + const bef = {xx: {yy: 22}, x: 2}; + const aft = Utils.OtherUtils.setIn(bef, ["x", "y"], 11, {canCreate: true, canChangeType: true}); + expect(bef.xx === aft.xx).to.eq(true); + expect(aft).to.eql({x: {y: 11}, xx: {yy: 22}}); + }); + }); + + describe("mergeIn()", () => { + const $v = Symbol.for("_v"); + const $type = Symbol.for("_type"); + const $canCreate = Symbol.for("_canCreate"); + const $canChangeType = Symbol.for("_canChangeType"); + //const $arrayMergeMode = Symbol.for("_arrayMergeMode"); + + function withCanCreate(o: T, v = true): T { + Object.assign(o as Object, {[$canCreate]: v}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return o; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + function withCantCreate(o: T): T { + return withCanCreate(o, false); + } + + it("throws", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => Utils.OtherUtils.mergeIn("" as any, {})).to.throw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => Utils.OtherUtils.mergeIn(undefined as any, {})).to.throw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => Utils.OtherUtils.mergeIn({}, undefined as any)).to.throw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + // @ts-ignore + expect(() => Utils.OtherUtils.mergeIn({}, [])).to.throw(); + }); + + it("noop if mixin is empty", () => { + const bef = {a: "a", x: []}; + const aft = Utils.OtherUtils.mergeIn(bef, {}); + expect(aft).to.eql(bef); + expect(bef === aft).to.eq(true); + }); + + it("noop if mixin does nothing", () => { + const bef = {a: "a", x: []}; + const aft = Utils.OtherUtils.mergeIn(bef, {y: undefined}); + expect(aft).to.eql(bef); + expect(bef === aft).to.eq(true); + }); + + it("noop if deep mixin does nothing", () => { + const bef = {a: "a", x: {}}; + const aft = Utils.OtherUtils.mergeIn(bef, {y: undefined, x: {g: undefined}}); + expect(aft).to.eql(bef); + expect(bef === aft).to.eq(true); + }); + + it("NOT noop if mixin creates empty {}", () => { + const bef = {a: "a", x: {}}; + const aft = Utils.OtherUtils.mergeIn(bef, {y: undefined, x: {g: {}}}); + expect(aft).to.eql({a: "a", x: {g: {}}}); + expect(bef === aft).to.eq(false); + }); + + it("can overwrite [] to {}", () => { + const bef = {a: "a", x: []}; + const aft = Utils.OtherUtils.mergeIn(bef, {x: {y: "y", z: "z"}}); + expect(aft).to.eql({a: "a", x: {y: "y", z: "z"}}); + }); + + it("can overwrite {} to primitive", () => { + const bef = {a: "a", x: {}}; + const aft = Utils.OtherUtils.mergeIn(bef, {x: "x"}); + expect(aft).to.eql({a: "a", x: "x"}); + }); + + it("can merge deeply", () => { + const bef = {a: "a", x: {y: 1}, z: {zz: {zzz: 3, aaa: 1}}}; + const aft = Utils.OtherUtils.mergeIn(bef, {z: {zz: {zzz: 4, ddd: 5}}}); + expect(aft).to.eql({a: "a", x: {y: 1}, z: {zz: {zzz: 4, aaa: 1, ddd: 5}}}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.a === aft.a).to.eq(true); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.x === aft.x).to.eq(true); + }); + + it("must clone if deepCopyObj: true", () => { + const bef = {a: "a", x: {y: 1}, z: {zz: {zzz: 3, aaa: 1}}}; + const aft = Utils.OtherUtils.mergeIn(bef, {z: {zz: {zzz: 4, ddd: 5}}}, { deepCopyObj: true }); + expect(aft).to.eql({a: "a", x: {y: 1}, z: {zz: {zzz: 4, aaa: 1, ddd: 5}}}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.a === aft.a).to.eq(true); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.x === aft.x).to.eq(false); + }); + + it("can delete key if value is undefined in mixin", () => { + const bef = { + a: "a", x: {y: 1}, z: {zz: {zzz: {zzzz: 0}, aaa: [1]}}, keeped: [2] + }; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: undefined, z: {zz: {zzz: undefined, miss: undefined}} + }); + expect(aft).to.eql({a: "a", z: {zz: {aaa: [1]}}, keeped: [2]}); + expect(bef.keeped === aft.keeped).to.eq(true); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.z.zz.aaa === aft.z.zz.aaa).to.eq(true); + }); + + it("can set undefined value with Symbol.for('_v')", () => { + const bef = {a: "a", x: {y: 1}}; + const aft = Utils.OtherUtils.mergeIn(bef, { + a: {[$v]: undefined}, x: {y: 1, z: {[$v]: undefined}} + }); + expect(aft).to.eql({a: undefined, x: {y: 1, z: undefined}}); + }); + + it("respects Symbol.for('_canCreate')", () => { + const bef = {x: {xx: 1}}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: {add: "add"}, y: {[$canCreate]: false, add: "add"}, z: {[$canCreate]: false, [$v]: "zz"} + }); + expect(aft).to.eql({x: {xx: 1, add: "add"}}); + + const aft2 = Utils.OtherUtils.mergeIn(bef, { + x: {add: "add"}, y: {[$canCreate]: true, add: "add"}, z: {[$canCreate]: true, [$v]: "zz"} + }); + expect(aft2).to.eql({x: {xx: 1, add: "add"}, y: {add: "add"}, z: "zz"}); + + // respects even for array + const aft3 = Utils.OtherUtils.mergeIn(bef, { + y: withCanCreate([1]), z: withCantCreate([2]) + }); + expect(aft3).to.eql({x: {xx: 1}, y: [1]}); + }); + + it("respects Symbol.for('_canChangeType')", () => { + const bef = {x: {xx: 1}, y: ["yy"], z: ["z", "z"]}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: {[$v]: ["x"], [$canChangeType]: false}, y: {[$canChangeType]: false, add: "add"}, z: ["zz"] + }); + expect(aft).to.eql({x: {xx: 1}, y: ["yy"], z: ["zz", "z"]}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.x === aft.x).to.eq(true); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.y === aft.y).to.eq(true); + + const aft2 = Utils.OtherUtils.mergeIn(bef, { + x: {[$v]: ["x"], [$canChangeType]: true}, y: {[$canChangeType]: true, add: "add"}, z: {[$v]: ["zz"]} + }); + expect(aft2).to.eql({x: ["x"], y: {add: "add"}, z: ["zz"]}); + + const aft3 = Utils.OtherUtils.mergeIn(bef, { + x: {[$canChangeType]: true, [$type]: "array", 1: "1"} + }); + expect(aft3).to.eql({x: [undefined, "1"], y: ["yy"], z: ["z", "z"]}); + }); + + it("(deprecated) respects arrays with Symbol.for('_type')", () => { + const bef = {a: [ + {aa: "a"}, + {bb: "b", cc: + [ 0, 1, 2, {dd: 1}, 44, {x: 1}, {}, 7, 8, 9 ] + } + ]}; + const aft = Utils.OtherUtils.mergeIn(bef, {a: { + [$type]: "array", + 0: {aa: "aa"}, + 1: { + bb: "bb", + cc: { + [$type]: "array", + 0: "0", + 1: "1", + // skip `2` + 3: {ee: 2}, + 4: undefined, // remove `44` + 5: {xx: 11}, + 6: {zz: "zz"}, + 7: undefined, // remove `7` + } + } + }}); + expect(aft).to.eql({a: [ + {aa: "aa"}, + {bb: "bb", cc: [ + "0", "1", 2, {dd: 1, ee: 2}, {x: 1, xx: 11}, {zz: "zz"}, 8, 9 + ]} + ]}); + }); + + it("respects arrays", () => { + const bef = {a: [ + 1, {b: "b"}, 2, {c: "c"}, 3, {} + ]}; + const aft = Utils.OtherUtils.mergeIn(bef, {a: [ + "11", + undefined, // will not remove! + 22, + {c: undefined, d: "d"}, + undefined + ]}); + expect(aft).to.eql({a: [ + "11", + undefined, // not removed! + 22, + {d: "d"}, + undefined, + {} + ]}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(bef.a[5] === aft.a[5]).to.eql(true); + }); + + it("can join arrays with arrayMergeMode: 'join'", () => { + const bef = {x: [1, 2, {a: 3}]}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: [2, {a: 3}, 5] + }, { + arrayMergeMode: "join", + }); + expect(aft).to.eql({x: [1, 2, {a: 3}, 2, {a: 3}, 5]}); + }); + + it("can join arrays without repeats with arrayMergeMode: 'joinMissing'", () => { + const bef = {x: [1, 2, {a: 3}]}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: [2, {a: 3}, 5] + }, { + arrayMergeMode: "joinMissing", + }); + expect(aft).to.eql({x: [1, 2, {a: 3}, {a: 3}, 5]}); + }); + + it("can join arrays respecting order with arrayMergeMode: 'joinRespectOrder'", () => { + const bef = {x: [1, 4, 9]}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: [3, 5, 9] + }, { + arrayMergeMode: "joinRespectOrder", + }); + expect(aft).to.eql({x: [1, 4, 3, 5, 9]}); + }); + + it("can overwrite arrays with arrayMergeMode: 'overwrite'", () => { + const bef = {x: [1, 4, 5]}; + const aft = Utils.OtherUtils.mergeIn(bef, { + x: [4, 6, 2] + }, { + arrayMergeMode: "overwrite", + }); + expect(aft).to.eql({x: [4, 6, 2]}); + }); + + }); +}); diff --git a/packages/tests/specs/QueryWithFunc.test.js b/packages/tests/specs/QueryWithFunc.test.js index 55dd10ca6..05765393d 100644 --- a/packages/tests/specs/QueryWithFunc.test.js +++ b/packages/tests/specs/QueryWithFunc.test.js @@ -188,7 +188,7 @@ describe("query with func", () => { describe("loads tree with func SUM_OF_MULTISELECT", () => { export_checks([with_all_types, with_funcs], inits.with_func_sum_of_multiselect, "JsonLogic", { "query": "num == SUM_OF_MULTISELECT(3,5)", - "queryHuman": "Number = Sum of multiselect(Value: 3,5)", + "queryHuman": "Number = Sum of multiselect(Value: C,E)", "sql": "num = SUM_OF_MULTISELECT(3,5)", "spel": "num == {3, 5}.sumOfMultiselect()", "logic": { @@ -211,7 +211,7 @@ describe("query with func", () => { describe("loads tree with func SUM_OF_MULTISELECT from SpEL", () => { export_checks([with_all_types, with_funcs], inits.with_func_sum_of_multiselect_spel, "SpEL", { "query": "num == SUM_OF_MULTISELECT(5)", - "queryHuman": "Number = Sum of multiselect(Value: 5)", + "queryHuman": "Number = Sum of multiselect(Value: E)", "sql": "num = SUM_OF_MULTISELECT(5)", "spel": "num == {5}.sumOfMultiselect()", "logic": { diff --git a/packages/tests/specs/QueryWithOperators.test.js b/packages/tests/specs/QueryWithOperators.test.js index 30a2b2d0c..09852b8c4 100644 --- a/packages/tests/specs/QueryWithOperators.test.js +++ b/packages/tests/specs/QueryWithOperators.test.js @@ -2,9 +2,11 @@ import * as configs from "../support/configs"; import * as inits from "../support/inits"; import { export_checks } from "../support/utils"; import { Utils } from "@react-awesome-query-builder/core"; +import { SqlUtils } from "@react-awesome-query-builder/sql"; import { BasicConfig } from "@react-awesome-query-builder/ui"; import { expect } from "chai"; + describe("query with ops", () => { describe("reverseOperatorsForNot == true", () => { export_checks([configs.with_all_types, configs.with_reverse_operators], inits.with_ops, "JsonLogic", { @@ -479,6 +481,12 @@ describe("query with ops", () => { expect(output.logic).to.deep.equal(input); }); }); + + describe("@sql", () => { + export_checks([configs.with_all_types], inits.with_ops_sql, "SQL", { + "sql": inits.with_ops_sql, + }); + }); }); describe("query with exclamation operators", () => { diff --git a/packages/tests/specs/SwitchCase.test.ts b/packages/tests/specs/SwitchCase.test.ts index 5e1d68284..b32c99740 100644 --- a/packages/tests/specs/SwitchCase.test.ts +++ b/packages/tests/specs/SwitchCase.test.ts @@ -5,7 +5,7 @@ import {export_checks} from "../support/utils"; describe("query with switch-case", () => { - describe("2 cases and 1 default", () => { + describe("@sql 2 cases and 1 default", () => { export_checks([configs.with_all_types, configs.with_cases], inits.spel_with_cases, "SpEL", { "spel": "(str == '222' ? 'is_string' : (num == 4 ? 'is_number' : 'unknown'))", logic: @@ -23,6 +23,10 @@ describe("query with switch-case", () => { export_checks([configs.with_all_types, configs.with_cases], inits.with_cases, undefined, { "spel": "(str == '222' ? 'is_string' : (num == 4 ? 'is_number' : 'unknown'))", }); + + export_checks([configs.with_all_types, configs.with_cases], inits.sql_with_cases, "SQL", { + "spel": "(str == '222' ? 'is_string' : (num == 4 ? 'is_number' : 'unknown'))", + }); }); describe("1 case and 1 default", () => { diff --git a/packages/tests/support/configs.js b/packages/tests/support/configs.js index 36296384d..5c7131235 100644 --- a/packages/tests/support/configs.js +++ b/packages/tests/support/configs.js @@ -111,6 +111,7 @@ export const without_less_format = (BasicConfig) => ({ less: { ...BasicConfig.operators.less, sqlOp: null, + sqlOps: null, spelOp: null, spelOps: null, formatOp: null, @@ -1086,6 +1087,7 @@ export const with_funcs = (BasicConfig) => ({ LOWER2: merge({}, BasicFuncs.LOWER, { label: "Lowercase2", mongoFunc: "$toLower2", + sqlFunc: "LOWER2", jsonLogic: "toLowerCase2", spelFunc: "${str}.toLowerCase2(${def}, ${opt})", allowSelfNesting: true, diff --git a/packages/tests/support/inits.js b/packages/tests/support/inits.js index 83812e324..f92a51ce4 100644 --- a/packages/tests/support/inits.js +++ b/packages/tests/support/inits.js @@ -842,6 +842,8 @@ export const with_ops = { ] }; +export const with_ops_sql = "(text = 'Long\\nText' AND num <> 2 AND str LIKE '%abc%' AND str NOT LIKE '%xyz%' AND num BETWEEN 1 AND 2 AND num NOT BETWEEN 3 AND 4 AND num IS NULL AND color IN ('yellow') AND color NOT IN ('green') AND multicolor != 'yellow')"; + export const with_ops_and_negation_groups = { "and": [ { @@ -1718,6 +1720,8 @@ export const spel_with_cases = "(str == '222' ? is_string : (num == 4 ? is_numbe export const spel_with_cases_simple = "(str == '222' ? foo : bar)"; export const spel_with_cases_and_concat = "(str == '222' ? foo : foo + bar)"; +export const sql_with_cases = "IF(str = '222', 'is_string', IF(num = 4, 'is_number', 'unknown'))"; + export const with_cases = {"if": [ {"==":[{"var":"str"},"222"]}, "is_string", @@ -1736,6 +1740,8 @@ export const with_cases_simple = { ] }; +export const sql_with_lhs_toLowerCase = "LOWER(str) LIKE 'aaa%'"; + export const spel_with_lhs_toLowerCase = "str.toLowerCase().startsWith('aaa')"; export const spel_with_lhs_toLowerCase_toUpperCase = "str.toLowerCase().toUpperCase() == str.toUpperCase()"; //export const spel_with_new_Date = "datetime == new java.util.Date()"; diff --git a/packages/tests/support/utils.tsx b/packages/tests/support/utils.tsx index f0448f12a..1bae7647b 100644 --- a/packages/tests/support/utils.tsx +++ b/packages/tests/support/utils.tsx @@ -1,5 +1,6 @@ import React, { ReactElement } from "react"; import { mount, shallow, ReactWrapper, MountRendererProps } from "enzyme"; +import type { SuiteFunction, TestFunction, HookFunction } from "mocha"; // to fix TS warnings in VSCode about `describe`, `it` import sinon, {spy} from "sinon"; import { expect } from "chai"; const stringify = JSON.stringify; @@ -26,6 +27,7 @@ import { MuiConfig } from "@react-awesome-query-builder/mui"; import { MaterialConfig } from "@react-awesome-query-builder/material"; import { BootstrapConfig } from "@react-awesome-query-builder/bootstrap"; import { FluentUIConfig } from "@react-awesome-query-builder/fluent"; +import { SqlUtils } from "@react-awesome-query-builder/sql"; let currentTestName: string; @@ -60,7 +62,7 @@ interface MockedConsole extends Console { __origConsole: Console; __consoleData: ConsoleData; } -type TreeValueFormat = "JsonLogic" | "default" | "SpEL" | null | undefined; +type TreeValueFormat = "JsonLogic" | "default" | "SpEL" | "SQL" | null | undefined; type TreeValue = JsonLogicTree | JsonTree | string | undefined; type ConfigFn = (_: Config) => Config; type ConfigFns = ConfigFn | ConfigFn[]; @@ -190,12 +192,14 @@ const stringifyValidationErrors = (errors: ValidationItemErrors[]) => { export const load_tree = (value: TreeValue, config: Config, valueFormat: TreeValueFormat = null, options?: DoOptions) => { if (!valueFormat) { - if (isJsonLogic(value)) + if (isJsonLogic(value)) { valueFormat = "JsonLogic"; - else if (typeof value === "string") + } else if (typeof value === "string") { + // todo: can be SQL valueFormat = "SpEL"; - else + } else { valueFormat = "default"; + } } let errors: string[] = []; @@ -204,7 +208,9 @@ export const load_tree = (value: TreeValue, config: Config, valueFormat: TreeVal let tree: ImmutableTree | undefined; if (valueFormat === "JsonLogic") { [tree, errors] = _loadFromJsonLogic(value, config); - } else if (valueFormat == "SpEL") { + } else if (valueFormat === "SQL") { + ({tree, errors} = SqlUtils.loadFromSql(value as string, config)); + } else if (valueFormat === "SpEL") { [tree, errors] = loadFromSpel(value as string, config); } else { tree = loadTree(value as JsonTree); @@ -629,11 +635,11 @@ const do_export_checks = async (config: Config, tree: ImmutableTree, expects?: E }); } - doIt("should work to QueryBuilder", () => { - const _res = queryBuilderFormat(tree, config); - }); + // doIt("should work to QueryBuilder", () => { + // const _res = queryBuilderFormat(tree, config); + // }); - if (options?.withRender) { + if (options?.withRender && tree) { const render = async () => { // Render const { destroyQb } = renderQueryBuilder(config, tree!, options); diff --git a/packages/tests/support/zipConfigs.tsx b/packages/tests/support/zipConfigs.tsx index e87eb87d3..f52ba43a6 100644 --- a/packages/tests/support/zipConfigs.tsx +++ b/packages/tests/support/zipConfigs.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Config, Fields, Funcs, BasicFuncs, Func, Types, Type, Operator, Operators, Settings, SelectField, AsyncFetchListValuesFn, SelectFieldSettings, NumberFieldSettings, - FieldProps, ConfigContext, VanillaWidgets, + FieldProps, ConfigContext, VanillaWidgets, SerializedFunction, } from "@react-awesome-query-builder/ui"; import sinon from "sinon"; import omit from "lodash/omit"; @@ -47,21 +47,24 @@ const fields: Fields = { useLoadMore: true, forceAsyncSearch: false, allowCustomValues: false, - asyncFetch: "autocompleteFetch", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + asyncFetch: "autocompleteFetch" as SerializedFunction as any, } as SelectFieldSettings, }, autocomplete2: { type: "select", fieldSettings: { useAsyncSearch: true, - asyncFetch: { CALL: [ {var: "ctx.autocompleteFetch"}, null, {var: "search"}, {var: "offset"} ] }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + asyncFetch: { CALL: [ {var: "ctx.autocompleteFetch"}, null, {var: "search"}, {var: "offset"} ] } as SerializedFunction as any, } as SelectFieldSettings, }, autocomplete3: { type: "select", fieldSettings: { useAsyncSearch: true, - asyncFetch: "autocompleteFetch__does_not_exist", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + asyncFetch: "autocompleteFetch__does_not_exist" as SerializedFunction as any, } as SelectFieldSettings, }, slider: { @@ -180,11 +183,16 @@ const funcs: Funcs = { }; const settings: Partial = { - renderField: "myRenderField", - renderButton: "button", // missing in ctx, so will try to render