From 4d71f9cda94c7eabaf549ee1456b904da5503593 Mon Sep 17 00:00:00 2001 From: Nicolas M Date: Tue, 30 Jan 2024 14:55:44 +0100 Subject: [PATCH] refactor: rebase with main (#935) * fix: disableFieldSorting is now only preventing frontend to sort the collection * chore(release): @forestadmin/datasource-replica@1.0.58 [skip ci] ## @forestadmin/datasource-replica [1.0.58](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.57...@forestadmin/datasource-replica@1.0.58) (2024-01-22) ### Dependencies * **@forestadmin/datasource-customizer:** upgraded to 1.40.3 * chore(release): example@1.6.75 [skip ci] ## [1.6.75](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.74...example@1.6.75) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/agent@1.36.15 [skip ci] ## [1.36.15](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.36.14...@forestadmin/agent@1.36.15) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/datasource-customizer@1.40.3 [skip ci] ## [1.40.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.40.2...@forestadmin/datasource-customizer@1.40.3) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/datasource-dummy@1.0.93 [skip ci] ## [1.0.93](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.92...@forestadmin/datasource-dummy@1.0.93) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/plugin-aws-s3@1.3.57 [skip ci] ## [1.3.57](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.56...@forestadmin/plugin-aws-s3@1.3.57) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/plugin-export-advanced@1.0.69 [skip ci] ## [1.0.69](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.68...@forestadmin/plugin-export-advanced@1.0.69) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * chore(release): @forestadmin/plugin-flattener@1.0.82 [skip ci] ## [1.0.82](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.81...@forestadmin/plugin-flattener@1.0.82) (2024-01-22) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * fix(replaceFieldWriting): force customer to give a replaceFieldWriting definition to avoid emulation when null is given (#913) * chore(release): @forestadmin/datasource-replica@1.0.59 [skip ci] ## @forestadmin/datasource-replica [1.0.59](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.58...@forestadmin/datasource-replica@1.0.59) (2024-01-22) ### Dependencies * **@forestadmin/datasource-customizer:** upgraded to 1.40.4 * chore(release): example@1.6.76 [skip ci] ## [1.6.76](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.75...example@1.6.76) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/agent@1.36.16 [skip ci] ## [1.36.16](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.36.15...@forestadmin/agent@1.36.16) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/datasource-customizer@1.40.4 [skip ci] ## [1.40.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.40.3...@forestadmin/datasource-customizer@1.40.4) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/datasource-dummy@1.0.94 [skip ci] ## [1.0.94](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.93...@forestadmin/datasource-dummy@1.0.94) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/plugin-aws-s3@1.3.58 [skip ci] ## [1.3.58](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.57...@forestadmin/plugin-aws-s3@1.3.58) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/plugin-export-advanced@1.0.70 [skip ci] ## [1.0.70](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.69...@forestadmin/plugin-export-advanced@1.0.70) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * chore(release): @forestadmin/plugin-flattener@1.0.83 [skip ci] ## [1.0.83](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.82...@forestadmin/plugin-flattener@1.0.83) (2024-01-22) ### Bug Fixes * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * fix: return more details in errors due to certificate validation to help debugging (#917) * chore(release): @forestadmin/forestadmin-client@1.25.5 [skip ci] ## @forestadmin/forestadmin-client [1.25.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.25.4...@forestadmin/forestadmin-client@1.25.5) (2024-01-23) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) * chore(release): example@1.6.77 [skip ci] ## [1.6.77](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.76...example@1.6.77) (2024-01-23) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) * chore(release): @forestadmin/agent@1.36.17 [skip ci] ## [1.36.17](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.36.16...@forestadmin/agent@1.36.17) (2024-01-23) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) * refactor(example): fix agent typings (#920) * refactor: bump nodejs version used in github actions (#919) * feat(datasource-customizer): implement gmail-style search (#780) * chore(release): @forestadmin/plugin-export-advanced@1.0.71 [skip ci] ## @forestadmin/plugin-export-advanced [1.0.71](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.70...@forestadmin/plugin-export-advanced@1.0.71) (2024-01-26) ### Dependencies * **@forestadmin/datasource-customizer:** upgraded to 1.41.0 * **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 * chore(release): example@1.6.78 [skip ci] ## [1.6.78](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.77...example@1.6.78) (2024-01-26) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/agent@1.36.18 [skip ci] ## [1.36.18](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.36.17...@forestadmin/agent@1.36.18) (2024-01-26) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-customizer@1.41.0 [skip ci] # [1.41.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.40.4...@forestadmin/datasource-customizer@1.41.0) (2024-01-26) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-dummy@1.1.0 [skip ci] # [1.1.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.94...@forestadmin/datasource-dummy@1.1.0) (2024-01-26) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-mongoose@1.6.0 [skip ci] # [1.6.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-mongoose@1.5.33...@forestadmin/datasource-mongoose@1.6.0) (2024-01-26) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * **replaceFieldOperator:** disallow to pass null because it will emulate instead to remove the operator ([#911](https://github.com/ForestAdmin/agent-nodejs/issues/911)) ([bf0c105](https://github.com/ForestAdmin/agent-nodejs/commit/bf0c105cfb5850ede7be223bfbe59044ff6fe9cb)) * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-replica@1.0.60 [skip ci] ## [1.0.60](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.59...@forestadmin/datasource-replica@1.0.60) (2024-01-26) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-sequelize@1.6.0 [skip ci] # [1.6.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sequelize@1.5.27...@forestadmin/datasource-sequelize@1.6.0) (2024-01-26) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * **replaceFieldOperator:** disallow to pass null because it will emulate instead to remove the operator ([#911](https://github.com/ForestAdmin/agent-nodejs/issues/911)) ([bf0c105](https://github.com/ForestAdmin/agent-nodejs/commit/bf0c105cfb5850ede7be223bfbe59044ff6fe9cb)) * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-sql@1.7.45 [skip ci] ## [1.7.45](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sql@1.7.44...@forestadmin/datasource-sql@1.7.45) (2024-01-26) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * **replaceFieldOperator:** disallow to pass null because it will emulate instead to remove the operator ([#911](https://github.com/ForestAdmin/agent-nodejs/issues/911)) ([bf0c105](https://github.com/ForestAdmin/agent-nodejs/commit/bf0c105cfb5850ede7be223bfbe59044ff6fe9cb)) * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/datasource-toolkit@1.30.0 [skip ci] # [1.30.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-toolkit@1.29.2...@forestadmin/datasource-toolkit@1.30.0) (2024-01-26) ### Bug Fixes * disableFieldSorting is now only preventing frontend to sort the collection ([7f1481b](https://github.com/ForestAdmin/agent-nodejs/commit/7f1481bd56fc98da87fa4aa473cb7b806851c551)) * **replaceFieldOperator:** disallow to pass null because it will emulate instead to remove the operator ([#911](https://github.com/ForestAdmin/agent-nodejs/issues/911)) ([bf0c105](https://github.com/ForestAdmin/agent-nodejs/commit/bf0c105cfb5850ede7be223bfbe59044ff6fe9cb)) * **replaceFieldWriting:** force customer to give a replaceFieldWriting definition to avoid emulation when null is given ([#913](https://github.com/ForestAdmin/agent-nodejs/issues/913)) ([b0b1862](https://github.com/ForestAdmin/agent-nodejs/commit/b0b1862dfba148acf61b0463f246dbc19a4b5afd)) * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/forestadmin-client@1.25.6 [skip ci] ## [1.25.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.25.5...@forestadmin/forestadmin-client@1.25.6) (2024-01-26) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/plugin-aws-s3@1.3.59 [skip ci] ## [1.3.59](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.58...@forestadmin/plugin-aws-s3@1.3.59) (2024-01-26) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) * chore(release): @forestadmin/plugin-flattener@1.0.84 [skip ci] ## [1.0.84](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.83...@forestadmin/plugin-flattener@1.0.84) (2024-01-26) ### Bug Fixes * return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) ### Features * **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) --------- Co-authored-by: Enki Co-authored-by: Forest Bot Co-authored-by: scra Co-authored-by: Guillaume Gautreau Co-authored-by: Romain Gilliotte Co-authored-by: Nicolas Moreau --- .codeclimate.yml | 7 +- .github/workflows/build.yml | 10 +- jest.config.ts | 1 + packages/_example/CHANGELOG.md | 41 +- packages/_example/package.json | 14 +- packages/_example/src/forest/typings.ts | 188 +-- packages/agent/CHANGELOG.md | 41 +- packages/agent/package.json | 8 +- packages/datasource-customizer/.eslintrc.js | 3 + packages/datasource-customizer/CHANGELOG.md | 29 + packages/datasource-customizer/package.json | 6 +- .../src/decorators/search/Query.g4 | 45 + .../search/collection-search-context.ts | 42 + .../src/decorators/search/collection.ts | 126 +- .../condition-tree-query-walker.ts | 166 +++ .../custom-parser/custom-error-strategy.ts | 8 + .../custom-parser/custom-query-parser.ts | 6 + .../custom-parser/fields-query-walker.ts | 10 + .../build-basic-array-field-filter.ts | 34 + .../build-boolean-field-filter.ts | 43 + .../filter-builder/build-date-field-filter.ts | 238 ++++ .../build-enum-array-field-filter.ts | 18 + .../filter-builder/build-enum-field-filter.ts | 39 + .../build-number-array-field-filter.ts | 15 + .../build-number-field-filter.ts | 52 + .../build-string-array-field-filter.ts | 12 + .../build-string-field-filter.ts | 61 + .../filter-builder/build-uuid-field-filter.ts | 35 + .../decorators/search/filter-builder/index.ts | 80 ++ .../utils/build-default-condition.ts | 5 + .../filter-builder/utils/find-enum-value.ts | 12 + .../search/generated-parser/Query.interp | 44 + .../search/generated-parser/Query.tokens | 15 + .../search/generated-parser/QueryLexer.interp | 55 + .../search/generated-parser/QueryLexer.tokens | 15 + .../search/generated-parser/QueryLexer.ts | 119 ++ .../search/generated-parser/QueryListener.ts | 135 ++ .../search/generated-parser/QueryParser.ts | 1035 ++++++++++++++ .../search/generated-parser/README.md | 4 + .../src/decorators/search/normalize-name.ts | 3 + .../src/decorators/search/parse-query.ts | 53 + .../src/decorators/sort-emulate/collection.ts | 51 +- .../write/write-replace/collection.ts | 10 +- .../test/collection-customizer.test.ts | 89 +- .../decorators/search/collections.test.ts | 742 ++++++----- .../build-basic-array-field-filter.test.ts | 69 + .../build-boolean-field-filter.test.ts | 98 ++ .../build-date-field-filter.test.ts | 1187 +++++++++++++++++ .../build-enum-array-field-filter.test.ts | 62 + .../build-enum-field-filter.test.ts | 134 ++ .../build-number-array-field.test.ts | 49 + .../build-number-field-filter.test.ts | 427 ++++++ .../build-string-array-field-filter.test.ts | 25 + .../build-string-field-filter.test.ts | 215 +++ .../build-uuid-field-filter.test.ts | 94 ++ .../search/filter-builder/index.test.ts | 226 ++++ .../decorators/search/parse-query.test.ts | 970 ++++++++++++++ .../sort-emulate/collection.test.ts | 14 +- .../write-replace/collection_basics.test.ts | 11 +- packages/datasource-dummy/CHANGELOG.md | 36 + packages/datasource-dummy/package.json | 6 +- .../datasource-dummy/src/collections/base.ts | 1 + packages/datasource-mongoose/CHANGELOG.md | 15 + packages/datasource-mongoose/package.json | 4 +- .../src/utils/pipeline/filter.ts | 6 + .../src/utils/schema/filter-operators.ts | 2 +- .../review/collection_list.test.ts | 37 +- .../test/utils/schema/filter-operator.test.ts | 7 +- packages/datasource-replica/CHANGELOG.md | 33 + packages/datasource-replica/package.json | 10 +- packages/datasource-sequelize/CHANGELOG.md | 15 + .../datasource-sequelize/docker-compose.yml | 37 + packages/datasource-sequelize/package.json | 4 +- .../src/utils/query-converter.ts | 174 ++- .../src/utils/type-converter.ts | 7 +- .../test/__tests/connections.ts | 63 + .../list/filter.integration.test.ts | 488 +++++++ ...del-to-collection-schema-converter.test.ts | 5 +- .../test/utils/query-converter.unit.test.ts | 159 ++- .../test/utils/type-converter.unit.test.ts | 4 +- packages/datasource-sql/CHANGELOG.md | 11 + packages/datasource-sql/package.json | 6 +- packages/datasource-toolkit/CHANGELOG.md | 7 + packages/datasource-toolkit/package.json | 2 +- .../query/condition-tree/equivalence.ts | 8 +- .../query/condition-tree/factory.ts | 2 + .../query/condition-tree/nodes/leaf.ts | 13 +- .../query/condition-tree/nodes/operators.ts | 2 + .../interfaces/query/filter/unpaginated.ts | 2 +- .../src/validation/rules.ts | 4 +- .../validation/condition-tree/index.test.ts | 7 +- packages/forestadmin-client/CHANGELOG.md | 17 + packages/forestadmin-client/package.json | 4 +- .../forestadmin-client/src/utils/server.ts | 3 +- .../test/utils/server.test.ts | 3 +- packages/plugin-aws-s3/CHANGELOG.md | 31 + packages/plugin-aws-s3/package.json | 6 +- packages/plugin-export-advanced/CHANGELOG.md | 31 + packages/plugin-export-advanced/package.json | 6 +- packages/plugin-flattener/CHANGELOG.md | 31 + packages/plugin-flattener/package.json | 6 +- yarn.lock | 114 +- 102 files changed, 8096 insertions(+), 679 deletions(-) create mode 100644 packages/datasource-customizer/.eslintrc.js create mode 100644 packages/datasource-customizer/src/decorators/search/Query.g4 create mode 100644 packages/datasource-customizer/src/decorators/search/collection-search-context.ts create mode 100644 packages/datasource-customizer/src/decorators/search/custom-parser/condition-tree-query-walker.ts create mode 100644 packages/datasource-customizer/src/decorators/search/custom-parser/custom-error-strategy.ts create mode 100644 packages/datasource-customizer/src/decorators/search/custom-parser/custom-query-parser.ts create mode 100644 packages/datasource-customizer/src/decorators/search/custom-parser/fields-query-walker.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-basic-array-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-boolean-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-date-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-array-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-number-array-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-number-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-string-array-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-string-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/build-uuid-field-filter.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/index.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/utils/build-default-condition.ts create mode 100644 packages/datasource-customizer/src/decorators/search/filter-builder/utils/find-enum-value.ts create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/Query.interp create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/Query.tokens create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.interp create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.tokens create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.ts create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/QueryListener.ts create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/QueryParser.ts create mode 100644 packages/datasource-customizer/src/decorators/search/generated-parser/README.md create mode 100644 packages/datasource-customizer/src/decorators/search/normalize-name.ts create mode 100644 packages/datasource-customizer/src/decorators/search/parse-query.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-basic-array-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-boolean-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-date-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-array-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-number-array-field.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-number-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-string-array-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-string-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/build-uuid-field-filter.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/filter-builder/index.test.ts create mode 100644 packages/datasource-customizer/test/decorators/search/parse-query.test.ts create mode 100644 packages/datasource-sequelize/docker-compose.yml create mode 100644 packages/datasource-sequelize/test/__tests/connections.ts create mode 100644 packages/datasource-sequelize/test/integration/list/filter.integration.test.ts diff --git a/.codeclimate.yml b/.codeclimate.yml index 3e3612a54c..39dbe9caab 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,4 +1,4 @@ -version: '2' # required to adjust maintainability checks +version: "2" # required to adjust maintainability checks checks: method-complexity: @@ -6,5 +6,6 @@ checks: config: threshold: 10 exclude_patterns: - - 'packages/_example/' - - '**/test/' + - "packages/_example/" + - "**/test/" + - "packages/datasource-customizer/src/decorators/search/generated-parser/*.ts" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89efdfa878..933b485041 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 - uses: actions/setup-node@v3 with: - node-version: 16.14.0 + node-version: 18.14.0 - uses: actions/cache@v3 with: path: | @@ -78,9 +78,9 @@ jobs: - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 - uses: actions/setup-node@v3 with: - node-version: 16.14.0 + node-version: 18.14.0 - name: Start docker containers - if: ${{ matrix.package == 'datasource-mongoose' || matrix.package == 'datasource-sql' }} + if: ${{ matrix.package == 'datasource-mongoose' || matrix.package == 'datasource-sql' || matrix.package == 'datasource-sequelize' }} run: docker-compose -f ./packages/${{ matrix.package }}/docker-compose.yml up -d; sleep 5 - name: Restore dependencies from cache uses: actions/cache/restore@v3 @@ -145,7 +145,7 @@ jobs: - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 - uses: actions/setup-node@v3 with: - node-version: 16.14.0 + node-version: 18.14.0 - name: Restore dependencies from cache uses: actions/cache/restore@v3 with: @@ -187,7 +187,7 @@ jobs: persist-credentials: false # GITHUB_TOKEN must not be set for the semantic release - uses: actions/setup-node@v3 with: - node-version: 16.14.0 + node-version: 18.14.0 - name: Restore dependencies from cache uses: actions/cache/restore@v3 with: diff --git a/jest.config.ts b/jest.config.ts index 35eb48907c..f8cc6d84b4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,6 +6,7 @@ const config: Config.InitialOptions = { collectCoverageFrom: [ '/packages/*/src/**/*.ts', '!/packages/_example/src/**/*.ts', + '!/packages/datasource-customizer/src/decorators/search/generated-parser/*.ts', ], testMatch: ['/packages/*/test/**/*.test.ts'], setupFilesAfterEnv: ['jest-extended/all'], diff --git a/packages/_example/CHANGELOG.md b/packages/_example/CHANGELOG.md index 5983e21242..2c821dd997 100644 --- a/packages/_example/CHANGELOG.md +++ b/packages/_example/CHANGELOG.md @@ -1,4 +1,4 @@ -## example [1.6.75-beta-cloud-customizer.1](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.74...example@1.6.75-beta-cloud-customizer.1) (2024-01-24) +## example [1.6.78](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.77...example@1.6.78) (2024-01-26) @@ -6,7 +6,44 @@ ### Dependencies -* **@forestadmin/agent:** upgraded to 1.37.0-beta-cloud-customizer.1 +* **@forestadmin/agent:** upgraded to 1.36.18 +* **@forestadmin/datasource-dummy:** upgraded to 1.1.0 +* **@forestadmin/datasource-mongoose:** upgraded to 1.6.0 +* **@forestadmin/datasource-sequelize:** upgraded to 1.6.0 +* **@forestadmin/datasource-sql:** upgraded to 1.7.45 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## example [1.6.77](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.76...example@1.6.77) (2024-01-23) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.36.17 + +## example [1.6.76](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.75...example@1.6.76) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.36.16 +* **@forestadmin/datasource-dummy:** upgraded to 1.0.94 + +## example [1.6.75](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.74...example@1.6.75) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/agent:** upgraded to 1.36.15 +* **@forestadmin/datasource-dummy:** upgraded to 1.0.93 ## example [1.6.74](https://github.com/ForestAdmin/agent-nodejs/compare/example@1.6.73...example@1.6.74) (2024-01-18) diff --git a/packages/_example/package.json b/packages/_example/package.json index ccf54912fc..aca3172fb8 100644 --- a/packages/_example/package.json +++ b/packages/_example/package.json @@ -1,16 +1,16 @@ { "name": "example", - "version": "1.6.75-beta-cloud-customizer.1", + "version": "1.6.78", "license": "GPL-V3", "private": true, "dependencies": { "@faker-js/faker": "^7.6.0", - "@forestadmin/agent": "1.37.0-beta-cloud-customizer.1", - "@forestadmin/datasource-dummy": "1.0.92", - "@forestadmin/datasource-mongoose": "1.5.33", - "@forestadmin/datasource-sequelize": "1.5.27", - "@forestadmin/datasource-sql": "1.7.44", - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/agent": "1.36.18", + "@forestadmin/datasource-dummy": "1.1.0", + "@forestadmin/datasource-mongoose": "1.6.0", + "@forestadmin/datasource-sequelize": "1.6.0", + "@forestadmin/datasource-sql": "1.7.45", + "@forestadmin/datasource-toolkit": "1.30.0", "@koa/router": "^12.0.0", "@nestjs/common": "^9.2.1", "@nestjs/core": "^9.2.1", diff --git a/packages/_example/src/forest/typings.ts b/packages/_example/src/forest/typings.ts index 8e67d1b310..911842b83a 100644 --- a/packages/_example/src/forest/typings.ts +++ b/packages/_example/src/forest/typings.ts @@ -117,182 +117,182 @@ export type AccountBillsItemsAggregation = TAggregation = TCollectionName, +> = { + /** + * Include fields from the first level of relations + */ + extended?: boolean; + /** + * Remove these fields from the default search fields + */ + excludeFields?: Array>; + /** + * Add these fields to the default search fields + */ + includeFields?: Array>; + /** + * Replace the list of default searched field by these fields + */ + onlyFields?: Array>; +}; + +export default class CollectionSearchContext< + S extends TSchema = TSchema, + N extends TCollectionName = TCollectionName, +> extends CollectionCustomizationContext { + constructor( + collection: Collection, + caller: Caller, + public readonly generateSearchFilter: ( + searchText: string, + options?: SearchOptions, + ) => ConditionTree, + ) { + super(collection, caller); + } +} diff --git a/packages/datasource-customizer/src/decorators/search/collection.ts b/packages/datasource-customizer/src/decorators/search/collection.ts index 77e883381e..6a3f3a9003 100644 --- a/packages/datasource-customizer/src/decorators/search/collection.ts +++ b/packages/datasource-customizer/src/decorators/search/collection.ts @@ -6,30 +6,28 @@ import { ColumnSchema, ConditionTree, ConditionTreeFactory, - ConditionTreeLeaf, - Operator, + DataSourceDecorator, PaginatedFilter, } from '@forestadmin/datasource-toolkit'; -import { validate as uuidValidate } from 'uuid'; +import CollectionSearchContext, { SearchOptions } from './collection-search-context'; +import normalizeName from './normalize-name'; +import { extractSpecifiedFields, generateConditionTree, parseQuery } from './parse-query'; import { SearchDefinition } from './types'; -import CollectionCustomizationContext from '../../context/collection-context'; export default class SearchCollectionDecorator extends CollectionDecorator { + override dataSource: DataSourceDecorator; replacer: SearchDefinition = null; replaceSearch(replacer: SearchDefinition): void { this.replacer = replacer; } - public override refineSchema(subSchema: CollectionSchema): CollectionSchema { + override refineSchema(subSchema: CollectionSchema): CollectionSchema { return { ...subSchema, searchable: true }; } - public override async refineFilter( - caller: Caller, - filter?: PaginatedFilter, - ): Promise { + override async refineFilter(caller: Caller, filter?: PaginatedFilter): Promise { // Search string is not significant if (!filter?.search?.trim().length) { return filter?.override({ search: null }); @@ -37,12 +35,16 @@ export default class SearchCollectionDecorator extends CollectionDecorator { // Implement search ourselves if (this.replacer || !this.childCollection.schema.searchable) { - const ctx = new CollectionCustomizationContext(this, caller); - let tree = this.defaultReplacer(filter.search, filter.searchExtended); + const ctx = new CollectionSearchContext(this, caller, this.generateSearchFilter.bind(this)); + let tree: ConditionTree; if (this.replacer) { const plainTree = await this.replacer(filter.search, filter.searchExtended, ctx); tree = ConditionTreeFactory.fromPlainObject(plainTree); + } else { + tree = this.generateSearchFilter(caller, filter.search, { + extended: filter.searchExtended, + }); } // Note that if no fields are searchable with the provided searchString, the conditions @@ -58,57 +60,41 @@ export default class SearchCollectionDecorator extends CollectionDecorator { return filter; } - private defaultReplacer(search: string, extended: boolean): ConditionTree { - const searchableFields = SearchCollectionDecorator.getFields(this.childCollection, extended); - const conditions = searchableFields - .map(([field, schema]) => SearchCollectionDecorator.buildCondition(field, schema, search)) - .filter(Boolean); - - return ConditionTreeFactory.union(...conditions); - } - - private static buildCondition( - field: string, - schema: ColumnSchema, - searchString: string, + private generateSearchFilter( + caller: Caller, + searchText: string, + options?: SearchOptions, ): ConditionTree { - const { columnType, enumValues, filterOperators } = schema; - const isNumber = Number(searchString).toString() === searchString; - const isUuid = uuidValidate(searchString); - - if (columnType === 'Number' && isNumber && filterOperators?.has('Equal')) { - return new ConditionTreeLeaf(field, 'Equal', Number(searchString)); - } - - if (columnType === 'Enum' && filterOperators?.has('Equal')) { - const searchValue = SearchCollectionDecorator.lenientFind(enumValues, searchString); - - if (searchValue) return new ConditionTreeLeaf(field, 'Equal', searchValue); - } - - if (columnType === 'String') { - const isCaseSensitive = searchString.toLocaleLowerCase() !== searchString.toLocaleUpperCase(); - const supportsIContains = filterOperators?.has('IContains'); - const supportsContains = filterOperators?.has('Contains'); - const supportsEqual = filterOperators?.has('Equal'); - - // Perf: don't use case-insensitive operator when the search string is indifferent to case - let operator: Operator; - if (supportsIContains && (isCaseSensitive || !supportsContains)) operator = 'IContains'; - else if (supportsContains) operator = 'Contains'; - else if (supportsEqual) operator = 'Equal'; + const parsedQuery = parseQuery(searchText); + + const specifiedFields = options?.onlyFields ? [] : extractSpecifiedFields(parsedQuery); + + const defaultFields = options?.onlyFields + ? [] + : this.getFields(this.childCollection, Boolean(options?.extended)); + + const searchableFields = new Map( + [ + ...defaultFields, + ...[...specifiedFields, ...(options?.onlyFields ?? []), ...(options?.includeFields ?? [])] + .map(name => this.lenientGetSchema(name)) + .filter(Boolean) + .map(schema => [schema.field, schema.schema] as [string, ColumnSchema]), + ] + .filter(Boolean) + .filter(([field]) => !options?.excludeFields?.includes(field)), + ); - if (operator) return new ConditionTreeLeaf(field, operator, searchString); - } + const conditionTree = generateConditionTree(caller, parsedQuery, [...searchableFields]); - if (columnType === 'Uuid' && isUuid && filterOperators?.has('Equal')) { - return new ConditionTreeLeaf(field, 'Equal', searchString); + if (!conditionTree && searchText.trim().length) { + return ConditionTreeFactory.MatchNone; } - return null; + return conditionTree; } - private static getFields(collection: Collection, extended: boolean): [string, ColumnSchema][] { + private getFields(collection: Collection, extended: boolean): [string, ColumnSchema][] { const fields: [string, ColumnSchema][] = []; for (const [name, field] of Object.entries(collection.schema.fields)) { @@ -125,10 +111,30 @@ export default class SearchCollectionDecorator extends CollectionDecorator { return fields; } - private static lenientFind(haystack: string[], needle: string): string { - return ( - haystack?.find(v => v === needle.trim()) ?? - haystack?.find(v => v.toLocaleLowerCase() === needle.toLocaleLowerCase().trim()) - ); + private lenientGetSchema(path: string): { field: string; schema: ColumnSchema } | null { + const [prefix, suffix] = path.split(/:(.*)/); + const fuzzyPrefix = normalizeName(prefix); + + for (const [field, schema] of Object.entries(this.schema.fields)) { + const fuzzyFieldName = normalizeName(field); + + if (fuzzyPrefix === fuzzyFieldName) { + if (!suffix && schema.type === 'Column') { + return { field, schema }; + } + + if ( + suffix && + (schema.type === 'OneToMany' || schema.type === 'ManyToOne' || schema.type === 'OneToOne') + ) { + const related = this.dataSource.getCollection(schema.foreignCollection); + const fuzzy = related.lenientGetSchema(suffix); + + if (fuzzy) return { field: `${field}:${fuzzy.field}`, schema: fuzzy.schema }; + } + } + } + + return null; } } diff --git a/packages/datasource-customizer/src/decorators/search/custom-parser/condition-tree-query-walker.ts b/packages/datasource-customizer/src/decorators/search/custom-parser/condition-tree-query-walker.ts new file mode 100644 index 0000000000..645541f81c --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/custom-parser/condition-tree-query-walker.ts @@ -0,0 +1,166 @@ +import { + Caller, + ColumnSchema, + ConditionTree, + ConditionTreeFactory, +} from '@forestadmin/datasource-toolkit'; + +import buildFieldFilter from '../filter-builder/index'; +import QueryListener from '../generated-parser/QueryListener'; +import { + NegatedContext, + PropertyMatchingContext, + QuotedContext, + WordContext, +} from '../generated-parser/QueryParser'; +import normalizeName from '../normalize-name'; + +export default class ConditionTreeQueryWalker extends QueryListener { + private parentStack: ConditionTree[][] = []; + private currentField: string = null; + private isNegated = false; + + get conditionTree(): ConditionTree { + if (this.parentStack.length !== 1 && this.parentStack[0].length !== 1) { + throw new Error('Invalid condition tree'); + } + + return this.parentStack[0][0]; + } + + constructor(private readonly caller: Caller, private readonly fields: [string, ColumnSchema][]) { + super(); + } + + public generateDefaultFilter(searchQuery: string): ConditionTree { + return this.buildDefaultCondition(searchQuery, this.isNegated); + } + + override enterQuery = () => { + this.parentStack.push([]); + }; + + override exitQuery = () => { + const rules = this.parentStack.pop(); + + if (!rules) { + throw new Error('Empty query'); + } + + if (rules.length === 1) { + this.parentStack.push(rules); + } else { + this.parentStack.push([ConditionTreeFactory.intersect(...rules)]); + } + }; + + override exitQuoted = (ctx: QuotedContext) => { + const current = this.parentStack[this.parentStack.length - 1]; + + current.push(this.buildDefaultCondition(ctx.getText().slice(1, -1), this.isNegated)); + }; + + override enterNegated = () => { + this.parentStack.push([]); + this.isNegated = true; + }; + + override exitNegated = (ctx: NegatedContext) => { + const text = ctx.getText(); + const rules = this.parentStack.pop(); + + if (!rules) return; + + let result: ConditionTree; + + if (!Number.isNaN(Number(text)) && rules.length === 1) { + result = this.buildDefaultCondition(text, false); + } else { + result = ConditionTreeFactory.intersect(...rules.filter(Boolean)); + } + + const parentRules = this.parentStack[this.parentStack.length - 1]; + + if (parentRules) { + parentRules.push(result); + } else { + // We should at least have an array for the root query + throw new Error('Empty stack'); + } + + this.isNegated = false; + }; + + override exitWord = (ctx: WordContext) => { + const current = this.parentStack[this.parentStack.length - 1]; + + current.push(this.buildDefaultCondition(ctx.getText(), this.isNegated)); + }; + + override enterPropertyMatching = (ctx: PropertyMatchingContext) => { + this.currentField = ctx.getChild(0).getText().replace(/\./g, ':'); + }; + + override exitPropertyMatching = () => { + this.currentField = null; + }; + + override enterOr = () => { + this.parentStack.push([]); + }; + + override exitOr = () => { + const rules = this.parentStack.pop(); + if (!rules.length) return; + + const parentRules = this.parentStack[this.parentStack.length - 1]; + + parentRules.push(ConditionTreeFactory.union(...rules)); + }; + + override enterAnd = () => { + this.parentStack.push([]); + }; + + override exitAnd = () => { + const rules = this.parentStack.pop(); + if (!rules.length) return; + + const parentRules = this.parentStack[this.parentStack.length - 1]; + + parentRules.push(ConditionTreeFactory.intersect(...rules)); + }; + + private buildDefaultCondition(searchString: string, isNegated: boolean): ConditionTree { + const targetedFields = + this.currentField && + this.fields.filter(([field]) => normalizeName(field) === normalizeName(this.currentField)); + + let rules: ConditionTree[] = []; + + if (!targetedFields?.length) { + rules = this.fields.map(([field, schema]) => + buildFieldFilter( + this.caller, + field, + schema, + // If targetFields is empty, it means that the query is not targeting a specific field + // OR that the field is not found in the schema. If it's the case, we are re-constructing + // the original query by adding the field name in front of the search string. + this.currentField ? `${this.currentField}:${searchString}` : searchString, + isNegated, + ), + ); + } else { + rules = targetedFields.map(([field, schema]) => + buildFieldFilter(this.caller, field, schema, searchString, isNegated), + ); + } + + if (!rules.some(Boolean)) return null; + + return isNegated + ? ConditionTreeFactory.intersect(...rules) + : ConditionTreeFactory.union(...rules); + } +} diff --git a/packages/datasource-customizer/src/decorators/search/custom-parser/custom-error-strategy.ts b/packages/datasource-customizer/src/decorators/search/custom-parser/custom-error-strategy.ts new file mode 100644 index 0000000000..6f42f1991f --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/custom-parser/custom-error-strategy.ts @@ -0,0 +1,8 @@ +import { DefaultErrorStrategy } from 'antlr4'; + +export default class CustomErrorStrategy extends DefaultErrorStrategy { + override reportError(): void { + // We don't want console logs when parsing fails + // Do nothing + } +} diff --git a/packages/datasource-customizer/src/decorators/search/custom-parser/custom-query-parser.ts b/packages/datasource-customizer/src/decorators/search/custom-parser/custom-query-parser.ts new file mode 100644 index 0000000000..75a4074cc8 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/custom-parser/custom-query-parser.ts @@ -0,0 +1,6 @@ +import CustomErrorStrategy from './custom-error-strategy'; +import QueryParser from '../generated-parser/QueryParser'; + +export default class CustomQueryParser extends QueryParser { + override _errHandler = new CustomErrorStrategy(); +} diff --git a/packages/datasource-customizer/src/decorators/search/custom-parser/fields-query-walker.ts b/packages/datasource-customizer/src/decorators/search/custom-parser/fields-query-walker.ts new file mode 100644 index 0000000000..ca89e4fa97 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/custom-parser/fields-query-walker.ts @@ -0,0 +1,10 @@ +import QueryListener from '../generated-parser/QueryListener'; +import { PropertyMatchingContext } from '../generated-parser/QueryParser'; + +export default class FieldsQueryWalker extends QueryListener { + public fields: string[] = []; + + override enterPropertyMatching = (ctx: PropertyMatchingContext) => { + this.fields.push(ctx.getChild(0).getText().replace(/\./g, ':')); + }; +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-basic-array-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-basic-array-field-filter.ts new file mode 100644 index 0000000000..1a6e96b05d --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-basic-array-field-filter.ts @@ -0,0 +1,34 @@ +import { + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; + +import buildDefaultCondition from './utils/build-default-condition'; + +export default function buildBasicArrayFieldFilter( + field: string, + filterOperators: Set | undefined, + searchString: unknown, + isNegated: boolean, +): ConditionTree { + if (!isNegated) { + if (filterOperators?.has('IncludesAll')) { + return new ConditionTreeLeaf(field, 'IncludesAll', searchString); + } + } else if (filterOperators?.has('IncludesNone')) { + if (filterOperators.has('IncludesNone') && filterOperators.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'IncludesNone', searchString), + new ConditionTreeLeaf(field, 'Missing'), + ); + } + + if (filterOperators.has('IncludesNone')) { + return new ConditionTreeLeaf(field, 'IncludesNone', searchString); + } + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-boolean-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-boolean-field-filter.ts new file mode 100644 index 0000000000..5513cedf00 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-boolean-field-filter.ts @@ -0,0 +1,43 @@ +import { + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; + +import buildDefaultCondition from './utils/build-default-condition'; + +export default function buildBooleanFieldFilter( + field: string, + filterOperators: Set, + searchString: string, + isNegated: boolean, +): ConditionTree { + const operator = isNegated ? 'NotEqual' : 'Equal'; + + if (filterOperators?.has(operator)) { + if (['true', '1'].includes(searchString?.toLowerCase())) { + if (isNegated && filterOperators.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, operator, true), + new ConditionTreeLeaf(field, 'Missing', null), + ); + } + + return new ConditionTreeLeaf(field, operator, true); + } + + if (['false', '0'].includes(searchString?.toLowerCase())) { + if (isNegated && filterOperators.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, operator, false), + new ConditionTreeLeaf(field, 'Missing', null), + ); + } + + return new ConditionTreeLeaf(field, operator, false); + } + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-date-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-date-field-filter.ts new file mode 100644 index 0000000000..013de93fb2 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-date-field-filter.ts @@ -0,0 +1,238 @@ +/* eslint-disable no-continue */ +import { + ColumnType, + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; +import { DateTime } from 'luxon'; + +import buildDefaultCondition from './utils/build-default-condition'; + +function isYear(str: string): boolean { + return ( + /^\d{4}$/.test(str) && Number(str) >= 1800 && Number(str) <= new Date().getFullYear() + 100 + ); +} + +function isYearMonth(str: string): boolean { + return /^(\d{4})-(\d{1,2})$/.test(str) && !Number.isNaN(Date.parse(`${str}-01`)); +} + +function isPlainDate(str: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(str) && !Number.isNaN(Date.parse(str)); +} + +function isValidDate(str: string): boolean { + return isYear(str) || isYearMonth(str) || isPlainDate(str); +} + +function getPeriodStart(string: string): string { + if (isYear(string)) return `${string}-01-01`; + if (isYearMonth(string)) return `${string}-01`; + + return string; +} + +function pad(month: number) { + if (month < 10) { + return `0${month}`; + } + + return `${month}`; +} + +function getAfterPeriodEnd(string: string): string { + if (isYear(string)) return `${Number(string) + 1}-01-01`; + + if (isYearMonth(string)) { + const [year, month] = string.split('-').map(Number); + const endDate = new Date(year, month, 1); + + return `${endDate.getFullYear()}-${pad(endDate.getMonth() + 1)}-01`; + } + + const date = new Date(string); + date.setDate(date.getDate() + 1); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function getTranslatedDateInTimezone( + value: string, + opts: { timezone: string; columnType: ColumnType }, +) { + if (opts.columnType !== 'Date') return value; + + return DateTime.fromISO(value, { zone: opts.timezone }).toISO(); +} + +const supportedOperators: [ + string, + [Operator, (value: string) => string][], + [Operator, (value: string) => string][], +][] = [ + [ + '>', + [ + ['After', getAfterPeriodEnd], + ['Equal', getAfterPeriodEnd], + ], + [ + ['Before', getPeriodStart], + ['Equal', getPeriodStart], + ['Missing', () => undefined], + ], + ], + [ + '>=', + [ + ['After', getPeriodStart], + ['Equal', getPeriodStart], + ], + [ + ['Before', getPeriodStart], + ['Missing', () => undefined], + ], + ], + [ + '≥', + [ + ['After', getPeriodStart], + ['Equal', getPeriodStart], + ], + [ + ['Before', getPeriodStart], + ['Missing', () => undefined], + ], + ], + [ + '<', + [['Before', getPeriodStart]], + [ + ['After', getPeriodStart], + ['Equal', getPeriodStart], + ['Missing', () => undefined], + ], + ], + [ + '<=', + [['Before', getAfterPeriodEnd]], + [ + ['After', getAfterPeriodEnd], + ['Equal', getAfterPeriodEnd], + ['Missing', () => undefined], + ], + ], + [ + '≤', + [['Before', getAfterPeriodEnd]], + [ + ['After', getAfterPeriodEnd], + ['Equal', getAfterPeriodEnd], + ['Missing', () => undefined], + ], + ], +]; + +export default function buildDateFieldFilter({ + field, + filterOperators, + searchString, + isNegated, + columnType, + timezone, +}: { + field: string; + filterOperators: Set; + searchString: string; + isNegated: boolean; + columnType: ColumnType; + timezone: string; +}): ConditionTree { + if (isValidDate(searchString)) { + const start = getTranslatedDateInTimezone(getPeriodStart(searchString), { + columnType, + timezone, + }); + const afterEnd = getTranslatedDateInTimezone(getAfterPeriodEnd(searchString), { + columnType, + timezone, + }); + + if ( + !isNegated && + filterOperators.has('Equal') && + filterOperators.has('Before') && + filterOperators.has('After') + ) { + return ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'Equal', start), + new ConditionTreeLeaf(field, 'After', start), + ), + new ConditionTreeLeaf(field, 'Before', afterEnd), + ); + } + + if ( + isNegated && + filterOperators.has('Before') && + filterOperators.has('After') && + filterOperators.has('Equal') + ) { + if (filterOperators.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'Before', start), + new ConditionTreeLeaf(field, 'After', afterEnd), + new ConditionTreeLeaf(field, 'Equal', afterEnd), + new ConditionTreeLeaf(field, 'Missing'), + ); + } + + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'Before', start), + new ConditionTreeLeaf(field, 'After', afterEnd), + new ConditionTreeLeaf(field, 'Equal', afterEnd), + ); + } + + return buildDefaultCondition(isNegated); + } + + for (const [operatorPrefix, positiveOperations, negativeOperations] of supportedOperators) { + if (!searchString.startsWith(operatorPrefix)) continue; + if (!isValidDate(searchString.slice(operatorPrefix.length))) continue; + + const value = searchString.slice(operatorPrefix.length); + + if (!isValidDate(value)) continue; + + const operations = isNegated ? negativeOperations : positiveOperations; + + // If Missing is not supported, we try to build a condition tree anyway + if ( + !operations + .filter(op => op[0] !== 'Missing') + .every(operation => filterOperators.has(operation[0])) + ) { + continue; + } + + return ConditionTreeFactory.union( + ...operations + .filter(op => filterOperators.has(op[0])) + .map( + ([operator, getDate]) => + new ConditionTreeLeaf( + field, + operator, + getTranslatedDateInTimezone(getDate(value), { timezone, columnType }), + ), + ), + ); + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-array-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-array-field-filter.ts new file mode 100644 index 0000000000..bbd878f938 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-array-field-filter.ts @@ -0,0 +1,18 @@ +import { ColumnSchema, ConditionTree } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from './build-basic-array-field-filter'; +import buildDefaultCondition from './utils/build-default-condition'; +import findEnumValue from './utils/find-enum-value'; + +export default function buildEnumArrayFieldFilter( + field: string, + schema: ColumnSchema, + searchString: string, + isNegated: boolean, +): ConditionTree { + const enumValue = findEnumValue(searchString, schema); + + if (!enumValue) return buildDefaultCondition(isNegated); + + return buildBasicArrayFieldFilter(field, schema.filterOperators, enumValue, isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-field-filter.ts new file mode 100644 index 0000000000..503a417029 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-enum-field-filter.ts @@ -0,0 +1,39 @@ +import { + ColumnSchema, + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, +} from '@forestadmin/datasource-toolkit'; + +import buildDefaultCondition from './utils/build-default-condition'; +import findEnumValue from './utils/find-enum-value'; + +export default function buildEnumFieldFilter( + field: string, + schema: ColumnSchema, + searchString: string, + isNegated: boolean, +): ConditionTree { + const { filterOperators } = schema; + const searchValue = findEnumValue(searchString, schema); + + if (!searchValue) return buildDefaultCondition(isNegated); + + if (filterOperators?.has('Equal') && !isNegated) { + return new ConditionTreeLeaf(field, 'Equal', searchValue); + } + + if (filterOperators?.has('NotEqual') && filterOperators?.has('Missing') && isNegated) { + // In DBs, NULL values are not equal to anything, including NULL. + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'NotEqual', searchValue), + new ConditionTreeLeaf(field, 'Missing'), + ); + } + + if (filterOperators?.has('NotEqual') && isNegated) { + return new ConditionTreeLeaf(field, 'NotEqual', searchValue); + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-array-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-array-field-filter.ts new file mode 100644 index 0000000000..e7dba103c4 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-array-field-filter.ts @@ -0,0 +1,15 @@ +import { ConditionTree, Operator } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from './build-basic-array-field-filter'; +import buildDefaultCondition from './utils/build-default-condition'; + +export default function buildNumberArrayFieldFilter( + field: string, + filterOperators: Set | undefined, + searchString: string, + isNegated: boolean, +): ConditionTree { + if (Number.isNaN(Number(searchString))) return buildDefaultCondition(isNegated); + + return buildBasicArrayFieldFilter(field, filterOperators, Number(searchString), isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-field-filter.ts new file mode 100644 index 0000000000..aa3fb41883 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-number-field-filter.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-continue */ +import { + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; + +import buildDefaultCondition from './utils/build-default-condition'; + +const supportedOperators: [string, Operator[], Operator[]][] = [ + ['', ['Equal'], ['NotEqual', 'Missing']], + ['=', ['Equal'], ['NotEqual', 'Missing']], + ['!=', ['NotEqual'], ['Equal', 'Missing']], + ['<>', ['NotEqual'], ['Equal', 'Missing']], + ['>', ['GreaterThan'], ['LessThan', 'Equal', 'Missing']], + ['>=', ['GreaterThan', 'Equal'], ['LessThan', 'Missing']], + ['≥', ['GreaterThan', 'Equal'], ['LessThan', 'Missing']], + ['<', ['LessThan'], ['GreaterThan', 'Equal', 'Missing']], + ['<=', ['LessThan', 'Equal'], ['GreaterThan', 'Missing']], + ['≤', ['LessThan', 'Equal'], ['GreaterThan', 'Missing']], +]; + +export default function buildNumberFieldFilter( + field: string, + filterOperators: Set, + searchString: string, + isNegated: boolean, +): ConditionTree { + for (const [operatorPrefix, positiveOperators, negativeOperators] of supportedOperators) { + if (operatorPrefix && !searchString.startsWith(operatorPrefix)) continue; + if (Number.isNaN(Number(searchString.slice(operatorPrefix.length)))) continue; + + const value = Number(searchString.slice(operatorPrefix.length)); + const operators = isNegated ? negativeOperators : positiveOperators; + + // If Missing is not supported, we try to build a condition tree anyway + if (!operators.filter(op => op !== 'Missing').every(operator => filterOperators.has(operator))) + continue; + + return ConditionTreeFactory.union( + ...operators + .filter(operator => filterOperators.has(operator)) + .map( + operator => + new ConditionTreeLeaf(field, operator, operator !== 'Missing' ? value : undefined), + ), + ); + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-array-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-array-field-filter.ts new file mode 100644 index 0000000000..659410b5d9 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-array-field-filter.ts @@ -0,0 +1,12 @@ +import { ConditionTree, Operator } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from './build-basic-array-field-filter'; + +export default function buildStringArrayFieldFilter( + field: string, + filterOperators: Set | undefined, + searchString: string, + isNegated: boolean, +): ConditionTree { + return buildBasicArrayFieldFilter(field, filterOperators, searchString, isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-field-filter.ts new file mode 100644 index 0000000000..07b07e298a --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-string-field-filter.ts @@ -0,0 +1,61 @@ +/* eslint-disable no-continue */ + +import { + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; + +import buildDefaultCondition from './utils/build-default-condition'; + +const operatorsStack: [Operator[], Operator[]][] = [ + [['IContains'], ['NotIContains', 'Missing']], + [['Contains'], ['NotContains', 'Missing']], + [['Equal'], ['NotEqual', 'Missing']], +]; + +export default function buildStringFieldFilter( + field: string, + filterOperators: Set, + searchString: string, + isNegated: boolean, +): ConditionTree { + if (!searchString) { + if (filterOperators.has('Equal') && !isNegated) { + return new ConditionTreeLeaf(field, 'Equal', ''); + } + + if (filterOperators.has('NotEqual') && isNegated) { + if (filterOperators.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'NotEqual', ''), + new ConditionTreeLeaf(field, 'Missing'), + ); + } + + return new ConditionTreeLeaf(field, 'NotEqual', ''); + } + + return buildDefaultCondition(isNegated); + } + + for (const [positiveOperators, negativeOperators] of operatorsStack) { + const operators = isNegated ? negativeOperators : positiveOperators; + + const neededOperators = operators.filter( + operator => operator !== 'Missing' || filterOperators.has(operator), + ); + + if (!neededOperators.every(operator => filterOperators.has(operator))) continue; + + return ConditionTreeFactory.union( + ...neededOperators.map( + operator => + new ConditionTreeLeaf(field, operator, operator !== 'Missing' ? searchString : undefined), + ), + ); + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/build-uuid-field-filter.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/build-uuid-field-filter.ts new file mode 100644 index 0000000000..5541417303 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/build-uuid-field-filter.ts @@ -0,0 +1,35 @@ +import { + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, +} from '@forestadmin/datasource-toolkit'; +import { validate as uuidValidate } from 'uuid'; + +import buildDefaultCondition from './utils/build-default-condition'; + +export default function buildUuidFieldFilter( + field: string, + filterOperators: Set, + searchString: string, + isNegated: boolean, +): ConditionTree { + if (!uuidValidate(searchString)) return buildDefaultCondition(isNegated); + + if (!isNegated && filterOperators?.has('Equal')) { + return new ConditionTreeLeaf(field, 'Equal', searchString); + } + + if (isNegated && filterOperators?.has('NotEqual') && filterOperators?.has('Missing')) { + return ConditionTreeFactory.union( + new ConditionTreeLeaf(field, 'NotEqual', searchString), + new ConditionTreeLeaf(field, 'Missing'), + ); + } + + if (isNegated && filterOperators?.has('NotEqual')) { + return new ConditionTreeLeaf(field, 'NotEqual', searchString); + } + + return buildDefaultCondition(isNegated); +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/index.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/index.ts new file mode 100644 index 0000000000..e5541ade4c --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/index.ts @@ -0,0 +1,80 @@ +import { + Caller, + ColumnSchema, + ColumnType, + ConditionTree, + ConditionTreeFactory, + ConditionTreeLeaf, + PrimitiveTypes, +} from '@forestadmin/datasource-toolkit'; + +import buildBooleanFieldFilter from './build-boolean-field-filter'; +import buildDateFieldFilter from './build-date-field-filter'; +import buildEnumArrayFieldFilter from './build-enum-array-field-filter'; +import buildEnumFieldFilter from './build-enum-field-filter'; +import buildNumberArrayFieldFilter from './build-number-array-field-filter'; +import buildNumberFieldFilter from './build-number-field-filter'; +import buildStringArrayFieldFilter from './build-string-array-field-filter'; +import buildStringFieldFilter from './build-string-field-filter'; +import buildUuidFieldFilter from './build-uuid-field-filter'; + +function generateDefaultCondition(isNegated: boolean): ConditionTree { + return isNegated ? ConditionTreeFactory.MatchAll : ConditionTreeFactory.MatchNone; +} + +function isArrayOf(columnType: ColumnType, testedType: PrimitiveTypes): boolean { + return Array.isArray(columnType) && columnType[0] === testedType; +} + +export default function buildFieldFilter( + caller: Caller, + field: string, + schema: ColumnSchema, + searchString: string, + isNegated: boolean, +): ConditionTree { + const { columnType, filterOperators } = schema; + + if (searchString === 'NULL') { + if (!isNegated && filterOperators?.has('Missing')) { + return new ConditionTreeLeaf(field, 'Missing'); + } + + if (isNegated && filterOperators?.has('Present')) { + return new ConditionTreeLeaf(field, 'Present'); + } + + return generateDefaultCondition(isNegated); + } + + switch (true) { + case columnType === 'Number': + return buildNumberFieldFilter(field, filterOperators, searchString, isNegated); + case isArrayOf(columnType, 'Number'): + return buildNumberArrayFieldFilter(field, filterOperators, searchString, isNegated); + case columnType === 'Enum': + return buildEnumFieldFilter(field, schema, searchString, isNegated); + case isArrayOf(columnType, 'Enum'): + return buildEnumArrayFieldFilter(field, schema, searchString, isNegated); + case columnType === 'String': + return buildStringFieldFilter(field, filterOperators, searchString, isNegated); + case isArrayOf(columnType, 'String'): + return buildStringArrayFieldFilter(field, filterOperators, searchString, isNegated); + case columnType === 'Boolean': + return buildBooleanFieldFilter(field, filterOperators, searchString, isNegated); + case columnType === 'Uuid': + return buildUuidFieldFilter(field, filterOperators, searchString, isNegated); + case columnType === 'Date': + case columnType === 'Dateonly': + return buildDateFieldFilter({ + field, + filterOperators, + searchString, + isNegated, + columnType, + timezone: caller.timezone, + }); + default: + return generateDefaultCondition(isNegated); + } +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/utils/build-default-condition.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/utils/build-default-condition.ts new file mode 100644 index 0000000000..b530df1f39 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/utils/build-default-condition.ts @@ -0,0 +1,5 @@ +import { ConditionTree, ConditionTreeFactory } from '@forestadmin/datasource-toolkit'; + +export default function buildDefaultCondition(isNegated: boolean): ConditionTree { + return isNegated ? ConditionTreeFactory.MatchAll : ConditionTreeFactory.MatchNone; +} diff --git a/packages/datasource-customizer/src/decorators/search/filter-builder/utils/find-enum-value.ts b/packages/datasource-customizer/src/decorators/search/filter-builder/utils/find-enum-value.ts new file mode 100644 index 0000000000..19f47286a7 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/filter-builder/utils/find-enum-value.ts @@ -0,0 +1,12 @@ +import { ColumnSchema } from '@forestadmin/datasource-toolkit'; + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +export default function findEnumValue(value: string, schema: ColumnSchema): string | null { + const haystack = schema.enumValues || []; + const needle = normalize(value || ''); + + return haystack?.find(v => normalize(v) === needle) || null; +} diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/Query.interp b/packages/datasource-customizer/src/decorators/search/generated-parser/Query.interp new file mode 100644 index 0000000000..b33180cd01 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/Query.interp @@ -0,0 +1,44 @@ +token literal names: +null +':' +null +null +'OR' +'AND' +null +null +'-' +null +null +null + +token symbolic names: +null +null +PARENS_OPEN +PARENS_CLOSE +OR +AND +SINGLE_QUOTED +DOUBLE_QUOTED +NEGATION +TOKEN +SEPARATOR +SPACING + +rule names: +query +parenthesized +or +and +queryToken +quoted +negated +propertyMatching +name +value +word + + +atn: +[4, 1, 11, 98, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 1, 0, 1, 0, 1, 0, 1, 0, 3, 0, 27, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 3, 1, 34, 8, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 3, 2, 41, 8, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 3, 2, 49, 8, 2, 4, 2, 51, 8, 2, 11, 2, 12, 2, 52, 1, 3, 1, 3, 3, 3, 57, 8, 3, 1, 3, 1, 3, 1, 3, 3, 3, 62, 8, 3, 1, 3, 1, 3, 3, 3, 66, 8, 3, 4, 3, 68, 8, 3, 11, 3, 12, 3, 69, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 76, 8, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 84, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 3, 9, 94, 8, 9, 1, 10, 1, 10, 1, 10, 0, 0, 11, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 0, 1, 1, 0, 6, 7, 105, 0, 26, 1, 0, 0, 0, 2, 30, 1, 0, 0, 0, 4, 40, 1, 0, 0, 0, 6, 56, 1, 0, 0, 0, 8, 75, 1, 0, 0, 0, 10, 77, 1, 0, 0, 0, 12, 79, 1, 0, 0, 0, 14, 85, 1, 0, 0, 0, 16, 89, 1, 0, 0, 0, 18, 93, 1, 0, 0, 0, 20, 95, 1, 0, 0, 0, 22, 27, 3, 6, 3, 0, 23, 27, 3, 4, 2, 0, 24, 27, 3, 8, 4, 0, 25, 27, 3, 2, 1, 0, 26, 22, 1, 0, 0, 0, 26, 23, 1, 0, 0, 0, 26, 24, 1, 0, 0, 0, 26, 25, 1, 0, 0, 0, 27, 28, 1, 0, 0, 0, 28, 29, 5, 0, 0, 1, 29, 1, 1, 0, 0, 0, 30, 33, 5, 2, 0, 0, 31, 34, 3, 4, 2, 0, 32, 34, 3, 6, 3, 0, 33, 31, 1, 0, 0, 0, 33, 32, 1, 0, 0, 0, 34, 35, 1, 0, 0, 0, 35, 36, 5, 3, 0, 0, 36, 3, 1, 0, 0, 0, 37, 41, 3, 6, 3, 0, 38, 41, 3, 8, 4, 0, 39, 41, 3, 2, 1, 0, 40, 37, 1, 0, 0, 0, 40, 38, 1, 0, 0, 0, 40, 39, 1, 0, 0, 0, 41, 50, 1, 0, 0, 0, 42, 43, 5, 10, 0, 0, 43, 44, 5, 4, 0, 0, 44, 48, 5, 10, 0, 0, 45, 49, 3, 6, 3, 0, 46, 49, 3, 8, 4, 0, 47, 49, 3, 2, 1, 0, 48, 45, 1, 0, 0, 0, 48, 46, 1, 0, 0, 0, 48, 47, 1, 0, 0, 0, 49, 51, 1, 0, 0, 0, 50, 42, 1, 0, 0, 0, 51, 52, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 52, 53, 1, 0, 0, 0, 53, 5, 1, 0, 0, 0, 54, 57, 3, 8, 4, 0, 55, 57, 3, 2, 1, 0, 56, 54, 1, 0, 0, 0, 56, 55, 1, 0, 0, 0, 57, 67, 1, 0, 0, 0, 58, 61, 5, 10, 0, 0, 59, 60, 5, 5, 0, 0, 60, 62, 5, 10, 0, 0, 61, 59, 1, 0, 0, 0, 61, 62, 1, 0, 0, 0, 62, 65, 1, 0, 0, 0, 63, 66, 3, 8, 4, 0, 64, 66, 3, 2, 1, 0, 65, 63, 1, 0, 0, 0, 65, 64, 1, 0, 0, 0, 66, 68, 1, 0, 0, 0, 67, 58, 1, 0, 0, 0, 68, 69, 1, 0, 0, 0, 69, 67, 1, 0, 0, 0, 69, 70, 1, 0, 0, 0, 70, 7, 1, 0, 0, 0, 71, 76, 3, 10, 5, 0, 72, 76, 3, 12, 6, 0, 73, 76, 3, 14, 7, 0, 74, 76, 3, 20, 10, 0, 75, 71, 1, 0, 0, 0, 75, 72, 1, 0, 0, 0, 75, 73, 1, 0, 0, 0, 75, 74, 1, 0, 0, 0, 76, 9, 1, 0, 0, 0, 77, 78, 7, 0, 0, 0, 78, 11, 1, 0, 0, 0, 79, 83, 5, 8, 0, 0, 80, 84, 3, 20, 10, 0, 81, 84, 3, 10, 5, 0, 82, 84, 3, 14, 7, 0, 83, 80, 1, 0, 0, 0, 83, 81, 1, 0, 0, 0, 83, 82, 1, 0, 0, 0, 84, 13, 1, 0, 0, 0, 85, 86, 3, 16, 8, 0, 86, 87, 5, 1, 0, 0, 87, 88, 3, 18, 9, 0, 88, 15, 1, 0, 0, 0, 89, 90, 5, 9, 0, 0, 90, 17, 1, 0, 0, 0, 91, 94, 3, 20, 10, 0, 92, 94, 3, 10, 5, 0, 93, 91, 1, 0, 0, 0, 93, 92, 1, 0, 0, 0, 94, 19, 1, 0, 0, 0, 95, 96, 5, 9, 0, 0, 96, 21, 1, 0, 0, 0, 12, 26, 33, 40, 48, 52, 56, 61, 65, 69, 75, 83, 93] \ No newline at end of file diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/Query.tokens b/packages/datasource-customizer/src/decorators/search/generated-parser/Query.tokens new file mode 100644 index 0000000000..9f60213517 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/Query.tokens @@ -0,0 +1,15 @@ +T__0=1 +PARENS_OPEN=2 +PARENS_CLOSE=3 +OR=4 +AND=5 +SINGLE_QUOTED=6 +DOUBLE_QUOTED=7 +NEGATION=8 +TOKEN=9 +SEPARATOR=10 +SPACING=11 +':'=1 +'OR'=4 +'AND'=5 +'-'=8 diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.interp b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.interp new file mode 100644 index 0000000000..641b65a81e --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.interp @@ -0,0 +1,55 @@ +token literal names: +null +':' +null +null +'OR' +'AND' +null +null +'-' +null +null +null + +token symbolic names: +null +null +PARENS_OPEN +PARENS_CLOSE +OR +AND +SINGLE_QUOTED +DOUBLE_QUOTED +NEGATION +TOKEN +SEPARATOR +SPACING + +rule names: +T__0 +PARENS_OPEN +PARENS_CLOSE +OR +AND +SINGLE_QUOTED +SINGLE_QUOTED_CONTENT +DOUBLE_QUOTED +DOUBLE_QUOTED_CONTENT +NEGATION +TOKEN +ONE_CHAR_TOKEN +TWO_CHARS_TOKEN +MULTIPLE_CHARS_TOKEN +SEPARATOR +SPACING + +channel names: +DEFAULT_TOKEN_CHANNEL +HIDDEN + +mode names: +DEFAULT_MODE + +atn: +[4, 0, 11, 115, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 1, 0, 1, 0, 1, 1, 1, 1, 5, 1, 38, 8, 1, 10, 1, 12, 1, 41, 9, 1, 1, 2, 5, 2, 44, 8, 2, 10, 2, 12, 2, 47, 9, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 64, 8, 5, 1, 6, 5, 6, 67, 8, 6, 10, 6, 12, 6, 70, 9, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 3, 7, 78, 8, 7, 1, 8, 5, 8, 81, 8, 8, 10, 8, 12, 8, 84, 9, 8, 1, 9, 1, 9, 1, 10, 1, 10, 1, 10, 3, 10, 91, 8, 10, 1, 11, 1, 11, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 4, 13, 100, 8, 13, 11, 13, 12, 13, 101, 1, 13, 1, 13, 1, 14, 4, 14, 107, 8, 14, 11, 14, 12, 14, 108, 1, 14, 3, 14, 112, 8, 14, 1, 15, 1, 15, 0, 0, 16, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 0, 15, 7, 17, 0, 19, 8, 21, 9, 23, 0, 25, 0, 27, 0, 29, 10, 31, 11, 1, 0, 7, 1, 0, 39, 39, 1, 0, 34, 34, 6, 0, 10, 10, 13, 13, 32, 32, 40, 41, 45, 45, 58, 58, 6, 0, 10, 10, 13, 13, 32, 32, 40, 40, 45, 45, 58, 58, 5, 0, 10, 10, 13, 13, 32, 32, 41, 41, 58, 58, 4, 0, 10, 10, 13, 13, 32, 32, 58, 58, 3, 0, 10, 10, 13, 13, 32, 32, 120, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 1, 33, 1, 0, 0, 0, 3, 35, 1, 0, 0, 0, 5, 45, 1, 0, 0, 0, 7, 50, 1, 0, 0, 0, 9, 53, 1, 0, 0, 0, 11, 63, 1, 0, 0, 0, 13, 68, 1, 0, 0, 0, 15, 77, 1, 0, 0, 0, 17, 82, 1, 0, 0, 0, 19, 85, 1, 0, 0, 0, 21, 90, 1, 0, 0, 0, 23, 92, 1, 0, 0, 0, 25, 94, 1, 0, 0, 0, 27, 97, 1, 0, 0, 0, 29, 111, 1, 0, 0, 0, 31, 113, 1, 0, 0, 0, 33, 34, 5, 58, 0, 0, 34, 2, 1, 0, 0, 0, 35, 39, 5, 40, 0, 0, 36, 38, 5, 32, 0, 0, 37, 36, 1, 0, 0, 0, 38, 41, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 39, 40, 1, 0, 0, 0, 40, 4, 1, 0, 0, 0, 41, 39, 1, 0, 0, 0, 42, 44, 5, 32, 0, 0, 43, 42, 1, 0, 0, 0, 44, 47, 1, 0, 0, 0, 45, 43, 1, 0, 0, 0, 45, 46, 1, 0, 0, 0, 46, 48, 1, 0, 0, 0, 47, 45, 1, 0, 0, 0, 48, 49, 5, 41, 0, 0, 49, 6, 1, 0, 0, 0, 50, 51, 5, 79, 0, 0, 51, 52, 5, 82, 0, 0, 52, 8, 1, 0, 0, 0, 53, 54, 5, 65, 0, 0, 54, 55, 5, 78, 0, 0, 55, 56, 5, 68, 0, 0, 56, 10, 1, 0, 0, 0, 57, 58, 5, 39, 0, 0, 58, 59, 3, 13, 6, 0, 59, 60, 5, 39, 0, 0, 60, 64, 1, 0, 0, 0, 61, 62, 5, 39, 0, 0, 62, 64, 5, 39, 0, 0, 63, 57, 1, 0, 0, 0, 63, 61, 1, 0, 0, 0, 64, 12, 1, 0, 0, 0, 65, 67, 8, 0, 0, 0, 66, 65, 1, 0, 0, 0, 67, 70, 1, 0, 0, 0, 68, 66, 1, 0, 0, 0, 68, 69, 1, 0, 0, 0, 69, 14, 1, 0, 0, 0, 70, 68, 1, 0, 0, 0, 71, 72, 5, 34, 0, 0, 72, 73, 3, 17, 8, 0, 73, 74, 5, 34, 0, 0, 74, 78, 1, 0, 0, 0, 75, 76, 5, 34, 0, 0, 76, 78, 5, 34, 0, 0, 77, 71, 1, 0, 0, 0, 77, 75, 1, 0, 0, 0, 78, 16, 1, 0, 0, 0, 79, 81, 8, 1, 0, 0, 80, 79, 1, 0, 0, 0, 81, 84, 1, 0, 0, 0, 82, 80, 1, 0, 0, 0, 82, 83, 1, 0, 0, 0, 83, 18, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 85, 86, 5, 45, 0, 0, 86, 20, 1, 0, 0, 0, 87, 91, 3, 23, 11, 0, 88, 91, 3, 25, 12, 0, 89, 91, 3, 27, 13, 0, 90, 87, 1, 0, 0, 0, 90, 88, 1, 0, 0, 0, 90, 89, 1, 0, 0, 0, 91, 22, 1, 0, 0, 0, 92, 93, 8, 2, 0, 0, 93, 24, 1, 0, 0, 0, 94, 95, 8, 3, 0, 0, 95, 96, 8, 4, 0, 0, 96, 26, 1, 0, 0, 0, 97, 99, 8, 3, 0, 0, 98, 100, 8, 5, 0, 0, 99, 98, 1, 0, 0, 0, 100, 101, 1, 0, 0, 0, 101, 99, 1, 0, 0, 0, 101, 102, 1, 0, 0, 0, 102, 103, 1, 0, 0, 0, 103, 104, 8, 4, 0, 0, 104, 28, 1, 0, 0, 0, 105, 107, 3, 31, 15, 0, 106, 105, 1, 0, 0, 0, 107, 108, 1, 0, 0, 0, 108, 106, 1, 0, 0, 0, 108, 109, 1, 0, 0, 0, 109, 112, 1, 0, 0, 0, 110, 112, 5, 0, 0, 1, 111, 106, 1, 0, 0, 0, 111, 110, 1, 0, 0, 0, 112, 30, 1, 0, 0, 0, 113, 114, 7, 6, 0, 0, 114, 32, 1, 0, 0, 0, 11, 0, 39, 45, 63, 68, 77, 82, 90, 101, 108, 111, 0] \ No newline at end of file diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.tokens b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.tokens new file mode 100644 index 0000000000..9f60213517 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.tokens @@ -0,0 +1,15 @@ +T__0=1 +PARENS_OPEN=2 +PARENS_CLOSE=3 +OR=4 +AND=5 +SINGLE_QUOTED=6 +DOUBLE_QUOTED=7 +NEGATION=8 +TOKEN=9 +SEPARATOR=10 +SPACING=11 +':'=1 +'OR'=4 +'AND'=5 +'-'=8 diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.ts b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.ts new file mode 100644 index 0000000000..1e0dca401b --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryLexer.ts @@ -0,0 +1,119 @@ +// Generated from src/decorators/search/Query.g4 by ANTLR 4.13.1 +// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols +import { + ATN, + ATNDeserializer, + CharStream, + DecisionState, DFA, + Lexer, + LexerATNSimulator, + RuleContext, + PredictionContextCache, + Token +} from "antlr4"; +export default class QueryLexer extends Lexer { + public static readonly T__0 = 1; + public static readonly PARENS_OPEN = 2; + public static readonly PARENS_CLOSE = 3; + public static readonly OR = 4; + public static readonly AND = 5; + public static readonly SINGLE_QUOTED = 6; + public static readonly DOUBLE_QUOTED = 7; + public static readonly NEGATION = 8; + public static readonly TOKEN = 9; + public static readonly SEPARATOR = 10; + public static readonly SPACING = 11; + public static readonly EOF = Token.EOF; + + public static readonly channelNames: string[] = [ "DEFAULT_TOKEN_CHANNEL", "HIDDEN" ]; + public static readonly literalNames: (string | null)[] = [ null, "':'", + null, null, + "'OR'", "'AND'", + null, null, + "'-'" ]; + public static readonly symbolicNames: (string | null)[] = [ null, null, + "PARENS_OPEN", + "PARENS_CLOSE", + "OR", "AND", + "SINGLE_QUOTED", + "DOUBLE_QUOTED", + "NEGATION", + "TOKEN", "SEPARATOR", + "SPACING" ]; + public static readonly modeNames: string[] = [ "DEFAULT_MODE", ]; + + public static readonly ruleNames: string[] = [ + "T__0", "PARENS_OPEN", "PARENS_CLOSE", "OR", "AND", "SINGLE_QUOTED", "SINGLE_QUOTED_CONTENT", + "DOUBLE_QUOTED", "DOUBLE_QUOTED_CONTENT", "NEGATION", "TOKEN", "ONE_CHAR_TOKEN", + "TWO_CHARS_TOKEN", "MULTIPLE_CHARS_TOKEN", "SEPARATOR", "SPACING", + ]; + + + constructor(input: CharStream) { + super(input); + this._interp = new LexerATNSimulator(this, QueryLexer._ATN, QueryLexer.DecisionsToDFA, new PredictionContextCache()); + } + + public get grammarFileName(): string { return "Query.g4"; } + + public get literalNames(): (string | null)[] { return QueryLexer.literalNames; } + public get symbolicNames(): (string | null)[] { return QueryLexer.symbolicNames; } + public get ruleNames(): string[] { return QueryLexer.ruleNames; } + + public get serializedATN(): number[] { return QueryLexer._serializedATN; } + + public get channelNames(): string[] { return QueryLexer.channelNames; } + + public get modeNames(): string[] { return QueryLexer.modeNames; } + + public static readonly _serializedATN: number[] = [4,0,11,115,6,-1,2,0, + 7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9, + 7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,1,0,1,0, + 1,1,1,1,5,1,38,8,1,10,1,12,1,41,9,1,1,2,5,2,44,8,2,10,2,12,2,47,9,2,1,2, + 1,2,1,3,1,3,1,3,1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,5,3,5,64,8,5,1,6, + 5,6,67,8,6,10,6,12,6,70,9,6,1,7,1,7,1,7,1,7,1,7,1,7,3,7,78,8,7,1,8,5,8, + 81,8,8,10,8,12,8,84,9,8,1,9,1,9,1,10,1,10,1,10,3,10,91,8,10,1,11,1,11,1, + 12,1,12,1,12,1,13,1,13,4,13,100,8,13,11,13,12,13,101,1,13,1,13,1,14,4,14, + 107,8,14,11,14,12,14,108,1,14,3,14,112,8,14,1,15,1,15,0,0,16,1,1,3,2,5, + 3,7,4,9,5,11,6,13,0,15,7,17,0,19,8,21,9,23,0,25,0,27,0,29,10,31,11,1,0, + 7,1,0,39,39,1,0,34,34,6,0,10,10,13,13,32,32,40,41,45,45,58,58,6,0,10,10, + 13,13,32,32,40,40,45,45,58,58,5,0,10,10,13,13,32,32,41,41,58,58,4,0,10, + 10,13,13,32,32,58,58,3,0,10,10,13,13,32,32,120,0,1,1,0,0,0,0,3,1,0,0,0, + 0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,15,1,0,0,0,0,19,1,0, + 0,0,0,21,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,1,33,1,0,0,0,3,35,1,0,0,0,5, + 45,1,0,0,0,7,50,1,0,0,0,9,53,1,0,0,0,11,63,1,0,0,0,13,68,1,0,0,0,15,77, + 1,0,0,0,17,82,1,0,0,0,19,85,1,0,0,0,21,90,1,0,0,0,23,92,1,0,0,0,25,94,1, + 0,0,0,27,97,1,0,0,0,29,111,1,0,0,0,31,113,1,0,0,0,33,34,5,58,0,0,34,2,1, + 0,0,0,35,39,5,40,0,0,36,38,5,32,0,0,37,36,1,0,0,0,38,41,1,0,0,0,39,37,1, + 0,0,0,39,40,1,0,0,0,40,4,1,0,0,0,41,39,1,0,0,0,42,44,5,32,0,0,43,42,1,0, + 0,0,44,47,1,0,0,0,45,43,1,0,0,0,45,46,1,0,0,0,46,48,1,0,0,0,47,45,1,0,0, + 0,48,49,5,41,0,0,49,6,1,0,0,0,50,51,5,79,0,0,51,52,5,82,0,0,52,8,1,0,0, + 0,53,54,5,65,0,0,54,55,5,78,0,0,55,56,5,68,0,0,56,10,1,0,0,0,57,58,5,39, + 0,0,58,59,3,13,6,0,59,60,5,39,0,0,60,64,1,0,0,0,61,62,5,39,0,0,62,64,5, + 39,0,0,63,57,1,0,0,0,63,61,1,0,0,0,64,12,1,0,0,0,65,67,8,0,0,0,66,65,1, + 0,0,0,67,70,1,0,0,0,68,66,1,0,0,0,68,69,1,0,0,0,69,14,1,0,0,0,70,68,1,0, + 0,0,71,72,5,34,0,0,72,73,3,17,8,0,73,74,5,34,0,0,74,78,1,0,0,0,75,76,5, + 34,0,0,76,78,5,34,0,0,77,71,1,0,0,0,77,75,1,0,0,0,78,16,1,0,0,0,79,81,8, + 1,0,0,80,79,1,0,0,0,81,84,1,0,0,0,82,80,1,0,0,0,82,83,1,0,0,0,83,18,1,0, + 0,0,84,82,1,0,0,0,85,86,5,45,0,0,86,20,1,0,0,0,87,91,3,23,11,0,88,91,3, + 25,12,0,89,91,3,27,13,0,90,87,1,0,0,0,90,88,1,0,0,0,90,89,1,0,0,0,91,22, + 1,0,0,0,92,93,8,2,0,0,93,24,1,0,0,0,94,95,8,3,0,0,95,96,8,4,0,0,96,26,1, + 0,0,0,97,99,8,3,0,0,98,100,8,5,0,0,99,98,1,0,0,0,100,101,1,0,0,0,101,99, + 1,0,0,0,101,102,1,0,0,0,102,103,1,0,0,0,103,104,8,4,0,0,104,28,1,0,0,0, + 105,107,3,31,15,0,106,105,1,0,0,0,107,108,1,0,0,0,108,106,1,0,0,0,108,109, + 1,0,0,0,109,112,1,0,0,0,110,112,5,0,0,1,111,106,1,0,0,0,111,110,1,0,0,0, + 112,30,1,0,0,0,113,114,7,6,0,0,114,32,1,0,0,0,11,0,39,45,63,68,77,82,90, + 101,108,111,0]; + + private static __ATN: ATN; + public static get _ATN(): ATN { + if (!QueryLexer.__ATN) { + QueryLexer.__ATN = new ATNDeserializer().deserialize(QueryLexer._serializedATN); + } + + return QueryLexer.__ATN; + } + + + static DecisionsToDFA = QueryLexer._ATN.decisionToState.map( (ds: DecisionState, index: number) => new DFA(ds, index) ); +} \ No newline at end of file diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/QueryListener.ts b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryListener.ts new file mode 100644 index 0000000000..f814f1f542 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryListener.ts @@ -0,0 +1,135 @@ +// Generated from src/decorators/search/Query.g4 by ANTLR 4.13.1 + +import {ParseTreeListener} from "antlr4"; + + +import { QueryContext } from "./QueryParser"; +import { ParenthesizedContext } from "./QueryParser"; +import { OrContext } from "./QueryParser"; +import { AndContext } from "./QueryParser"; +import { QueryTokenContext } from "./QueryParser"; +import { QuotedContext } from "./QueryParser"; +import { NegatedContext } from "./QueryParser"; +import { PropertyMatchingContext } from "./QueryParser"; +import { NameContext } from "./QueryParser"; +import { ValueContext } from "./QueryParser"; +import { WordContext } from "./QueryParser"; + + +/** + * This interface defines a complete listener for a parse tree produced by + * `QueryParser`. + */ +export default class QueryListener extends ParseTreeListener { + /** + * Enter a parse tree produced by `QueryParser.query`. + * @param ctx the parse tree + */ + enterQuery?: (ctx: QueryContext) => void; + /** + * Exit a parse tree produced by `QueryParser.query`. + * @param ctx the parse tree + */ + exitQuery?: (ctx: QueryContext) => void; + /** + * Enter a parse tree produced by `QueryParser.parenthesized`. + * @param ctx the parse tree + */ + enterParenthesized?: (ctx: ParenthesizedContext) => void; + /** + * Exit a parse tree produced by `QueryParser.parenthesized`. + * @param ctx the parse tree + */ + exitParenthesized?: (ctx: ParenthesizedContext) => void; + /** + * Enter a parse tree produced by `QueryParser.or`. + * @param ctx the parse tree + */ + enterOr?: (ctx: OrContext) => void; + /** + * Exit a parse tree produced by `QueryParser.or`. + * @param ctx the parse tree + */ + exitOr?: (ctx: OrContext) => void; + /** + * Enter a parse tree produced by `QueryParser.and`. + * @param ctx the parse tree + */ + enterAnd?: (ctx: AndContext) => void; + /** + * Exit a parse tree produced by `QueryParser.and`. + * @param ctx the parse tree + */ + exitAnd?: (ctx: AndContext) => void; + /** + * Enter a parse tree produced by `QueryParser.queryToken`. + * @param ctx the parse tree + */ + enterQueryToken?: (ctx: QueryTokenContext) => void; + /** + * Exit a parse tree produced by `QueryParser.queryToken`. + * @param ctx the parse tree + */ + exitQueryToken?: (ctx: QueryTokenContext) => void; + /** + * Enter a parse tree produced by `QueryParser.quoted`. + * @param ctx the parse tree + */ + enterQuoted?: (ctx: QuotedContext) => void; + /** + * Exit a parse tree produced by `QueryParser.quoted`. + * @param ctx the parse tree + */ + exitQuoted?: (ctx: QuotedContext) => void; + /** + * Enter a parse tree produced by `QueryParser.negated`. + * @param ctx the parse tree + */ + enterNegated?: (ctx: NegatedContext) => void; + /** + * Exit a parse tree produced by `QueryParser.negated`. + * @param ctx the parse tree + */ + exitNegated?: (ctx: NegatedContext) => void; + /** + * Enter a parse tree produced by `QueryParser.propertyMatching`. + * @param ctx the parse tree + */ + enterPropertyMatching?: (ctx: PropertyMatchingContext) => void; + /** + * Exit a parse tree produced by `QueryParser.propertyMatching`. + * @param ctx the parse tree + */ + exitPropertyMatching?: (ctx: PropertyMatchingContext) => void; + /** + * Enter a parse tree produced by `QueryParser.name`. + * @param ctx the parse tree + */ + enterName?: (ctx: NameContext) => void; + /** + * Exit a parse tree produced by `QueryParser.name`. + * @param ctx the parse tree + */ + exitName?: (ctx: NameContext) => void; + /** + * Enter a parse tree produced by `QueryParser.value`. + * @param ctx the parse tree + */ + enterValue?: (ctx: ValueContext) => void; + /** + * Exit a parse tree produced by `QueryParser.value`. + * @param ctx the parse tree + */ + exitValue?: (ctx: ValueContext) => void; + /** + * Enter a parse tree produced by `QueryParser.word`. + * @param ctx the parse tree + */ + enterWord?: (ctx: WordContext) => void; + /** + * Exit a parse tree produced by `QueryParser.word`. + * @param ctx the parse tree + */ + exitWord?: (ctx: WordContext) => void; +} + diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/QueryParser.ts b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryParser.ts new file mode 100644 index 0000000000..3eb470ea4e --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/QueryParser.ts @@ -0,0 +1,1035 @@ +// Generated from src/decorators/search/Query.g4 by ANTLR 4.13.1 +// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols + +import { + ATN, + ATNDeserializer, + DecisionState, + DFA, + FailedPredicateException, + RecognitionException, + NoViableAltException, + BailErrorStrategy, + Parser, + ParserATNSimulator, + RuleContext, + ParserRuleContext, + PredictionMode, + PredictionContextCache, + TerminalNode, + RuleNode, + Token, + TokenStream, + Interval, + IntervalSet, +} from 'antlr4'; +import QueryListener from './QueryListener.js'; +// for running tests with parameters, TODO: discuss strategy for typed parameters in CI +// eslint-disable-next-line no-unused-vars +type int = number; + +export default class QueryParser extends Parser { + public static readonly T__0 = 1; + public static readonly PARENS_OPEN = 2; + public static readonly PARENS_CLOSE = 3; + public static readonly OR = 4; + public static readonly AND = 5; + public static readonly SINGLE_QUOTED = 6; + public static readonly DOUBLE_QUOTED = 7; + public static readonly NEGATION = 8; + public static readonly TOKEN = 9; + public static readonly SEPARATOR = 10; + public static readonly SPACING = 11; + public static override readonly EOF = Token.EOF; + public static readonly RULE_query = 0; + public static readonly RULE_parenthesized = 1; + public static readonly RULE_or = 2; + public static readonly RULE_and = 3; + public static readonly RULE_queryToken = 4; + public static readonly RULE_quoted = 5; + public static readonly RULE_negated = 6; + public static readonly RULE_propertyMatching = 7; + public static readonly RULE_name = 8; + public static readonly RULE_value = 9; + public static readonly RULE_word = 10; + public static readonly literalNames: (string | null)[] = [ + null, + "':'", + null, + null, + "'OR'", + "'AND'", + null, + null, + "'-'", + ]; + public static readonly symbolicNames: (string | null)[] = [ + null, + null, + 'PARENS_OPEN', + 'PARENS_CLOSE', + 'OR', + 'AND', + 'SINGLE_QUOTED', + 'DOUBLE_QUOTED', + 'NEGATION', + 'TOKEN', + 'SEPARATOR', + 'SPACING', + ]; + // tslint:disable:no-trailing-whitespace + public static readonly ruleNames: string[] = [ + 'query', + 'parenthesized', + 'or', + 'and', + 'queryToken', + 'quoted', + 'negated', + 'propertyMatching', + 'name', + 'value', + 'word', + ]; + public get grammarFileName(): string { + return 'Query.g4'; + } + public get literalNames(): (string | null)[] { + return QueryParser.literalNames; + } + public get symbolicNames(): (string | null)[] { + return QueryParser.symbolicNames; + } + public get ruleNames(): string[] { + return QueryParser.ruleNames; + } + public get serializedATN(): number[] { + return QueryParser._serializedATN; + } + + protected createFailedPredicateException( + predicate?: string, + message?: string, + ): FailedPredicateException { + return new FailedPredicateException(this, predicate, message); + } + + constructor(input: TokenStream) { + super(input); + this._interp = new ParserATNSimulator( + this, + QueryParser._ATN, + QueryParser.DecisionsToDFA, + new PredictionContextCache(), + ); + } + // @RuleVersion(0) + public query(): QueryContext { + let localctx: QueryContext = new QueryContext(this, this._ctx, this.state); + this.enterRule(localctx, 0, QueryParser.RULE_query); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 26; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 0, this._ctx)) { + case 1: + { + this.state = 22; + this.and(); + } + break; + case 2: + { + this.state = 23; + this.or(); + } + break; + case 3: + { + this.state = 24; + this.queryToken(); + } + break; + case 4: + { + this.state = 25; + this.parenthesized(); + } + break; + } + this.state = 28; + this.match(QueryParser.EOF); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public parenthesized(): ParenthesizedContext { + let localctx: ParenthesizedContext = new ParenthesizedContext(this, this._ctx, this.state); + this.enterRule(localctx, 2, QueryParser.RULE_parenthesized); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 30; + this.match(QueryParser.PARENS_OPEN); + this.state = 33; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 1, this._ctx)) { + case 1: + { + this.state = 31; + this.or(); + } + break; + case 2: + { + this.state = 32; + this.and(); + } + break; + } + this.state = 35; + this.match(QueryParser.PARENS_CLOSE); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public or(): OrContext { + let localctx: OrContext = new OrContext(this, this._ctx, this.state); + this.enterRule(localctx, 4, QueryParser.RULE_or); + let _la: number; + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 40; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 2, this._ctx)) { + case 1: + { + this.state = 37; + this.and(); + } + break; + case 2: + { + this.state = 38; + this.queryToken(); + } + break; + case 3: + { + this.state = 39; + this.parenthesized(); + } + break; + } + this.state = 50; + this._errHandler.sync(this); + _la = this._input.LA(1); + do { + { + { + this.state = 42; + this.match(QueryParser.SEPARATOR); + this.state = 43; + this.match(QueryParser.OR); + this.state = 44; + this.match(QueryParser.SEPARATOR); + this.state = 48; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 3, this._ctx)) { + case 1: + { + this.state = 45; + this.and(); + } + break; + case 2: + { + this.state = 46; + this.queryToken(); + } + break; + case 3: + { + this.state = 47; + this.parenthesized(); + } + break; + } + } + } + this.state = 52; + this._errHandler.sync(this); + _la = this._input.LA(1); + } while (_la === 10); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public and(): AndContext { + let localctx: AndContext = new AndContext(this, this._ctx, this.state); + this.enterRule(localctx, 6, QueryParser.RULE_and); + let _la: number; + try { + let _alt: number; + this.enterOuterAlt(localctx, 1); + { + this.state = 56; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case 6: + case 7: + case 8: + case 9: + { + this.state = 54; + this.queryToken(); + } + break; + case 2: + { + this.state = 55; + this.parenthesized(); + } + break; + default: + throw new NoViableAltException(this); + } + this.state = 67; + this._errHandler.sync(this); + _alt = 1; + do { + switch (_alt) { + case 1: + { + { + this.state = 58; + this.match(QueryParser.SEPARATOR); + this.state = 61; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === 5) { + { + this.state = 59; + this.match(QueryParser.AND); + this.state = 60; + this.match(QueryParser.SEPARATOR); + } + } + + this.state = 65; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case 6: + case 7: + case 8: + case 9: + { + this.state = 63; + this.queryToken(); + } + break; + case 2: + { + this.state = 64; + this.parenthesized(); + } + break; + default: + throw new NoViableAltException(this); + } + } + } + break; + default: + throw new NoViableAltException(this); + } + this.state = 69; + this._errHandler.sync(this); + _alt = this._interp.adaptivePredict(this._input, 8, this._ctx); + } while (_alt !== 2 && _alt !== ATN.INVALID_ALT_NUMBER); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public queryToken(): QueryTokenContext { + let localctx: QueryTokenContext = new QueryTokenContext(this, this._ctx, this.state); + this.enterRule(localctx, 8, QueryParser.RULE_queryToken); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 75; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 9, this._ctx)) { + case 1: + { + this.state = 71; + this.quoted(); + } + break; + case 2: + { + this.state = 72; + this.negated(); + } + break; + case 3: + { + this.state = 73; + this.propertyMatching(); + } + break; + case 4: + { + this.state = 74; + this.word(); + } + break; + } + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public quoted(): QuotedContext { + let localctx: QuotedContext = new QuotedContext(this, this._ctx, this.state); + this.enterRule(localctx, 10, QueryParser.RULE_quoted); + let _la: number; + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 77; + _la = this._input.LA(1); + if (!(_la === 6 || _la === 7)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public negated(): NegatedContext { + let localctx: NegatedContext = new NegatedContext(this, this._ctx, this.state); + this.enterRule(localctx, 12, QueryParser.RULE_negated); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 79; + this.match(QueryParser.NEGATION); + this.state = 83; + this._errHandler.sync(this); + switch (this._interp.adaptivePredict(this._input, 10, this._ctx)) { + case 1: + { + this.state = 80; + this.word(); + } + break; + case 2: + { + this.state = 81; + this.quoted(); + } + break; + case 3: + { + this.state = 82; + this.propertyMatching(); + } + break; + } + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public propertyMatching(): PropertyMatchingContext { + let localctx: PropertyMatchingContext = new PropertyMatchingContext( + this, + this._ctx, + this.state, + ); + this.enterRule(localctx, 14, QueryParser.RULE_propertyMatching); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 85; + this.name(); + this.state = 86; + this.match(QueryParser.T__0); + this.state = 87; + this.value(); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public name(): NameContext { + let localctx: NameContext = new NameContext(this, this._ctx, this.state); + this.enterRule(localctx, 16, QueryParser.RULE_name); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 89; + this.match(QueryParser.TOKEN); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public value(): ValueContext { + let localctx: ValueContext = new ValueContext(this, this._ctx, this.state); + this.enterRule(localctx, 18, QueryParser.RULE_value); + try { + this.state = 93; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case 9: + this.enterOuterAlt(localctx, 1); + { + this.state = 91; + this.word(); + } + break; + case 6: + case 7: + this.enterOuterAlt(localctx, 2); + { + this.state = 92; + this.quoted(); + } + break; + default: + throw new NoViableAltException(this); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + // @RuleVersion(0) + public word(): WordContext { + let localctx: WordContext = new WordContext(this, this._ctx, this.state); + this.enterRule(localctx, 20, QueryParser.RULE_word); + try { + this.enterOuterAlt(localctx, 1); + { + this.state = 95; + this.match(QueryParser.TOKEN); + } + } catch (re) { + if (re instanceof RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; + } + + public static readonly _serializedATN: number[] = [ + 4, 1, 11, 98, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, + 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 1, 0, 1, 0, 1, 0, 1, 0, 3, 0, 27, 8, 0, 1, + 0, 1, 0, 1, 1, 1, 1, 1, 1, 3, 1, 34, 8, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 3, 2, 41, 8, 2, 1, 2, + 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 3, 2, 49, 8, 2, 4, 2, 51, 8, 2, 11, 2, 12, 2, 52, 1, 3, 1, 3, 3, + 3, 57, 8, 3, 1, 3, 1, 3, 1, 3, 3, 3, 62, 8, 3, 1, 3, 1, 3, 3, 3, 66, 8, 3, 4, 3, 68, 8, 3, 11, + 3, 12, 3, 69, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 76, 8, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, + 84, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 3, 9, 94, 8, 9, 1, 10, 1, 10, 1, 10, + 0, 0, 11, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 0, 1, 1, 0, 6, 7, 105, 0, 26, 1, 0, 0, 0, 2, + 30, 1, 0, 0, 0, 4, 40, 1, 0, 0, 0, 6, 56, 1, 0, 0, 0, 8, 75, 1, 0, 0, 0, 10, 77, 1, 0, 0, 0, 12, + 79, 1, 0, 0, 0, 14, 85, 1, 0, 0, 0, 16, 89, 1, 0, 0, 0, 18, 93, 1, 0, 0, 0, 20, 95, 1, 0, 0, 0, + 22, 27, 3, 6, 3, 0, 23, 27, 3, 4, 2, 0, 24, 27, 3, 8, 4, 0, 25, 27, 3, 2, 1, 0, 26, 22, 1, 0, 0, + 0, 26, 23, 1, 0, 0, 0, 26, 24, 1, 0, 0, 0, 26, 25, 1, 0, 0, 0, 27, 28, 1, 0, 0, 0, 28, 29, 5, 0, + 0, 1, 29, 1, 1, 0, 0, 0, 30, 33, 5, 2, 0, 0, 31, 34, 3, 4, 2, 0, 32, 34, 3, 6, 3, 0, 33, 31, 1, + 0, 0, 0, 33, 32, 1, 0, 0, 0, 34, 35, 1, 0, 0, 0, 35, 36, 5, 3, 0, 0, 36, 3, 1, 0, 0, 0, 37, 41, + 3, 6, 3, 0, 38, 41, 3, 8, 4, 0, 39, 41, 3, 2, 1, 0, 40, 37, 1, 0, 0, 0, 40, 38, 1, 0, 0, 0, 40, + 39, 1, 0, 0, 0, 41, 50, 1, 0, 0, 0, 42, 43, 5, 10, 0, 0, 43, 44, 5, 4, 0, 0, 44, 48, 5, 10, 0, + 0, 45, 49, 3, 6, 3, 0, 46, 49, 3, 8, 4, 0, 47, 49, 3, 2, 1, 0, 48, 45, 1, 0, 0, 0, 48, 46, 1, 0, + 0, 0, 48, 47, 1, 0, 0, 0, 49, 51, 1, 0, 0, 0, 50, 42, 1, 0, 0, 0, 51, 52, 1, 0, 0, 0, 52, 50, 1, + 0, 0, 0, 52, 53, 1, 0, 0, 0, 53, 5, 1, 0, 0, 0, 54, 57, 3, 8, 4, 0, 55, 57, 3, 2, 1, 0, 56, 54, + 1, 0, 0, 0, 56, 55, 1, 0, 0, 0, 57, 67, 1, 0, 0, 0, 58, 61, 5, 10, 0, 0, 59, 60, 5, 5, 0, 0, 60, + 62, 5, 10, 0, 0, 61, 59, 1, 0, 0, 0, 61, 62, 1, 0, 0, 0, 62, 65, 1, 0, 0, 0, 63, 66, 3, 8, 4, 0, + 64, 66, 3, 2, 1, 0, 65, 63, 1, 0, 0, 0, 65, 64, 1, 0, 0, 0, 66, 68, 1, 0, 0, 0, 67, 58, 1, 0, 0, + 0, 68, 69, 1, 0, 0, 0, 69, 67, 1, 0, 0, 0, 69, 70, 1, 0, 0, 0, 70, 7, 1, 0, 0, 0, 71, 76, 3, 10, + 5, 0, 72, 76, 3, 12, 6, 0, 73, 76, 3, 14, 7, 0, 74, 76, 3, 20, 10, 0, 75, 71, 1, 0, 0, 0, 75, + 72, 1, 0, 0, 0, 75, 73, 1, 0, 0, 0, 75, 74, 1, 0, 0, 0, 76, 9, 1, 0, 0, 0, 77, 78, 7, 0, 0, 0, + 78, 11, 1, 0, 0, 0, 79, 83, 5, 8, 0, 0, 80, 84, 3, 20, 10, 0, 81, 84, 3, 10, 5, 0, 82, 84, 3, + 14, 7, 0, 83, 80, 1, 0, 0, 0, 83, 81, 1, 0, 0, 0, 83, 82, 1, 0, 0, 0, 84, 13, 1, 0, 0, 0, 85, + 86, 3, 16, 8, 0, 86, 87, 5, 1, 0, 0, 87, 88, 3, 18, 9, 0, 88, 15, 1, 0, 0, 0, 89, 90, 5, 9, 0, + 0, 90, 17, 1, 0, 0, 0, 91, 94, 3, 20, 10, 0, 92, 94, 3, 10, 5, 0, 93, 91, 1, 0, 0, 0, 93, 92, 1, + 0, 0, 0, 94, 19, 1, 0, 0, 0, 95, 96, 5, 9, 0, 0, 96, 21, 1, 0, 0, 0, 12, 26, 33, 40, 48, 52, 56, + 61, 65, 69, 75, 83, 93, + ]; + + private static __ATN: ATN; + public static get _ATN(): ATN { + if (!QueryParser.__ATN) { + QueryParser.__ATN = new ATNDeserializer().deserialize(QueryParser._serializedATN); + } + + return QueryParser.__ATN; + } + + static DecisionsToDFA = QueryParser._ATN.decisionToState.map( + (ds: DecisionState, index: number) => new DFA(ds, index), + ); +} + +export class QueryContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public EOF(): TerminalNode { + return this.getToken(QueryParser.EOF, 0); + } + public and(): AndContext { + return this.getTypedRuleContext(AndContext, 0) as AndContext; + } + public or(): OrContext { + return this.getTypedRuleContext(OrContext, 0) as OrContext; + } + public queryToken(): QueryTokenContext { + return this.getTypedRuleContext(QueryTokenContext, 0) as QueryTokenContext; + } + public parenthesized(): ParenthesizedContext { + return this.getTypedRuleContext(ParenthesizedContext, 0) as ParenthesizedContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_query; + } + public enterRule(listener: QueryListener): void { + if (listener.enterQuery) { + listener.enterQuery(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitQuery) { + listener.exitQuery(this); + } + } +} + +export class ParenthesizedContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public PARENS_OPEN(): TerminalNode { + return this.getToken(QueryParser.PARENS_OPEN, 0); + } + public PARENS_CLOSE(): TerminalNode { + return this.getToken(QueryParser.PARENS_CLOSE, 0); + } + public or(): OrContext { + return this.getTypedRuleContext(OrContext, 0) as OrContext; + } + public and(): AndContext { + return this.getTypedRuleContext(AndContext, 0) as AndContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_parenthesized; + } + public enterRule(listener: QueryListener): void { + if (listener.enterParenthesized) { + listener.enterParenthesized(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitParenthesized) { + listener.exitParenthesized(this); + } + } +} + +export class OrContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public and_list(): AndContext[] { + return this.getTypedRuleContexts(AndContext) as AndContext[]; + } + public and(i: number): AndContext { + return this.getTypedRuleContext(AndContext, i) as AndContext; + } + public queryToken_list(): QueryTokenContext[] { + return this.getTypedRuleContexts(QueryTokenContext) as QueryTokenContext[]; + } + public queryToken(i: number): QueryTokenContext { + return this.getTypedRuleContext(QueryTokenContext, i) as QueryTokenContext; + } + public parenthesized_list(): ParenthesizedContext[] { + return this.getTypedRuleContexts(ParenthesizedContext) as ParenthesizedContext[]; + } + public parenthesized(i: number): ParenthesizedContext { + return this.getTypedRuleContext(ParenthesizedContext, i) as ParenthesizedContext; + } + public SEPARATOR_list(): TerminalNode[] { + return this.getTokens(QueryParser.SEPARATOR); + } + public SEPARATOR(i: number): TerminalNode { + return this.getToken(QueryParser.SEPARATOR, i); + } + public OR_list(): TerminalNode[] { + return this.getTokens(QueryParser.OR); + } + public OR(i: number): TerminalNode { + return this.getToken(QueryParser.OR, i); + } + public get ruleIndex(): number { + return QueryParser.RULE_or; + } + public enterRule(listener: QueryListener): void { + if (listener.enterOr) { + listener.enterOr(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitOr) { + listener.exitOr(this); + } + } +} + +export class AndContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public queryToken_list(): QueryTokenContext[] { + return this.getTypedRuleContexts(QueryTokenContext) as QueryTokenContext[]; + } + public queryToken(i: number): QueryTokenContext { + return this.getTypedRuleContext(QueryTokenContext, i) as QueryTokenContext; + } + public parenthesized_list(): ParenthesizedContext[] { + return this.getTypedRuleContexts(ParenthesizedContext) as ParenthesizedContext[]; + } + public parenthesized(i: number): ParenthesizedContext { + return this.getTypedRuleContext(ParenthesizedContext, i) as ParenthesizedContext; + } + public SEPARATOR_list(): TerminalNode[] { + return this.getTokens(QueryParser.SEPARATOR); + } + public SEPARATOR(i: number): TerminalNode { + return this.getToken(QueryParser.SEPARATOR, i); + } + public AND_list(): TerminalNode[] { + return this.getTokens(QueryParser.AND); + } + public AND(i: number): TerminalNode { + return this.getToken(QueryParser.AND, i); + } + public get ruleIndex(): number { + return QueryParser.RULE_and; + } + public enterRule(listener: QueryListener): void { + if (listener.enterAnd) { + listener.enterAnd(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitAnd) { + listener.exitAnd(this); + } + } +} + +export class QueryTokenContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public quoted(): QuotedContext { + return this.getTypedRuleContext(QuotedContext, 0) as QuotedContext; + } + public negated(): NegatedContext { + return this.getTypedRuleContext(NegatedContext, 0) as NegatedContext; + } + public propertyMatching(): PropertyMatchingContext { + return this.getTypedRuleContext(PropertyMatchingContext, 0) as PropertyMatchingContext; + } + public word(): WordContext { + return this.getTypedRuleContext(WordContext, 0) as WordContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_queryToken; + } + public enterRule(listener: QueryListener): void { + if (listener.enterQueryToken) { + listener.enterQueryToken(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitQueryToken) { + listener.exitQueryToken(this); + } + } +} + +export class QuotedContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public SINGLE_QUOTED(): TerminalNode { + return this.getToken(QueryParser.SINGLE_QUOTED, 0); + } + public DOUBLE_QUOTED(): TerminalNode { + return this.getToken(QueryParser.DOUBLE_QUOTED, 0); + } + public get ruleIndex(): number { + return QueryParser.RULE_quoted; + } + public enterRule(listener: QueryListener): void { + if (listener.enterQuoted) { + listener.enterQuoted(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitQuoted) { + listener.exitQuoted(this); + } + } +} + +export class NegatedContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public NEGATION(): TerminalNode { + return this.getToken(QueryParser.NEGATION, 0); + } + public word(): WordContext { + return this.getTypedRuleContext(WordContext, 0) as WordContext; + } + public quoted(): QuotedContext { + return this.getTypedRuleContext(QuotedContext, 0) as QuotedContext; + } + public propertyMatching(): PropertyMatchingContext { + return this.getTypedRuleContext(PropertyMatchingContext, 0) as PropertyMatchingContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_negated; + } + public enterRule(listener: QueryListener): void { + if (listener.enterNegated) { + listener.enterNegated(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitNegated) { + listener.exitNegated(this); + } + } +} + +export class PropertyMatchingContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public name(): NameContext { + return this.getTypedRuleContext(NameContext, 0) as NameContext; + } + public value(): ValueContext { + return this.getTypedRuleContext(ValueContext, 0) as ValueContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_propertyMatching; + } + public enterRule(listener: QueryListener): void { + if (listener.enterPropertyMatching) { + listener.enterPropertyMatching(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitPropertyMatching) { + listener.exitPropertyMatching(this); + } + } +} + +export class NameContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public TOKEN(): TerminalNode { + return this.getToken(QueryParser.TOKEN, 0); + } + public get ruleIndex(): number { + return QueryParser.RULE_name; + } + public enterRule(listener: QueryListener): void { + if (listener.enterName) { + listener.enterName(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitName) { + listener.exitName(this); + } + } +} + +export class ValueContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public word(): WordContext { + return this.getTypedRuleContext(WordContext, 0) as WordContext; + } + public quoted(): QuotedContext { + return this.getTypedRuleContext(QuotedContext, 0) as QuotedContext; + } + public get ruleIndex(): number { + return QueryParser.RULE_value; + } + public enterRule(listener: QueryListener): void { + if (listener.enterValue) { + listener.enterValue(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitValue) { + listener.exitValue(this); + } + } +} + +export class WordContext extends ParserRuleContext { + constructor(parser?: QueryParser, parent?: ParserRuleContext, invokingState?: number) { + super(parent, invokingState); + this.parser = parser; + } + public TOKEN(): TerminalNode { + return this.getToken(QueryParser.TOKEN, 0); + } + public get ruleIndex(): number { + return QueryParser.RULE_word; + } + public enterRule(listener: QueryListener): void { + if (listener.enterWord) { + listener.enterWord(this); + } + } + public exitRule(listener: QueryListener): void { + if (listener.exitWord) { + listener.exitWord(this); + } + } +} diff --git a/packages/datasource-customizer/src/decorators/search/generated-parser/README.md b/packages/datasource-customizer/src/decorators/search/generated-parser/README.md new file mode 100644 index 0000000000..65ff7f8a0d --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/generated-parser/README.md @@ -0,0 +1,4 @@ +Everything in this directory (except for this file) is automatically generated by +`yarn build:parser`. + +Generated code needs to be manually edited to add an `override`, and format the code. \ No newline at end of file diff --git a/packages/datasource-customizer/src/decorators/search/normalize-name.ts b/packages/datasource-customizer/src/decorators/search/normalize-name.ts new file mode 100644 index 0000000000..7214e5f7b9 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/normalize-name.ts @@ -0,0 +1,3 @@ +export default function normalizeName(value: string): string { + return value.toLocaleLowerCase().replace(/[-_]/g, ''); +} diff --git a/packages/datasource-customizer/src/decorators/search/parse-query.ts b/packages/datasource-customizer/src/decorators/search/parse-query.ts new file mode 100644 index 0000000000..8117a28561 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/parse-query.ts @@ -0,0 +1,53 @@ +import { Caller, ColumnSchema, ConditionTree } from '@forestadmin/datasource-toolkit'; +import { CharStream, CommonTokenStream, ParseTreeWalker } from 'antlr4'; + +import ConditionTreeQueryWalker from './custom-parser/condition-tree-query-walker'; +import CustomQueryParser from './custom-parser/custom-query-parser'; +/** + * All these classes are generated by antlr (the command line) + * In order to support new syntax: + * 1. Update the grammar file (src/parser/Query.g4) + * 2. Run `yarn build:parser` to generate the new classes + * 3. Manually update the parser to add `override` on the EOF line (needed by TS) + * + * The grammar syntax is documented here: https://www.antlr.org/ + * And can be tested online here: http://lab.antlr.org/ + */ +import FieldsQueryWalker from './custom-parser/fields-query-walker'; +import QueryLexer from './generated-parser/QueryLexer'; +import { QueryContext } from './generated-parser/QueryParser'; + +export function parseQuery(query: string): QueryContext { + const chars = new CharStream(query?.trim()); // replace this with a FileStream as required + const lexer = new QueryLexer(chars); + const tokens = new CommonTokenStream(lexer); + const parser = new CustomQueryParser(tokens); + + return parser.query(); +} + +export function generateConditionTree( + caller: Caller, + tree: QueryContext, + fields: [string, ColumnSchema][], +): ConditionTree { + const walker = new ConditionTreeQueryWalker(caller, fields); + + ParseTreeWalker.DEFAULT.walk(walker, tree); + + const result = walker.conditionTree; + + if (result) { + return result; + } + + // Parsing error, fallback + return walker.generateDefaultFilter(tree.getText()); +} + +export function extractSpecifiedFields(tree: QueryContext) { + const walker = new FieldsQueryWalker(); + ParseTreeWalker.DEFAULT.walk(walker, tree); + + return walker.fields; +} diff --git a/packages/datasource-customizer/src/decorators/sort-emulate/collection.ts b/packages/datasource-customizer/src/decorators/sort-emulate/collection.ts index 44ee4183cd..8be84d2db5 100644 --- a/packages/datasource-customizer/src/decorators/sort-emulate/collection.ts +++ b/packages/datasource-customizer/src/decorators/sort-emulate/collection.ts @@ -2,7 +2,6 @@ import { Caller, CollectionDecorator, CollectionSchema, - ColumnSchema, ConditionTreeFactory, DataSourceDecorator, FieldSchema, @@ -20,22 +19,33 @@ import { export default class SortEmulate extends CollectionDecorator { override readonly dataSource: DataSourceDecorator; private readonly sorts: Map = new Map(); + private readonly disabledSorts: Set = new Set(); emulateFieldSorting(name: string): void { - this.replaceFieldSorting(name, null); + this.replaceOrEmulateFieldSorting(name, null); } + /** + * Disable sorting on this field. This only prevents the end-user to sort on this field. + * It will still be possible to sort on this field in the customizations code. + * @param name name of the field + * @deprecated this method will be removed soon. + */ disableFieldSorting(name: string): void { - // This is not the best way to achieve the disable behavior - // but basically we replace sort with nothing to disable any sort - this.replaceFieldSorting(name, []); + FieldValidator.validate(this, name); + + this.disabledSorts.add(name); + this.markSchemaAsDirty(); } replaceFieldSorting(name: string, equivalentSort: PlainSortClause[]): void { - FieldValidator.validate(this, name); + if (!equivalentSort) + throw new Error('A new sorting method should be provided to replace field sorting'); + this.replaceOrEmulateFieldSorting(name, equivalentSort); + } - const field = this.childCollection.schema.fields[name] as ColumnSchema; - if (!field) throw new Error('Cannot replace sort on relation'); + private replaceOrEmulateFieldSorting(name: string, equivalentSort: PlainSortClause[]): void { + FieldValidator.validate(this, name); this.sorts.set(name, equivalentSort ? new Sort(...equivalentSort) : null); this.markSchemaAsDirty(); @@ -81,16 +91,21 @@ export default class SortEmulate extends CollectionDecorator { const fields: Record = {}; for (const [name, schema] of Object.entries(childSchema.fields)) { - const fieldSort = this.sorts.get(name); - - fields[name] = - this.sorts.has(name) && - schema.type === 'Column' && - // In order to support disableFieldSorting with empty array (isSortable: false) - // and emulateFieldSorting with null value (isSortable: true) - (!fieldSort || (fieldSort && fieldSort?.length !== 0)) - ? { ...schema, isSortable: true } - : schema; + if (schema.type === 'Column') { + let sortable = schema.isSortable; + + if (this.disabledSorts.has(name)) { + // disableFieldSorting + sortable = false; + } else if (this.sorts.has(name)) { + // replaceFieldSorting + sortable = true; + } + + fields[name] = { ...schema, isSortable: sortable }; + } else { + fields[name] = schema; + } } return { ...childSchema, fields }; diff --git a/packages/datasource-customizer/src/decorators/write/write-replace/collection.ts b/packages/datasource-customizer/src/decorators/write/write-replace/collection.ts index 4161ba4d72..9ea67400b9 100644 --- a/packages/datasource-customizer/src/decorators/write/write-replace/collection.ts +++ b/packages/datasource-customizer/src/decorators/write/write-replace/collection.ts @@ -4,6 +4,7 @@ import { CollectionSchema, ColumnSchema, DataSourceDecorator, + FieldValidator, Filter, RecordData, RecordValidator, @@ -17,12 +18,9 @@ export default class WriteReplacerCollectionDecorator extends CollectionDecorato override readonly dataSource: DataSourceDecorator; replaceFieldWriting(fieldName: string, definition: WriteDefinition): void { - if (!Object.keys(this.schema.fields).includes(fieldName)) { - throw new Error( - `The given field "${fieldName}" does not exist on the ${this.name} collection.`, - ); - } - + if (!definition) + throw new Error('A new writing method should be provided to replace field writing'); + FieldValidator.validate(this, fieldName); this.handlers[fieldName] = definition; this.markSchemaAsDirty(); } diff --git a/packages/datasource-customizer/test/collection-customizer.test.ts b/packages/datasource-customizer/test/collection-customizer.test.ts index d960c22713..1cb07e3a99 100644 --- a/packages/datasource-customizer/test/collection-customizer.test.ts +++ b/packages/datasource-customizer/test/collection-customizer.test.ts @@ -265,7 +265,7 @@ describe('Builder > Collection', () => { expect(spy).toHaveBeenCalledWith('translatorName', expect.any(Function)); const [[, definition]] = spy.mock.calls; - expect(definition('newNameValue', {} as never)).toEqual({ + expect(definition?.('newNameValue', {} as never)).toEqual({ translator: { name: 'newNameValue' }, }); expect(self).toEqual(customizer); @@ -551,6 +551,64 @@ describe('Builder > Collection', () => { expect(self.schema.fields.myBooks).toBeDefined(); expect(self).toEqual(customizer); }); + + it('should not allow disableFieldSorting', async () => { + const { dsc, customizer, stack } = await setup(); + + const spy = jest.spyOn(stack.relation.getCollection('authors'), 'addRelation'); + + const self = customizer.addOneToOneRelation('myBookAuthor', 'book_author', { + originKey: 'authorFk', + originKeyTarget: 'authorId', + }); + + customizer.disableFieldSorting('myBookAuthor'); + + await expect(() => dsc.getDataSource(logger)).rejects.toThrow( + new Error( + "Unexpected field type: 'authors.myBookAuthor' (found 'OneToOne' expected 'Column')", + ), + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('myBookAuthor', { + type: 'OneToOne', + foreignCollection: 'book_author', + originKey: 'authorFk', + originKeyTarget: 'authorId', + }); + expect(self.schema.fields.myBookAuthor).toBeDefined(); + expect(self).toEqual(customizer); + }); + + it('should not allow replaceFieldSorting', async () => { + const { dsc, customizer, stack } = await setup(); + + const spy = jest.spyOn(stack.relation.getCollection('authors'), 'addRelation'); + + const self = customizer.addOneToOneRelation('myBookAuthor', 'book_author', { + originKey: 'authorFk', + originKeyTarget: 'authorId', + }); + + customizer.replaceFieldSorting('myBookAuthor', []); + + await expect(() => dsc.getDataSource(logger)).rejects.toThrow( + new Error( + "Unexpected field type: 'authors.myBookAuthor' (found 'OneToOne' expected 'Column')", + ), + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('myBookAuthor', { + type: 'OneToOne', + foreignCollection: 'book_author', + originKey: 'authorFk', + originKeyTarget: 'authorId', + }); + expect(self.schema.fields.myBookAuthor).toBeDefined(); + expect(self).toEqual(customizer); + }); }); describe('addSegment', () => { @@ -573,7 +631,7 @@ describe('Builder > Collection', () => { }); describe('disableFieldSorting', () => { - it('should emulate sort on field', async () => { + it('should disable sort on field', async () => { const { dsc, customizer, stack } = await setup(); const collection = stack.sortEmulate.getCollection('authors'); @@ -648,7 +706,32 @@ describe('Builder > Collection', () => { const self = customizer.emulateFieldFiltering('lastName'); await dsc.getDataSource(logger); - expect(spy).toHaveBeenCalledTimes(19); + expect(spy).toHaveBeenCalledTimes(21); + [ + 'Equal', + 'NotEqual', + 'Present', + 'Blank', + 'In', + 'NotIn', + 'StartsWith', + 'EndsWith', + 'IStartsWith', + 'IEndsWith', + 'Contains', + 'NotContains', + 'IContains', + 'NotIContains', + 'Missing', + 'Like', + 'ILike', + 'LongerThan', + 'ShorterThan', + 'IncludesAll', + 'IncludesNone', + ].forEach(operator => { + expect(spy).toHaveBeenCalledWith('lastName', operator); + }); expect(self).toEqual(customizer); }); }); diff --git a/packages/datasource-customizer/test/decorators/search/collections.test.ts b/packages/datasource-customizer/test/decorators/search/collections.test.ts index a5868375d0..25b6918706 100644 --- a/packages/datasource-customizer/test/decorators/search/collections.test.ts +++ b/packages/datasource-customizer/test/decorators/search/collections.test.ts @@ -1,55 +1,61 @@ -import { ConditionTreeFactory, ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; +import { + Collection, + CollectionSchema, + ConditionTreeFactory, + ConditionTreeLeaf, + DataSourceDecorator, + PaginatedFilter, +} from '@forestadmin/datasource-toolkit'; import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; import SearchCollectionDecorator from '../../../src/decorators/search/collection'; +const caller = factories.caller.build(); + +function buildCollection( + schema: Partial, + otherCollections: Collection[] = [], +): SearchCollectionDecorator { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + schema: factories.collectionSchema.unsearchable().build(schema), + }), + ...otherCollections, + ]); + + const decoratedDataSource = new DataSourceDecorator(dataSource, SearchCollectionDecorator); + + return decoratedDataSource.collections[0]; +} + describe('SearchCollectionDecorator', () => { describe('refineSchema', () => { it('should set the schema searchable', async () => { - const collection = factories.collection.build(); - const unsearchableSchema = factories.collectionSchema.build({ searchable: false }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const schema = await searchCollectionDecorator.refineSchema(unsearchableSchema); + const decorator = buildCollection({ searchable: false }); - expect(schema.searchable).toBe(true); + expect(decorator.schema.searchable).toBe(true); }); }); describe('refineFilter', () => { describe('when the search value is null', () => { test('should return the given filter to return all records', async () => { - const collection = factories.collection.build(); - const filter = factories.filter.build({ search: null }); + const decorator = buildCollection({}); + const filter = factories.filter.build({ search: null as unknown as undefined }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toStrictEqual(filter); + expect(await decorator.refineFilter(caller, filter)).toStrictEqual(filter); }); }); describe('when the given field is not a column', () => { test('adds a condition to not return record if it is the only one filter', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.oneToManySchema.build(), - }, - }), + const decorator = buildCollection({ + fields: { fieldName: factories.oneToManySchema.build() }, }); - const filter = factories.filter.build({ search: 'a search value' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); + const filter = factories.filter.build({ search: 'a search value' }); - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: ConditionTreeFactory.MatchNone, }); @@ -58,34 +64,24 @@ describe('SearchCollectionDecorator', () => { describe('when the collection schema is searchable', () => { test('should return the given filter without adding condition', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.build({ searchable: true }), - }); - const filter = factories.filter.build({ search: 'a text' }); + const decorator = buildCollection({ searchable: true }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); + const filter = factories.filter.build({ search: 'a text' }); - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toStrictEqual(filter); + expect(await decorator.refineFilter(caller, filter)).toStrictEqual(filter); }); }); describe('when a replacer is provided', () => { test('it should be used instead of the default one', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.build({ - fields: { id: factories.columnSchema.uuidPrimaryKey().build() }, - }), + const decorator = buildCollection({ + fields: { id: factories.columnSchema.uuidPrimaryKey().build() }, }); - const filter = factories.filter.build({ search: 'something' }); - const decorator = new SearchCollectionDecorator(collection, null); decorator.replaceSearch(value => ({ field: 'id', operator: 'Equal', value })); - const refinedFilter = await decorator.refineFilter(factories.caller.build(), filter); - expect(refinedFilter).toEqual({ + const filter = factories.filter.build({ search: 'something' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ ...filter, conditionTree: new ConditionTreeLeaf('id', 'Equal', 'something'), search: null, @@ -96,36 +92,29 @@ describe('SearchCollectionDecorator', () => { describe('when the search is defined and the collection schema is not searchable', () => { describe('when the search is empty', () => { test('returns the same filter and set search as null', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build(), - }); + const decorator = buildCollection(factories.collectionSchema.unsearchable().build()); const filter = factories.filter.build({ search: ' ' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ ...filter, search: null }); + expect(await decorator.refineFilter(caller, filter)).toEqual({ + ...filter, + search: null, + }); }); }); describe('when the filter contains already conditions', () => { test('should add its conditions to the filter', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'String', - filterOperators: new Set(['IContains']), - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains']), + }), + }, }); const filter = factories.filter.build({ - search: 'a text', + search: '"a text"', conditionTree: factories.conditionTreeBranch.build({ aggregator: 'And', conditions: [ @@ -138,13 +127,7 @@ describe('SearchCollectionDecorator', () => { }), }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { aggregator: 'And', @@ -157,116 +140,255 @@ describe('SearchCollectionDecorator', () => { }); }); - describe('when the search is a string and the column type is a string', () => { - test('should return filter with "contains" condition and "or" aggregator', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'String', - filterOperators: new Set(['IContains', 'Contains']), - }), - }, - }), + describe('when using bad column indication', () => { + test('should behave as if it was not a column indication', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains', 'Contains']), + }), + }, }); - const filter = factories.filter.build({ search: 'a text' }); + const filter = factories.filter.build({ search: 'noexist:atext' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'IContains', value: 'noexist:atext' }, + }); + }); + }); + + describe('when mixing indications and normal text', () => { + test('should mix and & or', async () => { + const decorator = buildCollection({ + fields: { + fieldName1: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains', 'Contains']), + }), + fieldName2: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains', 'Contains']), + }), + fieldName3: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains', 'Contains', 'NotContains']), + }), + }, + }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); + const filter = factories.filter.build({ + search: 'fieldname1:atext -fieldname3:something "extra keywords"', + }); - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, - conditionTree: { field: 'fieldName', operator: 'IContains', value: 'a text' }, + conditionTree: { + aggregator: 'And', + conditions: [ + { field: 'fieldName1', operator: 'IContains', value: 'atext' }, + { field: 'fieldName3', operator: 'NotContains', value: 'something' }, + { + aggregator: 'Or', + conditions: [ + { field: 'fieldName1', operator: 'IContains', value: 'extra keywords' }, + { field: 'fieldName2', operator: 'IContains', value: 'extra keywords' }, + { field: 'fieldName3', operator: 'IContains', value: 'extra keywords' }, + ], + }, + ], + }, }); }); }); - describe('when searching on a string that only supports Equal', () => { - test('should return filter with "equal" condition', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'String', - filterOperators: new Set(['Equal']), - }), - }, - }), + describe('when using column indication on unsupported field', () => { + test('should behave as if it was not a column indication', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(), + }), + }, }); - const filter = factories.filter.build({ search: 'a text' }); + const filter = factories.filter.build({ search: 'fieldName:atext' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: ConditionTreeFactory.MatchNone, + }); + }); + }); - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + describe('when using boolean search', () => { + test('should return filter with "Equal"', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'Boolean', + filterOperators: new Set(['Equal']), + }), + }, + }); + + const filter = factories.filter.build({ search: 'fieldname:true' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, - conditionTree: { field: 'fieldName', operator: 'Equal', value: 'a text' }, + conditionTree: { field: 'fieldName', operator: 'Equal', value: true }, }); }); }); - describe('search is a case insensitive string and both operators are supported', () => { - test('should return filter with "contains" condition and "or" aggregator', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'String', - filterOperators: new Set(['IContains', 'Contains']), - }), - }, - }), + describe('when using negated equal to null', () => { + test('should return filter with "Present"', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['Present']), + }), + }, + }); + + const filter = factories.filter.build({ search: '-fielDnAme:NULL' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'Present' }, + }); + }); + }); + + describe('when using "equal null"', () => { + test('should return filter with "Missing"', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['Missing']), + }), + }, }); - const filter = factories.filter.build({ search: '@#*$(@#*$(23423423' }); + const filter = factories.filter.build({ search: 'fielDnAme:NULL' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'Missing' }, + }); + }); + }); - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + describe('when using negated keyword', () => { + test('should return filter with "notcontains"', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['NotContains']), + }), + fieldName2: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['NotContains']), + }), + }, + }); + + const filter = factories.filter.build({ search: '-atext' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { - field: 'fieldName', - operator: 'Contains', - value: '@#*$(@#*$(23423423', + aggregator: 'And', + conditions: [ + { field: 'fieldName', operator: 'NotContains', value: 'atext' }, + { field: 'fieldName2', operator: 'NotContains', value: 'atext' }, + ], + }, + }); + }); + }); + + describe('when using negated column indication', () => { + test('should return filter with "notcontains"', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['NotContains']), + }), }, }); + + const filter = factories.filter.build({ search: '-fieldname:atext' }); + const refined = await decorator.refineFilter(caller, filter); + + expect(refined).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'NotContains', value: 'atext' }, + }); + }); + }); + + describe('when the search is a string and the column type is a string', () => { + test('should return filter with "contains" condition and "or" aggregator', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains', 'Contains']), + }), + }, + }); + + const filter = factories.filter.build({ search: 'text' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'IContains', value: 'text' }, + }); + }); + }); + + describe('when searching on a string that only supports Equal', () => { + test('should return filter with "equal" condition', async () => { + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['Equal']), + }), + }, + }); + + const filter = factories.filter.build({ search: 'text' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'fieldName', operator: 'Equal', value: 'text' }, + }); }); }); describe('when the search is an uuid and the column type is an uuid', () => { test('should return filter with "equal" condition and "or" aggregator', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'Uuid', - filterOperators: new Set(['Equal']), - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'Uuid', + filterOperators: new Set(['Equal']), + }), + }, }); const filter = factories.filter.build({ search: '2d162303-78bf-599e-b197-93590ac3d315' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { field: 'fieldName', @@ -279,30 +401,22 @@ describe('SearchCollectionDecorator', () => { describe('when the search is a number and the column type is a number', () => { test('returns "equal" condition, "or" aggregator and cast value to Number', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'Number', - filterOperators: new Set(['Equal']), - }), - fieldName2: factories.columnSchema.build({ - columnType: 'String', - filterOperators: new Set(['IContains']), - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'Number', + filterOperators: new Set(['Equal']), + }), + fieldName2: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains']), + }), + }, }); const filter = factories.filter.build({ search: '1584' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { aggregator: 'Or', @@ -317,27 +431,19 @@ describe('SearchCollectionDecorator', () => { describe('when the search is an string and the column type is an enum', () => { test('should return filter with "equal" condition and "or" aggregator', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'Enum', - enumValues: ['AnEnUmVaLue'], - filterOperators: new Set(['Equal']), - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'Enum', + enumValues: ['AnEnUmVaLue'], + filterOperators: new Set(['Equal']), + }), + }, }); const filter = factories.filter.build({ search: 'anenumvalue' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { field: 'fieldName', operator: 'Equal', value: 'AnEnUmVaLue' }, }); @@ -345,26 +451,18 @@ describe('SearchCollectionDecorator', () => { describe('when the search value does not match any enum', () => { test('adds a condition to not return record if it is the only one filter', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'Enum', - enumValues: ['AEnumValue'], - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ + columnType: 'Enum', + enumValues: ['AEnumValue'], + }), + }, }); const filter = factories.filter.build({ search: 'NotExistEnum' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: ConditionTreeFactory.MatchNone, }); @@ -373,26 +471,14 @@ describe('SearchCollectionDecorator', () => { describe('when the enum values are not defined', () => { test('adds a condition to not return record if it is the only one filter', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ - columnType: 'Enum', - // enum values is not defined - }), - }, - }), + // enum values is not defined + const decorator = buildCollection({ + fields: { fieldName: factories.columnSchema.build({ columnType: 'Enum' }) }, }); const filter = factories.filter.build({ search: 'NotExistEnum' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: ConditionTreeFactory.MatchNone, }); @@ -401,27 +487,19 @@ describe('SearchCollectionDecorator', () => { describe('when the column type is not searchable', () => { test('adds a condition to not return record if it is the only one filter', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - fieldName: factories.columnSchema.build({ columnType: 'Boolean' }), - originKey: factories.columnSchema.build({ - columnType: 'String', - filterOperators: null, - }), - }, - }), + const decorator = buildCollection({ + fields: { + fieldName: factories.columnSchema.build({ columnType: 'Boolean' }), + originKey: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(), + }), + }, }); const filter = factories.filter.build({ search: '1584' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: ConditionTreeFactory.MatchNone, }); @@ -431,31 +509,23 @@ describe('SearchCollectionDecorator', () => { describe('when there are several fields', () => { test('should return all the number fields when a number is researched', async () => { - const collection = factories.collection.build({ - schema: factories.collectionSchema.unsearchable().build({ - fields: { - numberField1: factories.columnSchema.build({ - columnType: 'Number', - filterOperators: new Set(['Equal']), - }), - numberField2: factories.columnSchema.build({ - columnType: 'Number', - filterOperators: new Set(['Equal']), - }), - fieldNotReturned: factories.columnSchema.build({ columnType: 'Uuid' }), - }, - }), + const decorator = buildCollection({ + fields: { + numberField1: factories.columnSchema.build({ + columnType: 'Number', + filterOperators: new Set(['Equal']), + }), + numberField2: factories.columnSchema.build({ + columnType: 'Number', + filterOperators: new Set(['Equal']), + }), + fieldNotReturned: factories.columnSchema.build({ columnType: 'Uuid' }), + }, }); const filter = factories.filter.build({ search: '1584' }); - const searchCollectionDecorator = new SearchCollectionDecorator(collection, null); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ search: null, conditionTree: { aggregator: 'Or', @@ -467,59 +537,133 @@ describe('SearchCollectionDecorator', () => { }); }); - describe('when it is a deep search with relation fields', () => { - test('should return all the uuid fields when uuid is researched', async () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ - name: 'books', - schema: factories.collectionSchema.unsearchable().build({ - fields: { - id: factories.columnSchema.uuidPrimaryKey().build(), - myPersons: factories.oneToOneSchema.build({ - foreignCollection: 'persons', - originKey: 'personId', - }), - myBookPersons: factories.manyToOneSchema.build({ - foreignCollection: 'bookPersons', - foreignKey: 'bookId', - }), - }, + describe('when using deep column indication', () => { + test('should return filter with "contains" condition and "or" aggregator', async () => { + const decorator = buildCollection( + { + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + userId: factories.columnSchema.build(), + Rela_tioN: factories.manyToOneSchema.build({ + foreignCollection: 'users', + foreignKey: 'userId', + foreignKeyTarget: 'id', + }), + }, + }, + [ + factories.collection.build({ + name: 'users', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + nAME: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains']), + }), + }, + }), }), - }), - factories.collection.build({ - name: 'bookPersons', - schema: factories.collectionSchema.unsearchable().build({ - fields: { - bookId: factories.columnSchema.uuidPrimaryKey().build(), - personId: factories.columnSchema.uuidPrimaryKey().build(), - }, + ], + ); + + const filter = factories.filter.build({ search: 'relation.name:atext' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual({ + search: null, + conditionTree: { field: 'Rela_tioN:nAME', operator: 'IContains', value: 'atext' }, + }); + }); + }); + + describe('when using invalid deep column indication', () => { + test('should do as if it was not an indication', async () => { + const decorator = buildCollection( + { + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + Rela_tioN: factories.oneToManySchema.build({ + foreignCollection: 'users', + originKey: 'userId', + originKeyTarget: 'id', + }), + }, + }, + [ + factories.collection.build({ + name: 'users', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + bookId: factories.columnSchema.build(), + nAME: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['IContains']), + }), + }, + }), }), - }), - factories.collection.build({ - name: 'persons', - schema: factories.collectionSchema.unsearchable().build({ - fields: { - id: factories.columnSchema.uuidPrimaryKey().build(), - }, + ], + ); + + const filter = factories.filter.build({ search: 'relation.name:atext' }); + + expect(await decorator.refineFilter(caller, filter)).toEqual( + new PaginatedFilter({ + conditionTree: ConditionTreeFactory.fromPlainObject({ + field: 'Rela_tioN:nAME', + operator: 'IContains', + value: 'atext', }), + search: null, }), - ]); + ); + }); + }); + + describe('when it is a deep search with relation fields', () => { + test('should return all the uuid fields when uuid is researched', async () => { + const decorator = buildCollection( + { + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + myPersons: factories.oneToOneSchema.build({ + foreignCollection: 'persons', + originKey: 'personId', + }), + myBookPersons: factories.manyToOneSchema.build({ + foreignCollection: 'bookPersons', + foreignKey: 'bookId', + }), + }, + }, + [ + factories.collection.build({ + name: 'bookPersons', + schema: factories.collectionSchema.build({ + fields: { + bookId: factories.columnSchema.uuidPrimaryKey().build(), + personId: factories.columnSchema.uuidPrimaryKey().build(), + }, + }), + }), + factories.collection.build({ + name: 'persons', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + }, + }), + }), + ], + ); const filter = factories.filter.build({ searchExtended: true, search: '2d162303-78bf-599e-b197-93590ac3d315', }); - const searchCollectionDecorator = new SearchCollectionDecorator( - dataSource.collections[0], - null, - ); - - const refinedFilter = await searchCollectionDecorator.refineFilter( - factories.caller.build(), - filter, - ); - expect(refinedFilter).toEqual({ + expect(await decorator.refineFilter(caller, filter)).toEqual({ searchExtended: true, search: null, conditionTree: { diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-basic-array-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-basic-array-field-filter.test.ts new file mode 100644 index 0000000000..9fcdf39f64 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-basic-array-field-filter.test.ts @@ -0,0 +1,69 @@ +import { ConditionTreeFactory, ConditionTreeLeaf, Operator } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'; + +describe('buildBasicArrayFieldFilter', () => { + describe('when not negated', () => { + const isNegated = false; + + it('should return a ConditionTreeLeaf with IncludesAll operator', () => { + const expectedResult = new ConditionTreeLeaf('field', 'IncludesAll', 'value'); + + const result = buildBasicArrayFieldFilter( + 'field', + new Set(['IncludesAll']), + 'value', + isNegated, + ); + + expect(result).toEqual(expectedResult); + }); + + it('should return match-none if no IncludesAll operator', () => { + const result = buildBasicArrayFieldFilter('field', new Set(), 'value', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when all operators are present', () => { + const operators = new Set(['IncludesNone', 'Missing']); + + it('should return a ConditionTreeLeaf with IncludesNone operator', () => { + const result = buildBasicArrayFieldFilter('field', operators, 'value', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('field', 'IncludesNone', 'value'), + new ConditionTreeLeaf('field', 'Missing'), + ), + ); + }); + }); + + describe('when only the IncludesNone operator is present', () => { + const operators = new Set(['IncludesNone']); + + it('should return a ConditionTreeLeaf with IncludesNone operator', () => { + const expectedResult = new ConditionTreeLeaf('field', 'IncludesNone', 'value'); + + const result = buildBasicArrayFieldFilter('field', operators, 'value', isNegated); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when IncludesNone operator is not present', () => { + const operators = new Set(['Missing']); + + it('should return match-all', () => { + const result = buildBasicArrayFieldFilter('field', operators, 'value', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-boolean-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-boolean-field-filter.test.ts new file mode 100644 index 0000000000..fc6ba2b013 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-boolean-field-filter.test.ts @@ -0,0 +1,98 @@ +import { + ConditionTreeBranch, + ConditionTreeFactory, + ConditionTreeLeaf, +} from '@forestadmin/datasource-toolkit'; + +import buildBooleanFieldFilter from '../../../../src/decorators/search/filter-builder/build-boolean-field-filter'; + +describe('buildBooleanFieldFilter', () => { + describe('if the field has the needed operator(s)', () => { + describe('When isNegated is false', () => { + it.each([ + ['true', true], + ['1', true], + ['TRUE', true], + ['false', false], + ['0', false], + ['FALSE', false], + ])( + 'should return a condition for the search string %s with the value %s', + (searchString, expectedValue) => { + const result = buildBooleanFieldFilter( + 'fieldName', + new Set(['Equal']), + searchString, + false, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', expectedValue)); + }, + ); + + it('should return a match-none if the value is not recognized', () => { + const result = buildBooleanFieldFilter('fieldName', new Set(['Equal']), 'FOO', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('When isNegated is true', () => { + it.each([ + ['true', true], + ['1', true], + ['TRUE', true], + ['false', false], + ['0', false], + ['FALSE', false], + ])( + 'should construct the right condition for the search string %s', + (searchString, expectedValue) => { + const result = buildBooleanFieldFilter( + 'fieldName', + new Set(['NotEqual', 'Missing']), + searchString, + true, + ); + + expect(result).toBeInstanceOf(ConditionTreeBranch); + expect((result as ConditionTreeBranch).aggregator).toBe('Or'); + expect((result as ConditionTreeBranch).conditions).toEqual([ + new ConditionTreeLeaf('fieldName', 'NotEqual', expectedValue), + new ConditionTreeLeaf('fieldName', 'Missing', null), + ]); + }, + ); + }); + + it('should return a match-all if the value is not recognized', () => { + const result = buildBooleanFieldFilter('fieldName', new Set(['Equal']), 'FOO', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + + describe('if the field does not have the needed operator(s)', () => { + describe('when isNegated = false', () => { + it('should return a match-none', () => { + const result = buildBooleanFieldFilter('fieldName', new Set([]), 'true', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when isNegated = true', () => { + it('should return a match-all if the field does not have the Equal operator', () => { + const result = buildBooleanFieldFilter('fieldName', new Set(['Missing']), 'true', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + + it('should return a match-all if the field does not have the Missing operator', () => { + const result = buildBooleanFieldFilter('fieldName', new Set(['Equal']), 'true', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-date-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-date-field-filter.test.ts new file mode 100644 index 0000000000..92b58017e8 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-date-field-filter.test.ts @@ -0,0 +1,1187 @@ +import { + ColumnType, + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, + allOperators, +} from '@forestadmin/datasource-toolkit'; + +import buildDateFieldFilter from '../../../../src/decorators/search/filter-builder/build-date-field-filter'; + +describe('buildDateFieldFilter', () => { + describe('with type Dateonly', () => { + const columnType: ColumnType = 'Dateonly'; + const timezone = 'Europe/Paris'; + + describe('without an operator', () => { + describe('when not negated', () => { + const isNegated = false; + const operators: Operator[] = ['Equal', 'After', 'Before']; + + describe('when the search string is a date', () => { + it('should return a valid condition if the date is < 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-04'), + new ConditionTreeLeaf('fieldName', 'After', '2022-05-04'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2022-05-05'), + ), + ); + }); + + it('should return a valid condition if the date is >= 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05-10', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-10'), + new ConditionTreeLeaf('fieldName', 'After', '2022-05-10'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2022-05-11'), + ), + ); + }); + + it('should return a valid condition if the date the last day of the year', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-12-31', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-12-31'), + new ConditionTreeLeaf('fieldName', 'After', '2022-12-31'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2023-01-01'), + ), + ); + }); + }); + + describe('when the search string contains only a year and month', () => { + it('should return a valid condition when the month is < 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-05-01'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2022-06-01'), + ), + ); + }); + + it('should return a valid condition when the month is >= 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-10', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-10-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-10-01'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2022-11-01'), + ), + ); + }); + + it('should return a valid condition when the month is december', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-12', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-12-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-12-01'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2023-01-01'), + ), + ); + }); + }); + + describe('when the search string contains only a year', () => { + it('should return a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-01-01'), + ), + new ConditionTreeLeaf('fieldName', 'Before', '2023-01-01'), + ), + ); + }); + }); + + it.each(['123', '1799', 'foo'])( + 'should return match-none when the search string is %s', + searchString => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }, + ); + + it.each(operators)('should return match-none if the operator %s is missing', operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(x => x !== operator) as Operator[]), + searchString: '2022-01-01', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + const operators: Operator[] = ['Equal', 'After', 'Before', 'Missing']; + + describe('when the search string is a date', () => { + it('should return a valid condition if the date is < 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-05-04'), + new ConditionTreeLeaf('fieldName', 'After', '2022-05-05'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-05'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + + it('should return a valid condition if the date is >= 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05-10', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-05-10'), + new ConditionTreeLeaf('fieldName', 'After', '2022-05-11'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-11'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + + it('should return a valid condition if the date the last day of the year', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-12-31', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-12-31'), + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string contains only a year and month', () => { + it('should return a valid condition when the month is < 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-05-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-06-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-06-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + + it('should return a valid condition when the month is >= 10', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-10', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-10-01'), + new ConditionTreeLeaf('fieldName', 'After', '2022-11-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-11-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + + it('should return a valid condition when the month is december', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022-12', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-12-01'), + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string contains only a year', () => { + it('should return a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + it.each(['123', '1799', 'foo'])( + 'should return match-none when the search string is %s', + searchString => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it.each(operators.filter(o => o !== 'Missing'))( + 'should return match-none if the operator %s is missing', + operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set( + operators.filter(x => x !== operator) as Operator[], + ), + searchString: '2022-01-01', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it('should generate a condition without the missing operator when not available', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== 'Missing')), + searchString: '2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + ), + ); + }); + }); + }); + + describe('with the operator <', () => { + describe('when not negated', () => { + const isNegated = false; + const operators: Operator[] = ['Before']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022-04-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-04-05')); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-04-01')); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01')); + }); + }); + + it.each(operators)('should generate a match-none when %s is missing', operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== operator)), + searchString: '<2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + const operators: Operator[] = ['After', 'Equal', 'Missing']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022-04-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '<2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + it.each(operators.filter(o => o !== 'Missing'))( + 'should generate a match-all when %s is missing', + operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== operator)), + searchString: '<2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it('should generate condition without the missing operator when missing', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== 'Missing')), + searchString: '<2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + ), + ); + }); + }); + }); + + describe('with the operator >', () => { + describe('when not negated', () => { + const isNegated = false; + const operators: Operator[] = ['After', 'Equal']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022-04-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-06'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-06'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-05-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-01'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + ), + ); + }); + }); + + it.each(operators)('should generate a match-none when %s is missing', operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== operator)), + searchString: '>2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + const operators: Operator[] = ['Before', 'Equal', 'Missing']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022-04-05', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022-04', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: '>2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + it.each(operators.filter(o => o !== 'Missing'))( + 'should generate a match-all when %s is missing', + operator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== operator)), + searchString: '>2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it('should generate condition without the missing operator when missing', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== 'Missing')), + searchString: '>2022', + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + ), + ); + }); + }); + }); + + describe.each(['<=', '≤'])('with the operator %s', operator => { + describe('when not negated', () => { + const isNegated = false; + const operators: Operator[] = ['Before']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04-05`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-04-06')); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-05-01')); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2023-01-01')); + }); + }); + + it.each(operators)('should generate a match-none when %s is missing', missingOperator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== missingOperator)), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + const operators: Operator[] = ['After', 'Equal', 'Missing']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04-05`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-06'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-06'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-05-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-05-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + it.each(operators.filter(o => o !== 'Missing'))( + 'should generate a match-all when %s is missing', + missingOperator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== missingOperator)), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it('should generate condition without the missing operator when missing', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== 'Missing')), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2023-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2023-01-01'), + ), + ); + }); + }); + }); + + describe.each(['>=', '≥'])('with the operator %s', operator => { + describe('when not negated', () => { + const isNegated = false; + const operators: Operator[] = ['After', 'Equal']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04-05`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-05'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-04-01'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Equal', '2022-01-01'), + ), + ); + }); + }); + + it.each(operators)('should generate a match-none when %s is missing', missingOperator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== missingOperator)), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + const operators: Operator[] = ['Before', 'Missing']; + + describe('when the search string is a date', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04-05`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-04-05'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a month', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022-04`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-04-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when the search string is a year', () => { + it('should generate a valid condition', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + it.each(operators.filter(o => o !== 'Missing'))( + 'should generate a match-all when %s is missing', + missingOperator => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== missingOperator)), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }, + ); + + it('should generate condition without the missing operator when missing', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(operators.filter(o => o !== 'Missing')), + searchString: `${operator}2022`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Before', '2022-01-01')); + }); + }); + }); + + describe.each(['>', '<', '>=', '<=', '≤', '≥'])('with the operator %s', operator => { + describe('when not negated', () => { + const isNegated = false; + + it('should return match-none when the rest is not a valid date', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(allOperators), + searchString: `${operator}FOO`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + it('should return match-all when the rest is not a valid date', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(allOperators), + searchString: `${operator}FOO`, + isNegated, + columnType, + timezone, + }); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe('with type Date', () => { + const columnType = 'Date'; + + describe.each([ + ['Pacific/Midway', '-11:00'], + ['Pacific/Kiritimati', '+14:00'], + ])('in timezone %s (UTC%s)', (timezone, offset) => { + describe('with no operator', () => { + it('should generate a condition with the right translated date', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(allOperators), + searchString: '2022-01-01', + isNegated: false, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'Equal', `2022-01-01T00:00:00.000${offset}`), + new ConditionTreeLeaf('fieldName', 'After', `2022-01-01T00:00:00.000${offset}`), + ), + new ConditionTreeLeaf('fieldName', 'Before', `2022-01-02T00:00:00.000${offset}`), + ), + ); + }); + }); + + describe('with an operator', () => { + it('should generate a condition with the right translated date', () => { + const result = buildDateFieldFilter({ + field: 'fieldName', + filterOperators: new Set(allOperators), + searchString: '>=2022-01-01', + isNegated: false, + columnType, + timezone, + }); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'After', `2022-01-01T00:00:00.000${offset}`), + new ConditionTreeLeaf('fieldName', 'Equal', `2022-01-01T00:00:00.000${offset}`), + ), + ); + }); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-array-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-array-field-filter.test.ts new file mode 100644 index 0000000000..7afa71e3ff --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-array-field-filter.test.ts @@ -0,0 +1,62 @@ +import { + ColumnSchema, + ConditionTreeFactory, + ConditionTreeLeaf, +} from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'; +import buildEnumArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-enum-array-field-filter'; + +jest.mock('../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'); + +describe('buildEnumArrayFieldFilter', () => { + const schema: ColumnSchema = { + columnType: ['Enum'], + enumValues: ['foo', 'bar'], + filterOperators: new Set(['In']), + type: 'Column', + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('when the value can be mapped to an enum value', () => { + it.each(['foo', 'FOO', ' foo '])( + 'should delegate to the basic array builder for the value "%s"', + value => { + const expectedResult = new ConditionTreeLeaf('field', 'In', 'foo'); + + jest.mocked(buildBasicArrayFieldFilter).mockReturnValue(expectedResult); + + const result = buildEnumArrayFieldFilter('field', schema, value, false); + + expect(buildBasicArrayFieldFilter).toHaveBeenCalledWith( + 'field', + schema.filterOperators, + 'foo', + false, + ); + expect(result).toBe(expectedResult); + }, + ); + }); + + describe('when the value cannot be mapped to an enum value', () => { + describe('when not negated', () => { + it('should return a match none condition tree', () => { + const result = buildEnumArrayFieldFilter('field', schema, 'baz', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + it('should return a match all condition tree', () => { + const result = buildEnumArrayFieldFilter('field', schema, 'baz', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-field-filter.test.ts new file mode 100644 index 0000000000..d3ef4abd2a --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-enum-field-filter.test.ts @@ -0,0 +1,134 @@ +import { + ColumnSchema, + ConditionTreeFactory, + ConditionTreeLeaf, + allOperators, +} from '@forestadmin/datasource-toolkit'; + +import buildEnumFieldFilter from '../../../../src/decorators/search/filter-builder/build-enum-field-filter'; + +describe('buildEnumFieldFilter', () => { + describe('when the value is part of the enum', () => { + describe('when not negated', () => { + const isNegated = false; + + describe('when Equal is supported', () => { + const schema: ColumnSchema = { + enumValues: ['foo', 'bar'], + columnType: 'Enum', + type: 'Column', + filterOperators: new Set(['Equal']), + }; + + it('should return a valid condition tree', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'foo', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', 'foo')); + }); + + describe('lenient find', () => { + it('should return a condition when the casing is different', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'FOO', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', 'foo')); + }); + + it('should return a condition when the value has extra spaces', () => { + const result = buildEnumFieldFilter('fieldName', schema, ' foo ', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', 'foo')); + }); + }); + }); + + describe('when Equal is not supported', () => { + it('should return match-none', () => { + const schema: ColumnSchema = { + enumValues: ['foo', 'bar'], + columnType: 'Enum', + type: 'Column', + filterOperators: new Set([]), + }; + const result = buildEnumFieldFilter('fieldName', schema, 'foo', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when NotEqual and Missing are supported', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Enum', + enumValues: ['foo', 'bar'], + filterOperators: new Set(['NotEqual', 'Missing']), + }; + + it('should return a valid condition tree', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'foo', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotEqual', 'foo'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when NotEqual only is supported', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Enum', + enumValues: ['foo', 'bar'], + filterOperators: new Set(['NotEqual']), + }; + + it('should return a valid condition tree', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'foo', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotEqual', 'foo')); + }); + }); + + describe('when NotEqual is not supported', () => { + it('should return match-all', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Enum', + enumValues: ['foo', 'bar'], + filterOperators: new Set(['Missing']), + }; + + const result = buildEnumFieldFilter('fieldName', schema, 'foo', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe('when the value is not part of the enum', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Enum', + filterOperators: new Set(allOperators), + enumValues: ['foo', 'bar'], + }; + + it('should return match-none when isNegated=false', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'fooBAR', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + + it('should return match-all when isNegated=true', () => { + const result = buildEnumFieldFilter('fieldName', schema, 'fooBAR', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-array-field.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-array-field.test.ts new file mode 100644 index 0000000000..2ca70bfead --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-array-field.test.ts @@ -0,0 +1,49 @@ +import { ConditionTreeFactory, ConditionTreeLeaf, Operator } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'; +import buildNumberArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-number-array-field-filter'; + +jest.mock('../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'); + +describe('buildNumberArrayFieldFilter', () => { + const operators = new Set(['In']); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('when the value is a valid number', () => { + const searchValue = '42'; + + it('should delegate to the basic array builder', () => { + const expectedResult = new ConditionTreeLeaf('field', 'In', 42); + + jest.mocked(buildBasicArrayFieldFilter).mockReturnValue(expectedResult); + + const result = buildNumberArrayFieldFilter('field', operators, searchValue, false); + + expect(buildBasicArrayFieldFilter).toHaveBeenCalledWith('field', operators, 42, false); + expect(result).toBe(expectedResult); + }); + }); + + describe('when the value is not a number', () => { + const searchValue = 'not a number'; + + describe('when not negated', () => { + it('should return a match none condition tree', () => { + const result = buildNumberArrayFieldFilter('field', operators, searchValue, false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + it('should return a match all condition tree', () => { + const result = buildNumberArrayFieldFilter('field', operators, searchValue, true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-field-filter.test.ts new file mode 100644 index 0000000000..e39705cd69 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-number-field-filter.test.ts @@ -0,0 +1,427 @@ +import { + ConditionTreeFactory, + ConditionTreeLeaf, + Operator, + allOperators, +} from '@forestadmin/datasource-toolkit'; + +import buildNumberFieldFilter from '../../../../src/decorators/search/filter-builder/build-number-field-filter'; + +describe('buildNumberFieldFilter', () => { + describe('when the search string is valid', () => { + describe.each(['', '='])('Equal with prefix "%s"', prefix => { + const searchString = `${prefix}42`; + + describe('when not negated', () => { + const isNegated = false; + + describe('when the operator Equal is present', () => { + const operators = new Set(['Equal']); + + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', 42)); + }); + }); + + describe('when the operator Equal is not present', () => { + const operators = new Set([]); + + it('should return a match-none', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when operators NotEqual and Missing are present', () => { + const operators = new Set(['NotEqual', 'Missing']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotEqual', 42), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when only the operator NotEqual is present', () => { + const operators = new Set(['NotEqual']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotEqual', 42)); + }); + }); + + describe('when operators are absent', () => { + const operators = new Set([]); + + it('should return a match-all', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe('LessThan', () => { + const searchString = `<42`; + + describe('when not negated', () => { + const isNegated = false; + + describe('when the operator LessThan is present', () => { + const operators = new Set(['LessThan']); + + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'LessThan', 42)); + }); + }); + + describe('when the operator LessThan is not present', () => { + const operators = new Set([]); + + it('should return a match-none', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when operators GreaterThan, Equal and Missing are present', () => { + const operators = new Set(['GreaterThan', 'Equal', 'Missing']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'GreaterThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when only the operator GreaterThan and Equal are present', () => { + const operators = new Set(['GreaterThan', 'Equal']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'GreaterThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + ), + ); + }); + }); + + describe.each(['Equal', 'GreaterThan'])( + 'when the operator %s is absent', + missingOperator => { + const operators = new Set( + (['Equal', 'GreaterThan', 'Missing'] as Operator[]).filter( + o => o !== missingOperator, + ), + ); + + it('should return a match-all', () => { + const result = buildNumberFieldFilter( + 'fieldName', + operators, + searchString, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }, + ); + }); + }); + + describe('GreaterThan', () => { + const searchString = `>42`; + + describe('when not negated', () => { + const isNegated = false; + + describe('when the operator GreaterThan is present', () => { + const operators = new Set(['GreaterThan']); + + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'GreaterThan', 42)); + }); + }); + + describe('when the operator GreaterThan is not present', () => { + const operators = new Set([]); + + it('should return a match-none', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when operators LessThan, Equal and Missing are present', () => { + const operators = new Set(['LessThan', 'Equal', 'Missing']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'LessThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when only the operator LessThan and Equal are present', () => { + const operators = new Set(['LessThan', 'Equal']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'LessThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + ), + ); + }); + }); + + describe.each(['Equal', 'LessThan'])('when the operator %s is absent', missingOperator => { + const operators = new Set( + (['Equal', 'LessThan', 'Missing'] as Operator[]).filter(o => o !== missingOperator), + ); + + it('should return a match-all', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe.each(['<=', '≤'])('LessThanOrEqual with prefix "%s"', prefix => { + const searchString = `${prefix}42`; + + describe('when not negated', () => { + const isNegated = false; + + describe('when the operators LessThan and Equal are present', () => { + const operators = new Set(['LessThan', 'Equal']); + + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'LessThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + ), + ); + }); + }); + + describe.each(['LessThan', 'Equal'])( + 'when the operator %s is not present', + missingOperator => { + const operators = new Set( + ['LessThan', 'Equal'].filter(o => o !== missingOperator) as Operator[], + ); + + it('should return a match-none', () => { + const result = buildNumberFieldFilter( + 'fieldName', + operators, + searchString, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }, + ); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when operators GreaterThan and Missing are present', () => { + const operators = new Set(['GreaterThan', 'Missing']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'GreaterThan', 42), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when only the operator GreaterThan is present', () => { + const operators = new Set(['GreaterThan']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'GreaterThan', 42)); + }); + }); + + describe('when GreaterThan is absent', () => { + const operators = new Set(['Missing']); + + it('should return a match-all', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe.each(['>=', '≥'])('GreaterThanOrEqual with prefix "%s"', prefix => { + const searchString = `${prefix}42`; + + describe('when not negated', () => { + const isNegated = false; + + describe('when the operators GreaterThan and Equal are present', () => { + const operators = new Set(['GreaterThan', 'Equal']); + + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'GreaterThan', 42), + new ConditionTreeLeaf('fieldName', 'Equal', 42), + ), + ); + }); + }); + + describe.each(['LessThan', 'Equal'])( + 'when the operator %s is not present', + missingOperator => { + const operators = new Set( + ['LessThan', 'Equal'].filter(o => o !== missingOperator) as Operator[], + ); + + it('should return a match-none', () => { + const result = buildNumberFieldFilter( + 'fieldName', + operators, + searchString, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }, + ); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when operators LessThan and Missing are present', () => { + const operators = new Set(['LessThan', 'Missing']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'LessThan', 42), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when only the operator LessThan is present', () => { + const operators = new Set(['LessThan']); + it('should return a valid condition tree', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'LessThan', 42)); + }); + }); + + describe('when LessThan is absent', () => { + const operators = new Set(['Missing']); + + it('should return a match-all', () => { + const result = buildNumberFieldFilter('fieldName', operators, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + }); + + describe('when the search string is invalid', () => { + const searchString = 'Not a number'; + + describe('when negated', () => { + const isNegated = true; + + it('should return match-all', () => { + const result = buildNumberFieldFilter( + 'fieldName', + new Set(allOperators), + searchString, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + + describe('when not negated', () => { + const isNegated = false; + + it('should return match-none', () => { + const result = buildNumberFieldFilter( + 'fieldName', + new Set(allOperators), + searchString, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-array-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-array-field-filter.test.ts new file mode 100644 index 0000000000..6cc6de9dd0 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-array-field-filter.test.ts @@ -0,0 +1,25 @@ +import { ConditionTreeLeaf, Operator } from '@forestadmin/datasource-toolkit'; + +import buildBasicArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'; +import buildStringArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-string-array-field-filter'; + +jest.mock('../../../../src/decorators/search/filter-builder/build-basic-array-field-filter'); + +describe('buildStringArrayFieldFilter', () => { + const operators = new Set(['In']); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should delegate to the basic array builder', () => { + const expectedResult = new ConditionTreeLeaf('field', 'In', 'value'); + + jest.mocked(buildBasicArrayFieldFilter).mockReturnValue(expectedResult); + + const result = buildStringArrayFieldFilter('field', operators, 'value', false); + + expect(buildBasicArrayFieldFilter).toHaveBeenCalledWith('field', operators, 'value', false); + expect(result).toBe(expectedResult); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-field-filter.test.ts new file mode 100644 index 0000000000..f6dc7d1f4a --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-string-field-filter.test.ts @@ -0,0 +1,215 @@ +import { ConditionTreeFactory, ConditionTreeLeaf, Operator } from '@forestadmin/datasource-toolkit'; + +import buildStringFieldFilter from '../../../../src/decorators/search/filter-builder/build-string-field-filter'; + +describe('buildStringFieldFilter', () => { + describe('when all major operators are supported', () => { + const operators = new Set([ + 'IContains', + 'NotIContains', + 'Contains', + 'NotContains', + 'Equal', + 'NotEqual', + 'Missing', + ]); + + describe('when not negated', () => { + const isNegated = false; + + it('should return a condition tree with IContains', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'IContains', 'Foo')); + }); + + it('should generate a Equal condition tree when the search string is empty', () => { + const result = buildStringFieldFilter('fieldName', operators, '', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', '')); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when Missing is supported', () => { + it('should return a condition tree with NotIContains and Missing', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotIContains', 'Foo'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + + it('should generate a NotEqual condition tree when the search string is empty', () => { + const result = buildStringFieldFilter('fieldName', operators, '', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotEqual', ''), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when Missing is not supported', () => { + it('should return a condition tree with only NotIContains', () => { + const result = buildStringFieldFilter( + 'fieldName', + new Set(Array.from(operators).filter(o => o !== 'Missing')), + 'Foo', + isNegated, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotIContains', 'Foo')); + }); + + it('should generate a NotEqual condition tree when the search string is empty', () => { + const result = buildStringFieldFilter( + 'fieldName', + new Set(Array.from(operators).filter(o => o !== 'Missing')), + '', + isNegated, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotEqual', '')); + }); + }); + }); + }); + + describe('when IContains/NotIContains are not supported but the others are', () => { + const operators = new Set([ + 'Contains', + 'NotContains', + 'Equal', + 'NotEqual', + 'Missing', + ]); + + describe('when not negated', () => { + const isNegated = false; + + it('should return a condition tree with Contains', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Contains', 'Foo')); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when Missing is supported', () => { + it('should return a condition tree with NotContains and Missing', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotContains', 'Foo'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when Missing is not supported', () => { + it('should return a condition tree with only NotContains', () => { + const result = buildStringFieldFilter( + 'fieldName', + new Set(Array.from(operators).filter(o => o !== 'Missing')), + 'Foo', + isNegated, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotContains', 'Foo')); + }); + }); + }); + }); + + describe('when only Equal/NotEqual are supported', () => { + const operators = new Set(['Equal', 'NotEqual', 'Missing']); + + describe('when not negated', () => { + const isNegated = false; + + it('should return a condition tree with Equal', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', 'Foo')); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when Missing is supported', () => { + it('should return a condition tree with NotEqual and Missing', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotEqual', 'Foo'), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when Missing is not supported', () => { + it('should return a condition tree with only NotEqual', () => { + const result = buildStringFieldFilter( + 'fieldName', + new Set(Array.from(operators).filter(o => o !== 'Missing')), + 'Foo', + isNegated, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotEqual', 'Foo')); + }); + }); + }); + }); + + describe('when operators are not supported', () => { + const operators: Set = new Set(); + + describe('when not negated', () => { + const isNegated = false; + + it('should generate a match-none', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + + it('should generate a match-none when the search string is empty', () => { + const result = buildStringFieldFilter('fieldName', operators, '', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + it('should generate a match-all', () => { + const result = buildStringFieldFilter('fieldName', operators, 'Foo', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + + it('should generate a match-all when the search string is empty', () => { + const result = buildStringFieldFilter('fieldName', operators, '', isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/build-uuid-field-filter.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/build-uuid-field-filter.test.ts new file mode 100644 index 0000000000..e7cd665c1f --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/build-uuid-field-filter.test.ts @@ -0,0 +1,94 @@ +import { ConditionTreeFactory, ConditionTreeLeaf, Operator } from '@forestadmin/datasource-toolkit'; + +import buildUuidFieldFilter from '../../../../src/decorators/search/filter-builder/build-uuid-field-filter'; + +describe('buildUuidfieldFilter', () => { + describe('when the value is a valid uuid', () => { + const value = '123e4567-e89b-12d3-a456-426614174000'; + const allOperators = new Set(['Equal', 'NotEqual', 'Missing']); + + describe('when not negated', () => { + const isNegated = false; + + it('should return a condition tree with Equal if the operator is present', () => { + const result = buildUuidFieldFilter('fieldName', allOperators, value, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'Equal', value)); + }); + + it('should return match-none if the operator is not present', () => { + const result = buildUuidFieldFilter('fieldName', new Set(), value, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + describe('when all operators are present', () => { + it('should return a condition tree with NotEqual and Missing', () => { + const result = buildUuidFieldFilter('fieldName', allOperators, value, isNegated); + + expect(result).toEqual( + ConditionTreeFactory.union( + new ConditionTreeLeaf('fieldName', 'NotEqual', value), + new ConditionTreeLeaf('fieldName', 'Missing'), + ), + ); + }); + }); + + describe('when missing is not present', () => { + it('should return a condition tree with NotEqual', () => { + const result = buildUuidFieldFilter( + 'fieldName', + new Set(['Equal', 'NotEqual']), + value, + isNegated, + ); + + expect(result).toEqual(new ConditionTreeLeaf('fieldName', 'NotEqual', value)); + }); + }); + + describe('when not equal is not present', () => { + it('should return a match all', () => { + const result = buildUuidFieldFilter( + 'fieldName', + new Set(['Equal', 'Missing']), + value, + isNegated, + ); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + }); + + describe('when the value is not a valid uuid', () => { + const value = 'not-a-uuid'; + const allOperators = new Set(['Equal', 'NotEqual', 'Missing']); + + describe('when not negated', () => { + const isNegated = false; + + it('should return match-none', () => { + const result = buildUuidFieldFilter('fieldName', allOperators, value, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + const isNegated = true; + + it('should return match-all', () => { + const result = buildUuidFieldFilter('fieldName', allOperators, value, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/filter-builder/index.test.ts b/packages/datasource-customizer/test/decorators/search/filter-builder/index.test.ts new file mode 100644 index 0000000000..3beb5dde6b --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/filter-builder/index.test.ts @@ -0,0 +1,226 @@ +import { + Caller, + ColumnSchema, + ConditionTreeFactory, + ConditionTreeLeaf, + PrimitiveTypes, + allOperators, +} from '@forestadmin/datasource-toolkit'; + +import buildFieldFilter from '../../../../src/decorators/search/filter-builder'; +import buildBooleanFieldFilter from '../../../../src/decorators/search/filter-builder/build-boolean-field-filter'; +import buildDateFieldFilter from '../../../../src/decorators/search/filter-builder/build-date-field-filter'; +import buildEnumArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-enum-array-field-filter'; +import buildEnumFieldFilter from '../../../../src/decorators/search/filter-builder/build-enum-field-filter'; +import buildNumberArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-number-array-field-filter'; +import buildNumberFieldFilter from '../../../../src/decorators/search/filter-builder/build-number-field-filter'; +import buildStringArrayFieldFilter from '../../../../src/decorators/search/filter-builder/build-string-array-field-filter'; +import buildStringFieldFilter from '../../../../src/decorators/search/filter-builder/build-string-field-filter'; +import buildUuidFieldFilter from '../../../../src/decorators/search/filter-builder/build-uuid-field-filter'; + +jest.mock('../../../../src/decorators/search/filter-builder/build-boolean-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-date-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-enum-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-number-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-string-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-uuid-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-number-array-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-string-array-field-filter'); +jest.mock('../../../../src/decorators/search/filter-builder/build-enum-array-field-filter'); + +const BUILDER_BY_TYPE: Record< + PrimitiveTypes, + | { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + builder: jest.MaybeMockedDeep; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrayBuilder?: jest.MaybeMockedDeep; + callWithSchema?: true; + structuredCall?: true; + } + | undefined +> = { + Boolean: { builder: jest.mocked(buildBooleanFieldFilter) }, + Date: { builder: jest.mocked(buildDateFieldFilter), structuredCall: true }, + Dateonly: { builder: jest.mocked(buildDateFieldFilter), structuredCall: true }, + Enum: { + builder: jest.mocked(buildEnumFieldFilter), + arrayBuilder: jest.mocked(buildEnumArrayFieldFilter), + callWithSchema: true, + }, + Number: { + builder: jest.mocked(buildNumberFieldFilter), + arrayBuilder: jest.mocked(buildNumberArrayFieldFilter), + }, + String: { + builder: jest.mocked(buildStringFieldFilter), + arrayBuilder: jest.mocked(buildStringArrayFieldFilter), + }, + Uuid: { builder: jest.mocked(buildUuidFieldFilter) }, + Json: undefined, + Binary: undefined, + Point: undefined, + Time: undefined, +}; + +describe('buildFieldFilter', () => { + const field = 'fieldName'; + const caller: Caller = { + id: 42, + } as Caller; + + describe('with a NULL search string', () => { + const searchString = 'NULL'; + + describe('when not negated', () => { + const isNegated = false; + + it('returns a valid condition tree if the operator Missing is present', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Number', + filterOperators: new Set(['Missing']), + }; + const result = buildFieldFilter(caller, field, schema, searchString, isNegated); + + expect(result).toEqual(new ConditionTreeLeaf(field, 'Missing')); + }); + + it('returns a match-none if the operator missing is missing', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Number', + filterOperators: new Set([]), + }; + const result = buildFieldFilter(caller, field, schema, searchString, isNegated); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('when negated', () => { + it('returns a valid condition if the operator Present is present', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Number', + filterOperators: new Set(['Present']), + }; + const result = buildFieldFilter(caller, field, schema, searchString, true); + + expect(result).toEqual(new ConditionTreeLeaf(field, 'Present')); + }); + + it('should return match-all if the operator is missing', () => { + const schema: ColumnSchema = { + type: 'Column', + columnType: 'Number', + filterOperators: new Set([]), + }; + const result = buildFieldFilter(caller, field, schema, searchString, true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + }); + + describe.each(Object.entries(BUILDER_BY_TYPE))('for type %s', (type, expected) => { + const schema: ColumnSchema = { + type: 'Column', + columnType: type as PrimitiveTypes, + filterOperators: new Set(allOperators), + }; + + if (expected) { + it('should call the builder with the right arguments', () => { + buildFieldFilter(caller, field, schema, 'searchString', false); + + const expectedArguments = expected.structuredCall + ? [ + { + field, + filterOperators: schema.filterOperators, + searchString: 'searchString', + isNegated: false, + columnType: schema.columnType, + timezone: caller.timezone, + }, + ] + : [ + field, + expected.callWithSchema ? schema : schema.filterOperators, + 'searchString', + false, + ]; + + expect(expected.builder).toHaveBeenCalledWith(...expectedArguments); + }); + + it('should pass isNegated = true if negated', () => { + buildFieldFilter(caller, field, schema, 'searchString', true); + + const expectedArguments = expected.structuredCall + ? [ + { + field, + filterOperators: schema.filterOperators, + searchString: 'searchString', + isNegated: true, + columnType: schema.columnType, + timezone: caller.timezone, + }, + ] + : [ + field, + expected.callWithSchema ? schema : schema.filterOperators, + 'searchString', + true, + ]; + + expect(expected.builder).toHaveBeenCalledWith(...expectedArguments); + }); + + describe('for array types', () => { + const arraySchema: ColumnSchema = { + type: 'Column', + columnType: [type as PrimitiveTypes], + filterOperators: new Set(allOperators), + }; + + if (expected.arrayBuilder) { + it('should call the arrayBuilder with the right arguments', () => { + buildFieldFilter(caller, field, arraySchema, 'searchString', true); + + expect(expected.arrayBuilder).toHaveBeenCalledWith( + field, + expected.callWithSchema ? arraySchema : arraySchema.filterOperators, + 'searchString', + true, + ); + }); + } else { + it('should have returned the default condition', () => { + const result = buildFieldFilter(caller, field, arraySchema, 'searchString', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + } + }); + } else { + describe('if not negated', () => { + it('should return match-none', () => { + const result = buildFieldFilter(caller, field, schema, 'searchString', false); + + expect(result).toEqual(ConditionTreeFactory.MatchNone); + }); + }); + + describe('if negated', () => { + it('should return match-all', () => { + const result = buildFieldFilter(caller, field, schema, 'searchString', true); + + expect(result).toEqual(ConditionTreeFactory.MatchAll); + }); + }); + } + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/parse-query.test.ts b/packages/datasource-customizer/test/decorators/search/parse-query.test.ts new file mode 100644 index 0000000000..0340b6984c --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/parse-query.test.ts @@ -0,0 +1,970 @@ +import { + Caller, + ColumnSchema, + ConditionTreeFactory, + Operator, +} from '@forestadmin/datasource-toolkit'; + +import { generateConditionTree, parseQuery } from '../../../src/decorators/search/parse-query'; + +describe('generateConditionTree', () => { + const caller: Caller = { + id: 42, + } as Caller; + const titleField: [string, ColumnSchema] = [ + 'title', + { + columnType: 'String', + type: 'Column', + filterOperators: new Set([ + 'IContains', + 'Missing', + 'NotIContains', + 'Present', + 'Equal', + 'NotEqual', + ]), + }, + ]; + + const descriptionField: [string, ColumnSchema] = [ + 'description', + { + columnType: 'String', + type: 'Column', + filterOperators: new Set([ + 'IContains', + 'Missing', + 'NotIContains', + 'Present', + 'Equal', + 'NotEqual', + ]), + }, + ]; + + function parseQueryAndGenerateCondition(search: string, fields: [string, ColumnSchema][]) { + const conditionTree = parseQuery(search); + + return generateConditionTree(caller, conditionTree, fields); + } + + describe('single word', () => { + describe('String fields', () => { + it.each(['foo', 'UNICODE_ÈÉÀÇÏŒÙØåΩÓ¥', '42.43.44'])( + 'should return a unique work with %s', + word => { + expect(parseQueryAndGenerateCondition(word, [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: word, + }), + ); + }, + ); + + it('should generate a condition tree with each field', () => { + expect(parseQueryAndGenerateCondition('foo', [titleField, descriptionField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'description', + value: 'foo', + }), + ), + ); + }); + + it('should generate a condition for each token', () => { + expect(parseQueryAndGenerateCondition('foo 52.53.54', [titleField])).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: '52.53.54', + }), + ), + ); + }); + }); + + describe('Number fields', () => { + const scoreField: [string, ColumnSchema] = [ + 'score', + { + columnType: 'Number', + type: 'Column', + filterOperators: new Set(['Equal', 'GreaterThan', 'LessThan']), + }, + ]; + + it.each([42, 37.5, -199, 0])( + 'should return a valid condition tree if the value is a number (%s)', + value => { + expect(parseQueryAndGenerateCondition(`${value}`, [scoreField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'score', + value, + }), + ); + }, + ); + + it.each(['foo', '', '42.43.44', '-42.43.44'])( + 'should return null if the value is not a number (%s)', + value => { + expect(parseQueryAndGenerateCondition(value, [scoreField])).toEqual({ + aggregator: 'Or', + conditions: [], + }); + }, + ); + + describe('with operators', () => { + it('should correctly parse the operator >', () => { + expect(parseQueryAndGenerateCondition('>42', [scoreField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'GreaterThan', + field: 'score', + value: 42, + }), + ); + }); + + it('should correctly parse the operator >=', () => { + expect(parseQueryAndGenerateCondition('>=42', [scoreField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + operator: 'GreaterThan', + field: 'score', + value: 42, + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'score', + value: 42, + }), + ), + ); + }); + + it('should correctly parse the operator <', () => { + expect(parseQueryAndGenerateCondition('<42', [scoreField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'LessThan', + field: 'score', + value: 42, + }), + ); + }); + + it('should correctly parse the operator <=', () => { + expect(parseQueryAndGenerateCondition('<=42', [scoreField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + operator: 'LessThan', + field: 'score', + value: 42, + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'score', + value: 42, + }), + ), + ); + }); + }); + }); + + describe('Boolean fields', () => { + const isActive: [string, ColumnSchema] = [ + 'isActive', + { + columnType: 'Boolean', + type: 'Column', + filterOperators: new Set(['Equal']), + }, + ]; + + it.each(['true', 'True', 'TRUE', '1'])( + 'should return a valid condition tree if the value is a boolean (%s)', + value => { + expect(parseQueryAndGenerateCondition(`${value}`, [isActive])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'isActive', + value: true, + }), + ); + }, + ); + + it.each(['false', 'False', 'FALSE', '0'])( + 'should return a valid condition tree if the value is a boolean (%s)', + value => { + expect(parseQueryAndGenerateCondition(`${value}`, [isActive])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'isActive', + value: false, + }), + ); + }, + ); + + it('should not generate a condition tree if the value is not a boolean', () => { + expect(parseQueryAndGenerateCondition('foo', [isActive])).toEqual({ + aggregator: 'Or', + conditions: [], + }); + }); + }); + }); + + describe('negated word', () => { + it.each(['-foo', '-42.43.44'])('should return a negated condition tree for value %s', value => { + expect(parseQueryAndGenerateCondition(value, [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'NotIContains', + value: value.slice(1), + }, + { + field: 'title', + operator: 'Missing', + }, + ], + }), + ); + }); + + it('should return a negated condition for multiple fields', () => { + expect(parseQueryAndGenerateCondition('-foo', [titleField, descriptionField])).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'NotIContains', + value: 'foo', + }, + { + field: 'title', + operator: 'Missing', + }, + ], + }), + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'description', + operator: 'NotIContains', + value: 'foo', + }, + { + field: 'description', + operator: 'Missing', + }, + ], + }), + ), + ); + }); + }); + + describe('quoted text', () => { + describe('with spaces', () => { + describe('double quotes', () => { + it('should return a condition tree with the quoted text', () => { + expect(parseQueryAndGenerateCondition('"foo bar"', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo bar', + }), + ); + }); + }); + + describe('simple quotes', () => { + it('should return a condition tree with the quoted text', () => { + expect(parseQueryAndGenerateCondition("'foo bar'", [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo bar', + }), + ); + }); + }); + }); + }); + + describe('multiple tokens', () => { + it('should generate a AND aggregation with a condition for each token', () => { + expect(parseQueryAndGenerateCondition('foo bar', [titleField])).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'bar', + }), + ), + ); + }); + }); + + describe('property/value pair', () => { + const fields = [titleField, descriptionField]; + + describe('when the value is a word', () => { + it('should generate a condition tree with the property and the value', () => { + expect(parseQueryAndGenerateCondition('title:foo', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo', + }), + ); + }); + + it('should correctly detect property names of 1 character', () => { + expect(parseQueryAndGenerateCondition('t:foo', [['t', titleField[1]]])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 't', + value: 'foo', + }), + ); + }); + + it('should correctly detect property names of 2 character', () => { + expect(parseQueryAndGenerateCondition('ti:foo', [['ti', titleField[1]]])).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'ti', + value: 'foo', + }), + ); + }); + }); + + describe('special values', () => { + describe('when the value is NULL', () => { + it('should generate a condition tree with the property and the value', () => { + expect(parseQueryAndGenerateCondition('title:NULL', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Missing', + field: 'title', + }), + ); + }); + }); + }); + + describe('when the value is quoted', () => { + it('should generate a condition tree with the property and the value', () => { + expect(parseQueryAndGenerateCondition('title:"foo bar"', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo bar', + }), + ); + }); + }); + + describe('when negated', () => { + it('should generate a condition tree with the property and the value', () => { + expect(parseQueryAndGenerateCondition('-title:foo', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'NotIContains', + value: 'foo', + }, + { + field: 'title', + operator: 'Missing', + }, + ], + }), + ); + }); + + describe('when the value is NULL', () => { + it('should generate a condition tree with the property and the value', () => { + expect(parseQueryAndGenerateCondition('-title:NULL', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Present', + field: 'title', + }), + ); + }); + }); + }); + + describe('when the property does not exist', () => { + it('should consider the search token as a whole', () => { + expect(parseQueryAndGenerateCondition('foo:bar title:foo', fields)).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo:bar', + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'description', + value: 'foo:bar', + }), + ), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'foo', + }), + ), + ); + }); + }); + + describe('when the value is an empty string', () => { + it('should generate a condition with an empty string', () => { + expect(parseQueryAndGenerateCondition('title:""', fields)).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'Equal', + field: 'title', + value: '', + }), + ); + }); + }); + + describe('when the property name contains a path', () => { + it('should generate a condition tree with the property and the value', () => { + const commentTitle: [string, ColumnSchema] = [ + 'comment:title', + { + columnType: 'String', + type: 'Column', + filterOperators: new Set(['IContains']), + }, + ]; + + expect( + parseQueryAndGenerateCondition('comment.title:foo', [...fields, commentTitle]), + ).toEqual( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'comment:title', + value: 'foo', + }), + ); + }); + }); + }); + + describe('when the syntax is incorrect', () => { + it('should generate a single condition tree with the default field', () => { + expect( + parseQueryAndGenerateCondition('tit:le:foo bar', [titleField, descriptionField]), + ).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'title', + value: 'tit:le:foo bar', + }), + ConditionTreeFactory.fromPlainObject({ + operator: 'IContains', + field: 'description', + value: 'tit:le:foo bar', + }), + ), + ); + }); + }); + + describe('OR syntax', () => { + it('should combine multiple conditions in a or statement', () => { + expect(parseQueryAndGenerateCondition('foo OR bar', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }), + ); + }); + + it('should combine multiple conditions on multiple fields in a or statement', () => { + expect(parseQueryAndGenerateCondition('foo OR bar', [titleField, descriptionField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'description', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + { + field: 'description', + operator: 'IContains', + value: 'bar', + }, + ], + }), + ); + }); + }); + + describe('AND syntax', () => { + it('should combine multiple conditions in a and statement', () => { + expect(parseQueryAndGenerateCondition('foo AND bar', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }), + ); + }); + + it('should combine multiple conditions on multiple fields in a and statement', () => { + expect(parseQueryAndGenerateCondition('foo AND bar', [titleField, descriptionField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'And', + conditions: [ + { + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'description', + operator: 'IContains', + value: 'foo', + }, + ], + }, + { + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + { + field: 'description', + operator: 'IContains', + value: 'bar', + }, + ], + }, + ], + }), + ); + }); + }); + + describe('OR and AND combined', () => { + it('should generate conditions with the correct priority with A OR B AND C', () => { + expect(parseQueryAndGenerateCondition('foo OR bar AND baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + ], + }, + ], + }), + ); + }); + + it('should generate conditions with the correct priority with A OR B C', () => { + expect(parseQueryAndGenerateCondition('foo OR bar baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + ], + }, + ], + }), + ); + }); + + it('should generate conditions with the correct priority with A AND B OR C', () => { + expect(parseQueryAndGenerateCondition('foo AND bar OR baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }, + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + ], + }), + ); + }); + + it('should generate conditions with the correct priority with A B OR C', () => { + expect(parseQueryAndGenerateCondition('foo bar OR baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'Or', + conditions: [ + { + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }, + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + ], + }), + ); + }); + + describe('with parenthesis', () => { + it('should apply the right priority to (A OR B) AND C', () => { + expect(parseQueryAndGenerateCondition('(foo OR bar) AND baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'And', + conditions: [ + { + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }, + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + ], + }), + ); + }); + + it('should apply the right priority to C AND (A OR B)', () => { + expect(parseQueryAndGenerateCondition('baz AND (foo OR bar)', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + aggregator: 'And', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'baz', + }, + { + aggregator: 'Or', + conditions: [ + { + field: 'title', + operator: 'IContains', + value: 'foo', + }, + { + field: 'title', + operator: 'IContains', + value: 'bar', + }, + ], + }, + ], + }), + ); + }); + + it('should correctly interpret nested conditions', () => { + expect( + parseQueryAndGenerateCondition('foo AND (bar OR (baz AND foo))', [titleField]), + ).toEqual( + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'bar', + }), + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'baz', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ), + ), + ), + ); + }); + + it('should work with (A OR B)', () => { + expect(parseQueryAndGenerateCondition('(foo OR bar)', [titleField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'bar', + }), + ), + ); + }); + + it('should work if parenthesis are spaced', () => { + expect(parseQueryAndGenerateCondition('( foo OR bar )', [titleField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'bar', + }), + ), + ); + }); + + it('should work with trailing spaces', () => { + expect(parseQueryAndGenerateCondition(' (foo OR bar) ', [titleField])).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'bar', + }), + ), + ); + }); + + it('should allow tokens to contain parenthesis if not at the end and beginning', () => { + expect(parseQueryAndGenerateCondition('foo(bar)baz', [titleField])).toEqual( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo(bar)baz', + }), + ); + }); + }); + }); + + describe('complex query', () => { + it('should generate a valid condition tree corresponding to a complex query', () => { + expect( + parseQueryAndGenerateCondition('foo title:bar OR title:baz -banana', [ + titleField, + descriptionField, + ]), + ).toEqual( + ConditionTreeFactory.union( + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'foo', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'description', + operator: 'IContains', + value: 'foo', + }), + ), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'bar', + }), + ), + ConditionTreeFactory.intersect( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'IContains', + value: 'baz', + }), + ConditionTreeFactory.intersect( + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'NotIContains', + value: 'banana', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'title', + operator: 'Missing', + }), + ), + ConditionTreeFactory.union( + ConditionTreeFactory.fromPlainObject({ + field: 'description', + operator: 'NotIContains', + value: 'banana', + }), + ConditionTreeFactory.fromPlainObject({ + field: 'description', + operator: 'Missing', + }), + ), + ), + ), + ), + ); + }); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/sort-emulate/collection.test.ts b/packages/datasource-customizer/test/decorators/sort-emulate/collection.test.ts index 8ebee7e37e..6e356fb65a 100644 --- a/packages/datasource-customizer/test/decorators/sort-emulate/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/sort-emulate/collection.test.ts @@ -104,9 +104,9 @@ describe('SortEmulationDecoratorCollection', () => { ); }); - test('emulateFieldSorting() should throw if the field is in a relation', () => { - expect(() => newBooks.emulateFieldSorting('author:firstName')).toThrow( - 'Cannot replace sort on relation', + test('replaceFieldSorting() should throw if no equivalentSort is provided', () => { + expect(() => newBooks.replaceFieldSorting('authorId', null)).toThrow( + 'A new sorting method should be provided to replace field sorting', ); }); @@ -253,7 +253,11 @@ describe('SortEmulationDecoratorCollection', () => { expect(schema.isSortable).toBeFalsy(); }); - test('should not be concerned by sorting', async () => { + test('should set isSortable to false', async () => { + expect((newBooks.schema.fields.title as ColumnSchema).isSortable).toBeFalse(); + }); + + test('should still sort normally when calling the method', async () => { const records = await newBooks.list( factories.caller.build(), new PaginatedFilter({ sort: new Sort({ field: 'title', ascending: true }) }), @@ -261,8 +265,8 @@ describe('SortEmulationDecoratorCollection', () => { ); expect(records).toStrictEqual([ - { id: 1, title: 'Foundation' }, { id: 2, title: 'Beat the dealer' }, + { id: 1, title: 'Foundation' }, { id: 3, title: 'Gomorrah' }, ]); }); diff --git a/packages/datasource-customizer/test/decorators/write/write-replace/collection_basics.test.ts b/packages/datasource-customizer/test/decorators/write/write-replace/collection_basics.test.ts index 69d065dca3..e617eb75fb 100644 --- a/packages/datasource-customizer/test/decorators/write/write-replace/collection_basics.test.ts +++ b/packages/datasource-customizer/test/decorators/write/write-replace/collection_basics.test.ts @@ -24,7 +24,7 @@ describe('WriteDecorator > When their are no relations', () => { const decorator = new WriteDecorator(collection, dataSource); expect(() => decorator.replaceFieldWriting('inexistant', () => ({}))).toThrow( - 'The given field "inexistant" does not exist on the books collection.', + "Column not found: 'books.inexistant'", ); }); @@ -41,16 +41,15 @@ describe('WriteDecorator > When their are no relations', () => { expect((decorator.schema.fields.name as ColumnSchema).isReadOnly).toEqual(false); }); - it('should mark fields as readonly when handler is null', () => { + it('should throw an error when definition is null', () => { const collection = dataSource.getCollection('books'); const decorator = new WriteDecorator(collection, dataSource); expect((collection.schema.fields.name as ColumnSchema).isReadOnly).toEqual(true); expect((decorator.schema.fields.name as ColumnSchema).isReadOnly).toEqual(true); - decorator.replaceFieldWriting('name', null); - - expect((collection.schema.fields.name as ColumnSchema).isReadOnly).toEqual(true); - expect((decorator.schema.fields.name as ColumnSchema).isReadOnly).toEqual(true); + expect(() => decorator.replaceFieldWriting('name', null)).toThrow( + 'A new writing method should be provided to replace field writing', + ); }); }); diff --git a/packages/datasource-dummy/CHANGELOG.md b/packages/datasource-dummy/CHANGELOG.md index 7b7bb37c79..6a7437d89c 100644 --- a/packages/datasource-dummy/CHANGELOG.md +++ b/packages/datasource-dummy/CHANGELOG.md @@ -1,3 +1,39 @@ +# @forestadmin/datasource-dummy [1.1.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.94...@forestadmin/datasource-dummy@1.1.0) (2024-01-26) + + +### Features + +* **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.41.0 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## @forestadmin/datasource-dummy [1.0.94](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.93...@forestadmin/datasource-dummy@1.0.94) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.4 + +## @forestadmin/datasource-dummy [1.0.93](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.92...@forestadmin/datasource-dummy@1.0.93) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.3 + ## @forestadmin/datasource-dummy [1.0.92](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-dummy@1.0.91...@forestadmin/datasource-dummy@1.0.92) (2024-01-18) diff --git a/packages/datasource-dummy/package.json b/packages/datasource-dummy/package.json index 66b986068f..f1bacfe850 100644 --- a/packages/datasource-dummy/package.json +++ b/packages/datasource-dummy/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-dummy", - "version": "1.0.92", + "version": "1.1.0", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -12,8 +12,8 @@ "directory": "packages/datasource-dummy" }, "dependencies": { - "@forestadmin/datasource-customizer": "1.40.2", - "@forestadmin/datasource-toolkit": "1.29.2" + "@forestadmin/datasource-customizer": "1.41.0", + "@forestadmin/datasource-toolkit": "1.30.0" }, "files": [ "dist/**/*.js", diff --git a/packages/datasource-dummy/src/collections/base.ts b/packages/datasource-dummy/src/collections/base.ts index e6bde5e8df..0265f29753 100644 --- a/packages/datasource-dummy/src/collections/base.ts +++ b/packages/datasource-dummy/src/collections/base.ts @@ -24,6 +24,7 @@ export default class BaseDummyCollection extends BaseCollection { 'GreaterThan', 'In', 'IncludesAll', + 'IncludesNone', 'ShorterThan', 'LongerThan', 'Present', diff --git a/packages/datasource-mongoose/CHANGELOG.md b/packages/datasource-mongoose/CHANGELOG.md index cd102c9c93..396f0dfda2 100644 --- a/packages/datasource-mongoose/CHANGELOG.md +++ b/packages/datasource-mongoose/CHANGELOG.md @@ -1,3 +1,18 @@ +# @forestadmin/datasource-mongoose [1.6.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-mongoose@1.5.33...@forestadmin/datasource-mongoose@1.6.0) (2024-01-26) + + +### Features + +* **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) + + + + + +### Dependencies + +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + ## @forestadmin/datasource-mongoose [1.5.33](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-mongoose@1.5.32...@forestadmin/datasource-mongoose@1.5.33) (2024-01-17) diff --git a/packages/datasource-mongoose/package.json b/packages/datasource-mongoose/package.json index fb9fc3c525..69be9ce970 100644 --- a/packages/datasource-mongoose/package.json +++ b/packages/datasource-mongoose/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-mongoose", - "version": "1.5.33", + "version": "1.6.0", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -16,7 +16,7 @@ "dist/**/*.d.ts" ], "dependencies": { - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/datasource-toolkit": "1.30.0", "luxon": "^3.2.1" }, "devDependencies": { diff --git a/packages/datasource-mongoose/src/utils/pipeline/filter.ts b/packages/datasource-mongoose/src/utils/pipeline/filter.ts index 9e01a8e6d9..8e729b9392 100644 --- a/packages/datasource-mongoose/src/utils/pipeline/filter.ts +++ b/packages/datasource-mongoose/src/utils/pipeline/filter.ts @@ -123,8 +123,14 @@ export default class FilterGenerator { return { $in: formattedLeafValue }; case 'IncludesAll': return { $all: formattedLeafValue }; + case 'IncludesNone': + return Array.isArray(formattedLeafValue) + ? { $nin: formattedLeafValue } + : { $ne: formattedLeafValue }; case 'NotContains': return { $not: new RegExp(`.*${formattedLeafValue}.*`) }; + case 'NotIContains': + return { $not: new RegExp(`.*${formattedLeafValue}.*`, 'i') }; case 'Match': return formattedLeafValue; case 'Present': diff --git a/packages/datasource-mongoose/src/utils/schema/filter-operators.ts b/packages/datasource-mongoose/src/utils/schema/filter-operators.ts index ad576be9ee..830ee7ca21 100644 --- a/packages/datasource-mongoose/src/utils/schema/filter-operators.ts +++ b/packages/datasource-mongoose/src/utils/schema/filter-operators.ts @@ -3,7 +3,7 @@ import { ColumnType, Operator } from '@forestadmin/datasource-toolkit'; export default class FilterOperatorsGenerator { static readonly defaultOperators: Partial = ['Equal', 'NotEqual', 'Present']; static readonly inOperators: Partial = ['In', 'NotIn']; - static readonly stringOperators: Partial = ['Match', 'NotContains']; + static readonly stringOperators: Partial = ['Match', 'NotContains', 'NotIContains']; static readonly comparisonOperators: Partial = ['GreaterThan', 'LessThan']; static getSupportedOperators(type: ColumnType): Set { diff --git a/packages/datasource-mongoose/test/integration/review/collection_list.test.ts b/packages/datasource-mongoose/test/integration/review/collection_list.test.ts index 39e79949e7..d6fa9d0237 100644 --- a/packages/datasource-mongoose/test/integration/review/collection_list.test.ts +++ b/packages/datasource-mongoose/test/integration/review/collection_list.test.ts @@ -182,10 +182,45 @@ describe('MongooseCollection', () => { [{ tags: ['A', 'B'] }], ], [ - { value: ['A'], operator: 'NotContains', field: 'tags' }, + { value: ['C', 'D'], operator: 'IncludesNone', field: 'tags' }, + new Projection('tags'), + [{ tags: ['A', 'B'] }], + ], + [ + { value: ['A', 'D'], operator: 'IncludesNone', field: 'tags' }, new Projection('tags'), [{ tags: ['B', 'C'] }], ], + [ + { value: ['A'], operator: 'IncludesNone', field: 'tags' }, + new Projection('tags'), + [{ tags: ['B', 'C'] }], + ], + [ + { value: 'titl', operator: 'NotContains', field: 'title' }, + new Projection('title'), + [{ title: null }], + ], + [ + { value: 'TiTlE', operator: 'NotContains', field: 'title' }, + new Projection('title'), + [{ title: 'a title' }, { title: null }], + ], + [ + { value: 'a TiTlE', operator: 'NotIContains', field: 'title' }, + new Projection('title'), + [{ title: null }], + ], + [ + { value: 'no matches', operator: 'NotContains', field: 'title' }, + new Projection('title'), + [{ title: 'a title' }, { title: null }], + ], + [ + { value: 'no matches', operator: 'NotIContains', field: 'title' }, + new Projection('title'), + [{ title: 'a title' }, { title: null }], + ], [ { value: /.*message.*/, operator: 'Match', field: 'message' }, new Projection('message'), diff --git a/packages/datasource-mongoose/test/utils/schema/filter-operator.test.ts b/packages/datasource-mongoose/test/utils/schema/filter-operator.test.ts index 26b8eb1c82..2010ec48e8 100644 --- a/packages/datasource-mongoose/test/utils/schema/filter-operator.test.ts +++ b/packages/datasource-mongoose/test/utils/schema/filter-operator.test.ts @@ -9,8 +9,11 @@ describe('FilterOperatorBuilder > getSupportedOperators', () => { ['Dateonly', ['Equal', 'NotEqual', 'Present', 'In', 'NotIn', 'GreaterThan', 'LessThan']], ['Enum', ['Equal', 'NotEqual', 'Present', 'In', 'NotIn']], ['Number', ['Equal', 'NotEqual', 'Present', 'In', 'NotIn', 'GreaterThan', 'LessThan']], - ['String', ['Equal', 'NotEqual', 'Present', 'In', 'NotIn', 'Match', 'NotContains']], - ['Uuid', ['Equal', 'NotEqual', 'Present', 'Match', 'NotContains']], + [ + 'String', + ['Equal', 'NotEqual', 'Present', 'In', 'NotIn', 'Match', 'NotContains', 'NotIContains'], + ], + ['Uuid', ['Equal', 'NotEqual', 'Present', 'Match', 'NotContains', 'NotIContains']], ['Json', ['Equal', 'NotEqual', 'Present']], ['Point', []], ['Time', []], diff --git a/packages/datasource-replica/CHANGELOG.md b/packages/datasource-replica/CHANGELOG.md index 9821d35592..b955de975f 100644 --- a/packages/datasource-replica/CHANGELOG.md +++ b/packages/datasource-replica/CHANGELOG.md @@ -1,3 +1,36 @@ +## @forestadmin/datasource-replica [1.0.60](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.59...@forestadmin/datasource-replica@1.0.60) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.41.0 +* **@forestadmin/datasource-sequelize:** upgraded to 1.6.0 +* **@forestadmin/datasource-sql:** upgraded to 1.7.45 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## @forestadmin/datasource-replica [1.0.59](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.58...@forestadmin/datasource-replica@1.0.59) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.4 + +## @forestadmin/datasource-replica [1.0.58](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.57...@forestadmin/datasource-replica@1.0.58) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.3 + ## @forestadmin/datasource-replica [1.0.57](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-replica@1.0.56...@forestadmin/datasource-replica@1.0.57) (2024-01-18) diff --git a/packages/datasource-replica/package.json b/packages/datasource-replica/package.json index 2477713cd9..c1a4c62f22 100644 --- a/packages/datasource-replica/package.json +++ b/packages/datasource-replica/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-replica", - "version": "1.0.57", + "version": "1.0.60", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -23,10 +23,10 @@ "test": "jest" }, "dependencies": { - "@forestadmin/datasource-customizer": "1.40.2", - "@forestadmin/datasource-sequelize": "1.5.27", - "@forestadmin/datasource-sql": "1.7.44", - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/datasource-customizer": "1.41.0", + "@forestadmin/datasource-sequelize": "1.6.0", + "@forestadmin/datasource-sql": "1.7.45", + "@forestadmin/datasource-toolkit": "1.30.0", "croner": "^6.0.6", "sequelize": "^6.28.0" } diff --git a/packages/datasource-sequelize/CHANGELOG.md b/packages/datasource-sequelize/CHANGELOG.md index c62b857e2c..8a20a82500 100644 --- a/packages/datasource-sequelize/CHANGELOG.md +++ b/packages/datasource-sequelize/CHANGELOG.md @@ -1,3 +1,18 @@ +# @forestadmin/datasource-sequelize [1.6.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sequelize@1.5.27...@forestadmin/datasource-sequelize@1.6.0) (2024-01-26) + + +### Features + +* **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) + + + + + +### Dependencies + +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + ## @forestadmin/datasource-sequelize [1.5.27](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sequelize@1.5.26...@forestadmin/datasource-sequelize@1.5.27) (2024-01-17) diff --git a/packages/datasource-sequelize/docker-compose.yml b/packages/datasource-sequelize/docker-compose.yml new file mode 100644 index 0000000000..16c2e8b5f7 --- /dev/null +++ b/packages/datasource-sequelize/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.1' +services: + postgres16: + image: postgres:16-alpine + container_name: forest_datasource_sql_test_postgres16 + ports: + - '5456:5432' + environment: + - POSTGRES_USER=test + - POSTGRES_PASSWORD=password + + mysql8: + image: mysql:8 + container_name: forest_datasource_sql_test_mysql8 + ports: + - '3318:3306' + environment: + - MYSQL_ROOT_PASSWORD=password + + mssql2022: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: forest_datasource_sql_test_mssql2022 + platform: linux/amd64 + ports: + - '1432:1433' + environment: + - MSSQL_SA_PASSWORD=yourStrong(!)Password + - ACCEPT_EULA=Y + - MSSQL_COLLATION=SQL_Latin1_General_CP1_CS_AS + + mariadb11: + image: mariadb:11 + container_name: forest_datasource_sql_test_mariadb11 + ports: + - '3821:3306' + environment: + - MARIADB_ROOT_PASSWORD=password diff --git a/packages/datasource-sequelize/package.json b/packages/datasource-sequelize/package.json index 89f2535218..80be77478f 100644 --- a/packages/datasource-sequelize/package.json +++ b/packages/datasource-sequelize/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-sequelize", - "version": "1.5.27", + "version": "1.6.0", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -12,7 +12,7 @@ "directory": "packages/datasource-sequelize" }, "dependencies": { - "@forestadmin/datasource-toolkit": "1.29.2" + "@forestadmin/datasource-toolkit": "1.30.0" }, "files": [ "dist/**/*.js", diff --git a/packages/datasource-sequelize/src/utils/query-converter.ts b/packages/datasource-sequelize/src/utils/query-converter.ts index de4b0a87c4..68a42da239 100644 --- a/packages/datasource-sequelize/src/utils/query-converter.ts +++ b/packages/datasource-sequelize/src/utils/query-converter.ts @@ -35,110 +35,167 @@ export default class QueryConverter { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - private makeWhereClause(field: string, operator: Operator, value?: unknown): any { - switch (operator) { + private makeWhereClause( + colName: string, + field: string, + operator: Operator, + value: unknown, + ): WhereOptions { + switch (true) { // Presence - case 'Present': - return { [Op.ne]: null }; - case 'Missing': - return { [Op.is]: null }; + case operator === 'Present': + return { [colName]: { [Op.ne]: null } }; + case operator === 'Missing': + return { [colName]: { [Op.is]: null } }; // Equality - case 'Equal': - return { [value !== null ? Op.eq : Op.is]: value }; - case 'NotEqual': - return { [Op.ne]: value }; - case 'In': - return this.makeInWhereClause(field, value); - case 'NotIn': - return this.makeNotInWhereClause(field, value); + case operator === 'Equal': + return { [colName]: { [value !== null ? Op.eq : Op.is]: value } }; + case operator === 'NotEqual': + return value === null + ? { [colName]: { [Op.ne]: value } } + : { + [Op.or]: [{ [colName]: { [Op.ne]: value } }, { [colName]: { [Op.is]: null } }], + }; + case operator === 'In': + return this.makeInWhereClause(colName, field, value); + case operator === 'NotIn': + return this.makeNotInWhereClause(colName, field, value); // Orderables - case 'LessThan': - return { [Op.lt]: value }; - case 'GreaterThan': - return { [Op.gt]: value }; + case operator === 'LessThan': + return { [colName]: { [Op.lt]: value } }; + case operator === 'GreaterThan': + return { [colName]: { [Op.gt]: value } }; // Strings - case 'Like': - return this.makeLikeWhereClause(field, value as string, true, false); - case 'ILike': - return this.makeLikeWhereClause(field, value as string, false, false); - case 'NotContains': - return this.makeLikeWhereClause(field, `%${value}%`, true, true); + case operator === 'Like': + return this.makeLikeWhereClause(colName, field, value as string, true, false); + case operator === 'ILike': + return this.makeLikeWhereClause(colName, field, value as string, false, false); + case operator === 'NotContains': + return this.makeLikeWhereClause(colName, field, `%${value}%`, true, true); + case operator === 'NotIContains': + return this.makeLikeWhereClause(colName, field, `%${value}%`, false, true); // Arrays - case 'IncludesAll': - return { [Op.contains]: Array.isArray(value) ? value : [value] }; + case operator === 'IncludesAll' && this.dialect === 'postgres': + return { [colName]: { [Op.contains]: Array.isArray(value) ? value : [value] } }; + case operator === 'IncludesNone' && this.dialect === 'postgres': + return { + [Op.or]: [ + { [colName]: { [Op.is]: null } }, + { + [Op.and]: (Array.isArray(value) ? value : [value]).map(oneValue => ({ + [Op.not]: { + [colName]: { [Op.contains]: [oneValue] }, + }, + })), + }, + ], + }; default: throw new Error(`Unsupported operator: "${operator}".`); } } - private makeInWhereClause(field: string, value: unknown): unknown { + private makeInWhereClause(colName: string, field: string, value: unknown): WhereOptions { const valueAsArray = value as unknown[]; if (valueAsArray.length === 1) { - return this.makeWhereClause(field, 'Equal', valueAsArray[0]); + return this.makeWhereClause(colName, field, 'Equal', valueAsArray[0]); } if (valueAsArray.includes(null)) { const valueAsArrayWithoutNull = valueAsArray.filter(v => v !== null); return { - [Op.or]: [this.makeInWhereClause(field, valueAsArrayWithoutNull), { [Op.is]: null }], + [Op.or]: [ + this.makeInWhereClause(colName, field, valueAsArrayWithoutNull), + { [colName]: { [Op.is]: null } }, + ], }; } - return { [Op.in]: valueAsArray }; + return { [colName]: { [Op.in]: valueAsArray } }; } - private makeNotInWhereClause(field: string, value: unknown): unknown { + private makeNotInWhereClause(colName: string, field: string, value: unknown): WhereOptions { const valueAsArray = value as unknown[]; - if (valueAsArray.length === 1) { - return this.makeWhereClause(field, 'NotEqual', valueAsArray[0]); - } - if (valueAsArray.includes(null)) { + if (valueAsArray.length === 1) { + return { [colName]: { [Op.ne]: null } }; + } + const valueAsArrayWithoutNull = valueAsArray.filter(v => v !== null); return { - [Op.and]: [{ [Op.ne]: null }, this.makeNotInWhereClause(field, valueAsArrayWithoutNull)], + [Op.and]: [ + { [colName]: { [Op.ne]: null } }, + ...valueAsArrayWithoutNull.map(v => ({ + [colName]: { [Op.ne]: v }, + })), + ], + }; + } + + if (valueAsArray.length === 1) { + return { + [Op.or]: [ + { [colName]: { [Op.is]: null } }, + ...valueAsArray.map(v => ({ + [colName]: { [Op.ne]: v }, + })), + ], }; } - return { [Op.notIn]: valueAsArray }; + return { + [Op.or]: [{ [colName]: { [Op.notIn]: valueAsArray } }, { [colName]: { [Op.is]: null } }], + }; } private makeLikeWhereClause( + colName: string, field: string, value: string, caseSensitive: boolean, not: boolean, - ): unknown { + ): WhereOptions { const op = not ? 'NOT LIKE' : 'LIKE'; - const seqOp = not ? Op.notLike : Op.like; + let condition: WhereOptions; if (caseSensitive) { if (this.dialect === 'sqlite') { const sqLiteOp = not ? 'NOT GLOB' : 'GLOB'; - return this.where(this.col(field), sqLiteOp, value.replace(/%/g, '*').replace(/_/g, '?')); + condition = this.where( + this.col(field), + sqLiteOp, + value.replace(/%/g, '*').replace(/_/g, '?'), + ); + } else if (this.dialect === 'mysql' || this.dialect === 'mariadb') { + condition = this.where(this.fn('BINARY', this.col(field)), op, value); + } else { + condition = { [colName]: { [not ? Op.notLike : Op.like]: value } }; } - if (this.dialect === 'mysql' || this.dialect === 'mariadb') - return this.where(this.fn('BINARY', this.col(field)), op, value); - - return { [seqOp]: value }; + // Case insensitive + } else if (this.dialect === 'postgres') { + condition = { [colName]: { [not ? Op.notILike : Op.iLike]: value } }; + } else if ( + this.dialect === 'mysql' || + this.dialect === 'mariadb' || + this.dialect === 'sqlite' + ) { + condition = { [colName]: { [not ? Op.notLike : Op.like]: value } }; + } else { + condition = this.where(this.fn('LOWER', this.col(field)), op, value.toLocaleLowerCase()); } - if (this.dialect === 'postgres') return { [Op.iLike]: value }; - if (this.dialect === 'mysql' || this.dialect === 'mariadb' || this.dialect === 'sqlite') - return { [seqOp]: value }; - - return this.where(this.fn('LOWER', this.col(field)), op, value.toLocaleLowerCase()); + return not ? { [Op.or]: [condition, { [colName]: { [Op.is]: null } }] } : condition; } /* @@ -173,8 +230,6 @@ export default class QueryConverter { getWhereFromConditionTree(conditionTree?: ConditionTree): WhereOptions { if (!conditionTree) return {}; - const sequelizeWhereClause = {}; - if ((conditionTree as ConditionTreeBranch).aggregator !== undefined) { const { aggregator, conditions } = conditionTree as ConditionTreeBranch; @@ -188,25 +243,26 @@ export default class QueryConverter { throw new Error('Conditions must be an array.'); } - sequelizeWhereClause[sequelizeOperator] = conditions.map(condition => - this.getWhereFromConditionTree(condition), - ); - } else if ((conditionTree as ConditionTreeLeaf).operator !== undefined) { + return { + [sequelizeOperator]: conditions.map(condition => this.getWhereFromConditionTree(condition)), + }; + } + + if ((conditionTree as ConditionTreeLeaf).operator !== undefined) { const { field, operator, value } = conditionTree as ConditionTreeLeaf; const isRelation = field.includes(':'); const safeField = unAmbigousField(this.model, field); - sequelizeWhereClause[isRelation ? `$${safeField}$` : safeField] = this.makeWhereClause( + return this.makeWhereClause( + isRelation ? `$${safeField}$` : safeField, safeField, operator, value, ); - } else { - throw new Error('Invalid ConditionTree.'); } - return sequelizeWhereClause; + throw new Error('Invalid ConditionTree.'); } getIncludeFromProjection( diff --git a/packages/datasource-sequelize/src/utils/type-converter.ts b/packages/datasource-sequelize/src/utils/type-converter.ts index 9f953c1f59..78046b3e1f 100644 --- a/packages/datasource-sequelize/src/utils/type-converter.ts +++ b/packages/datasource-sequelize/src/utils/type-converter.ts @@ -63,11 +63,10 @@ export default class TypeConverter { public static operatorsForColumnType(columnType: ColumnType): Set { const result: Operator[] = ['Present', 'Missing']; const equality: Operator[] = ['Equal', 'NotEqual', 'In', 'NotIn']; + const orderables: Operator[] = ['LessThan', 'GreaterThan']; + const strings: Operator[] = ['Like', 'ILike', 'NotContains', 'NotIContains']; if (typeof columnType === 'string') { - const orderables: Operator[] = ['LessThan', 'GreaterThan']; - const strings: Operator[] = ['Like', 'ILike', 'NotContains']; - if (['Boolean', 'Binary', 'Enum', 'Uuid'].includes(columnType)) { result.push(...equality); } @@ -82,7 +81,7 @@ export default class TypeConverter { } if (Array.isArray(columnType)) { - result.push(...equality, 'IncludesAll'); + result.push('Equal', 'NotEqual', 'IncludesAll', 'IncludesNone'); } return new Set(result); diff --git a/packages/datasource-sequelize/test/__tests/connections.ts b/packages/datasource-sequelize/test/__tests/connections.ts new file mode 100644 index 0000000000..a0285deacc --- /dev/null +++ b/packages/datasource-sequelize/test/__tests/connections.ts @@ -0,0 +1,63 @@ +import type { Dialect, Sequelize } from 'sequelize'; + +type Connection = { + name: string; + dialect: Dialect; + uri: (db?: string) => string; + supports: { + arrays?: boolean; + }; + query: { + createDatabase(sequelize: Sequelize, dbName: string): Promise; + }; +}; + +function defaultCreateDatabase(sequelize: Sequelize, dbName: string) { + return sequelize.getQueryInterface().createDatabase(dbName); +} + +const CONNECTIONS: Connection[] = [ + { + name: 'Postgres 16', + dialect: 'postgres', + uri: (db?: string) => `postgres://test:password@localhost:5456/${db ?? ''}`, + supports: { arrays: true }, + query: { + createDatabase: defaultCreateDatabase, + }, + }, + { + name: 'MySQL 8', + dialect: 'mysql', + uri: (db?: string) => `mysql://root:password@localhost:3318/${db ?? ''}`, + supports: {}, + query: { + createDatabase: defaultCreateDatabase, + }, + }, + { + name: 'MariaDB 11', + dialect: 'mariadb', + uri: (db?: string) => `mariadb://root:password@localhost:3821/${db ?? ''}`, + supports: {}, + query: { + createDatabase: defaultCreateDatabase, + }, + }, + { + name: 'MSSQL 2022', + dialect: 'mssql', + uri: (db?: string) => `mssql://sa:yourStrong(!)Password@localhost:1432/${db ?? ''}`, + supports: {}, + query: { + createDatabase: async (sequelize: Sequelize, dbName: string) => { + await sequelize.query(`CREATE DATABASE ${dbName} COLLATE SQL_Latin1_General_CP1_CS_AS`, { + raw: true, + type: 'RAW', + }); + }, + }, + }, +]; + +export default CONNECTIONS; diff --git a/packages/datasource-sequelize/test/integration/list/filter.integration.test.ts b/packages/datasource-sequelize/test/integration/list/filter.integration.test.ts new file mode 100644 index 0000000000..66a049a6a8 --- /dev/null +++ b/packages/datasource-sequelize/test/integration/list/filter.integration.test.ts @@ -0,0 +1,488 @@ +import { + Caller, + ConditionTreeLeaf, + PaginatedFilter, + Projection, +} from '@forestadmin/datasource-toolkit'; +import { DataTypes, Sequelize } from 'sequelize'; + +import SequelizeCollection from '../../../src/collection'; +import { createSequelizeDataSource } from '../../../src/index'; +import CONNECTIONS from '../../__tests/connections'; + +describe('Filter tests on collection', () => { + describe.each(CONNECTIONS)('On $name', connection => { + let sequelize: Sequelize; + const DB = 'forest_test_search'; + const caller: Caller = {} as Caller; + + beforeAll(async () => { + try { + const dbSequelize = new Sequelize(connection.uri(), { logging: false }); + await dbSequelize.getQueryInterface().dropDatabase(DB); + await connection.query.createDatabase(dbSequelize, DB); + await dbSequelize.close(); + + sequelize = await new Sequelize(connection.uri(DB), { logging: false }); + } catch (e) { + console.error(e); + throw e; + } + }); + + afterAll(async () => { + await sequelize.close(); + }); + + describe('On text fields', () => { + let collection: SequelizeCollection; + + beforeAll(async () => { + sequelize.define('User', { + name: { + type: DataTypes.TEXT, + allowNull: true, + }, + }); + + await sequelize.sync({ force: true }); + + await sequelize.models.User.bulkCreate([ + { name: 'John Doe' }, + { name: 'Jane Doe' }, + { name: 'john Smith' }, + { name: null }, + ]); + + const datasource = await createSequelizeDataSource(sequelize)(undefined as any); + collection = datasource.getCollection('User') as SequelizeCollection; + }, 30_000); + + describe('Like', () => { + it('should return lines containing the searched text', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'Like', 'John%'), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'John Doe' })]), + ); + }); + }); + + describe('ILike', () => { + it('should return lines containing the searched text', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'ILike', 'John%'), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'john Smith' }), + ]), + ); + }); + }); + + describe('NotContains', () => { + it('should return lines not containing the searched text', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotContains', 'John'), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: 'john Smith' }), + expect.objectContaining({ name: null }), + ]), + ); + }); + }); + + describe('NotIContains', () => { + it('should return lines not containing the searched text', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotIContains', 'John'), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: null }), + ]), + ); + }); + }); + + describe('Present', () => { + it('should return lines with a value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'Present', null), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: 'john Smith' }), + ]), + ); + }); + }); + + describe('Missing', () => { + it('should return lines without a value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'Missing', null), + }), + new Projection('name'), + ); + + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ name: null })])); + }); + }); + + describe('Equal', () => { + it('should return lines equal the searched value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'Equal', 'John Doe'), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'John Doe' })]), + ); + }); + }); + + describe('NotEqual', () => { + it('should return lines not equal to the searched value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotEqual', 'John Doe'), + }), + new Projection('name'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'John Doe' })]), + ); + + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ name: null })])); + }); + + it('should return lines without a null value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotEqual', null), + }), + new Projection('name'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: null })]), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: 'john Smith' }), + ]), + ); + }); + }); + + describe('In', () => { + it('should return lines in the searched values', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'In', ['John Doe', 'Jane Doe']), + }), + new Projection('name'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Jane Doe' }), + ]), + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'john Smith' })]), + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: null })]), + ); + }); + }); + + describe('NotIn', () => { + it('should return lines not in the searched values', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotIn', ['John Doe', 'Jane Doe']), + }), + new Projection('name'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Jane Doe' }), + ]), + ); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'john Smith' })]), + ); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ name: null })])); + }); + + it('should return lines without a null value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotIn', [null]), + }), + new Projection('name'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: null })]), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: 'john Smith' }), + ]), + ); + }); + + it('should return lines without a null value and another', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('name', 'NotIn', [null, 'John Doe']), + }), + new Projection('name'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: null })]), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'John Doe' })]), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Jane Doe' }), + expect.objectContaining({ name: 'john Smith' }), + ]), + ); + }); + }); + }); + + if (connection.supports.arrays) { + describe('On array fields of strings', () => { + let collection: SequelizeCollection; + + beforeAll(async () => { + sequelize.define('Objects', { + tags: { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: true, + }, + }); + + await sequelize.sync({ force: true }); + + await sequelize.models.Objects.bulkCreate([ + { tags: ['blue', 'red'] }, + { tags: ['yellow', 'red'] }, + { tags: ['orange', 'red'] }, + { tags: null }, + ]); + + const datasource = await createSequelizeDataSource(sequelize)(undefined as any); + collection = datasource.getCollection('Objects') as SequelizeCollection; + }); + + describe('IncludesAll', () => { + it('should return lines containing all the searched values', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'IncludesAll', ['blue', 'red']), + }), + new Projection('tags'), + ); + + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).toHaveLength(1); + }); + + it('should return all lines containing the given value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'IncludesAll', ['red']), + }), + new Projection('tags'), + ); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tags: ['blue', 'red'] }), + expect.objectContaining({ tags: ['yellow', 'red'] }), + expect.objectContaining({ tags: ['orange', 'red'] }), + ]), + ); + expect(result).toHaveLength(3); + }); + + it('should work with a single value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'IncludesAll', 'blue'), + }), + new Projection('tags'), + ); + + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).toHaveLength(1); + }); + }); + + describe('IncludesNone', () => { + it('should return lines not containing any of the searched values', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'IncludesNone', ['blue', 'red']), + }), + new Projection('tags'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['yellow', 'red'] })]), + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['orange', 'red'] })]), + ); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ tags: null })]), + ); + expect(result).toHaveLength(1); + }); + + it('should work with one value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'IncludesNone', 'blue'), + }), + new Projection('tags'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tags: ['yellow', 'red'] }), + expect.objectContaining({ tags: ['orange', 'red'] }), + expect.objectContaining({ tags: null }), + ]), + ); + expect(result).toHaveLength(3); + }); + }); + + describe('Equal', () => { + it('should return lines equal the searched value', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'Equal', ['blue', 'red']), + }), + new Projection('tags'), + ); + + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).toHaveLength(1); + }); + }); + + describe('NotEqual', () => { + it('should return matching lines', async () => { + const result = await collection.list( + caller, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('tags', 'NotEqual', ['blue', 'red']), + }), + new Projection('tags'), + ); + + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ tags: ['blue', 'red'] })]), + ); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tags: ['yellow', 'red'] }), + expect.objectContaining({ tags: ['orange', 'red'] }), + expect.objectContaining({ tags: null }), + ]), + ); + expect(result).toHaveLength(3); + }); + }); + }); + } + }); +}); diff --git a/packages/datasource-sequelize/test/utils/model-to-collection-schema-converter.test.ts b/packages/datasource-sequelize/test/utils/model-to-collection-schema-converter.test.ts index 0637f63baa..3c12198fce 100644 --- a/packages/datasource-sequelize/test/utils/model-to-collection-schema-converter.test.ts +++ b/packages/datasource-sequelize/test/utils/model-to-collection-schema-converter.test.ts @@ -152,10 +152,7 @@ describe('Utils > ModelToCollectionSchemaConverter', () => { }, myEnumList: { columnType: ['Enum'], - filterOperators: new Set([ - ...TypeConverter.operatorsForColumnType('Enum'), - 'IncludesAll', - ]), + filterOperators: new Set([...TypeConverter.operatorsForColumnType(['Enum'])]), enumValues: ['enum1', 'enum2', 'enum3'], isSortable: true, validation: [], diff --git a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts index 73fabd4523..c742fa5d84 100644 --- a/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts +++ b/packages/datasource-sequelize/test/utils/query-converter.unit.test.ts @@ -211,37 +211,106 @@ describe('Utils > QueryConverter', () => { const stringValue = 'VaLuE'; it.each([ - ['Equal', integerValue, { [Op.eq]: integerValue }], - ['Equal', null, { [Op.is]: null }], - ['GreaterThan', integerValue, { [Op.gt]: integerValue }], - ['In', [null], { [Op.is]: null }], - ['In', [null, 2], { [Op.or]: [{ [Op.eq]: 2 }, { [Op.is]: null }] }], - ['In', simpleArrayValue, { [Op.in]: simpleArrayValue }], + ['Equal', integerValue, { __field_1__: { [Op.eq]: integerValue } }], + ['Equal', null, { __field_1__: { [Op.is]: null } }], + ['GreaterThan', integerValue, { __field_1__: { [Op.gt]: integerValue } }], + ['In', [null], { __field_1__: { [Op.is]: null } }], + [ + 'In', + [null, 2], + { [Op.or]: [{ __field_1__: { [Op.eq]: 2 } }, { __field_1__: { [Op.is]: null } }] }, + ], + ['In', simpleArrayValue, { __field_1__: { [Op.in]: simpleArrayValue } }], [ 'In', arrayValueWithNull, - { [Op.or]: [{ [Op.in]: simpleArrayValue }, { [Op.is]: null }] }, + { + [Op.or]: [ + { __field_1__: { [Op.in]: simpleArrayValue } }, + { __field_1__: { [Op.is]: null } }, + ], + }, + ], + ['In', [integerValue], { __field_1__: { [Op.eq]: integerValue } }], + ['IncludesAll', simpleArrayValue, { __field_1__: { [Op.contains]: simpleArrayValue } }], + ['LessThan', integerValue, { __field_1__: { [Op.lt]: integerValue } }], + ['Missing', undefined, { __field_1__: { [Op.is]: null } }], + [ + 'NotEqual', + integerValue, + { + [Op.or]: [ + { __field_1__: { [Op.ne]: integerValue } }, + { __field_1__: { [Op.is]: null } }, + ], + }, + ], + [ + 'NotEqual', + null, + { + __field_1__: { [Op.ne]: null }, + }, + ], + [ + 'NotIn', + [2], + { [Op.or]: [{ __field_1__: { [Op.is]: null } }, { __field_1__: { [Op.ne]: 2 } }] }, + ], + ['NotIn', [null], { __field_1__: { [Op.ne]: null } }], + [ + 'NotIn', + simpleArrayValue, + { + [Op.or]: [ + { __field_1__: { [Op.notIn]: simpleArrayValue } }, + { __field_1__: { [Op.is]: null } }, + ], + }, ], - ['In', [integerValue], { [Op.eq]: integerValue }], - ['IncludesAll', simpleArrayValue, { [Op.contains]: simpleArrayValue }], - ['LessThan', integerValue, { [Op.lt]: integerValue }], - ['Missing', undefined, { [Op.is]: null }], - ['NotEqual', integerValue, { [Op.ne]: integerValue }], - ['NotIn', [2], { [Op.ne]: 2 }], - ['NotIn', [null], { [Op.ne]: null }], - ['NotIn', simpleArrayValue, { [Op.notIn]: simpleArrayValue }], [ 'NotIn', arrayValueWithNull, - { [Op.and]: [{ [Op.ne]: null }, { [Op.notIn]: simpleArrayValue }] }, + { + [Op.and]: [ + { __field_1__: { [Op.ne]: null } }, + { __field_1__: { [Op.ne]: 21 } }, + { __field_1__: { [Op.ne]: 42 } }, + { __field_1__: { [Op.ne]: 84 } }, + ], + }, ], [ 'NotIn', [null, integerValue], - { [Op.and]: [{ [Op.ne]: null }, { [Op.ne]: integerValue }] }, + { + [Op.and]: [ + { __field_1__: { [Op.ne]: null } }, + { __field_1__: { [Op.ne]: integerValue } }, + ], + }, + ], + ['Present', undefined, { __field_1__: { [Op.ne]: null } }], + [ + 'NotContains', + stringValue, + { + [Op.or]: [ + { __field_1__: { [Op.notLike]: `%${stringValue}%` } }, + { __field_1__: { [Op.is]: null } }, + ], + }, + ], + [ + 'NotIContains', + stringValue, + { + [Op.or]: [ + { __field_1__: { [Op.notILike]: `%${stringValue}%` } }, + { __field_1__: { [Op.is]: null } }, + ], + }, ], - ['Present', undefined, { [Op.ne]: null }], - ['NotContains', stringValue, { [Op.notLike]: `%${stringValue}%` }], ])( 'should generate a "where" Sequelize filter from a "%s" operator', (operator, value, where) => { @@ -251,7 +320,7 @@ describe('Utils > QueryConverter', () => { const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree(conditionTree); - expect(sequelizeFilter).toHaveProperty('__field_1__', where); + expect(sequelizeFilter).toEqual(where); }, ); @@ -263,11 +332,18 @@ describe('Utils > QueryConverter', () => { const conditionTree = new ConditionTreeLeaf('__field_1__', 'NotContains', 'test'); const sequelizeFilter = queryConverter.getWhereFromConditionTree(conditionTree); expect(sequelizeFilter).toEqual({ - __field_1__: { - attribute: { col: '__field_1__' }, - comparator: 'NOT GLOB', - logic: '*test*', - }, + [Op.or]: [ + { + attribute: { col: '__field_1__' }, + comparator: 'NOT GLOB', + logic: '*test*', + }, + { + __field_1__: { + [Op.is]: null, + }, + }, + ], }); }); }); @@ -282,7 +358,7 @@ describe('Utils > QueryConverter', () => { logic: 'VaLuE', }, ], - ['mssql', { [Op.like]: 'VaLuE' }], + ['mssql', { __field_1__: { [Op.like]: 'VaLuE' } }], [ 'mysql', { @@ -291,20 +367,20 @@ describe('Utils > QueryConverter', () => { logic: 'VaLuE', }, ], - ['postgres', { [Op.like]: 'VaLuE' }], + ['postgres', { __field_1__: { [Op.like]: 'VaLuE' } }], ])('should generate a "where" Sequelize filter for "%s"', (dialect, where) => { const tree = new ConditionTreeLeaf('__field_1__', 'Like', 'VaLuE'); const model = setupModel(dialect as Dialect); const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree(tree); - expect(sequelizeFilter).toHaveProperty('__field_1__', where); + expect(sequelizeFilter).toEqual(where); }); }); describe('with "ILike" operator', () => { it.each([ - ['mariadb', { [Op.like]: 'VaLuE' }], + ['mariadb', { __field_1__: { [Op.like]: 'VaLuE' } }], [ 'mssql', { @@ -313,15 +389,15 @@ describe('Utils > QueryConverter', () => { logic: 'value', }, ], - ['mysql', { [Op.like]: 'VaLuE' }], - ['postgres', { [Op.iLike]: 'VaLuE' }], + ['mysql', { __field_1__: { [Op.like]: 'VaLuE' } }], + ['postgres', { __field_1__: { [Op.iLike]: 'VaLuE' } }], ])('should generate a "where" Sequelize filter for "%s"', (dialect, where) => { const tree = new ConditionTreeLeaf('__field_1__', 'ILike', 'VaLuE'); const model = setupModel(dialect as Dialect); const queryConverter = new QueryConverter(model); const sequelizeFilter = queryConverter.getWhereFromConditionTree(tree); - expect(sequelizeFilter).toHaveProperty('__field_1__', where); + expect(sequelizeFilter).toEqual(where); }); }); @@ -404,7 +480,6 @@ describe('Utils > QueryConverter', () => { it.each([ ['In', Op.in], ['IncludesAll', Op.contains], - ['NotIn', Op.notIn], ])('should handle array values "%s"', (operator, sequelizeOperator) => { const model = setupModel(); const queryConverter = new QueryConverter(model); @@ -415,6 +490,24 @@ describe('Utils > QueryConverter', () => { expect(sequelizeFilter).toHaveProperty('__field_1__', { [sequelizeOperator]: [42, 43] }); }); + + describe('NotIn', () => { + it('should handle array values', () => { + const model = setupModel(); + const queryConverter = new QueryConverter(model); + + const sequelizeFilter = queryConverter.getWhereFromConditionTree( + new ConditionTreeLeaf('__field_1__', 'NotIn', [42, 43]), + ); + + expect(sequelizeFilter).toEqual({ + [Op.or]: [ + { __field_1__: { [Op.notIn]: [42, 43] } }, + { __field_1__: { [Op.is]: null } }, + ], + }); + }); + }); }); }); diff --git a/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts b/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts index cb75b24b42..be567ad6e5 100644 --- a/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts +++ b/packages/datasource-sequelize/test/utils/type-converter.unit.test.ts @@ -29,7 +29,7 @@ describe('Utils > TypeConverter', () => { const presence = ['Present', 'Missing'] as const; const equality = ['Equal', 'NotEqual', 'In', 'NotIn'] as const; const orderables = ['LessThan', 'GreaterThan'] as const; - const strings = ['Like', 'ILike', 'NotContains'] as const; + const strings = ['Like', 'ILike', 'NotContains', 'NotIContains'] as const; it.each([ // Primitive type @@ -42,7 +42,7 @@ describe('Utils > TypeConverter', () => { ['Uuid', [...presence, ...equality]], // Array type - [['Boolean'], [...presence, ...equality, 'IncludesAll']], + [['Boolean'], [...presence, 'Equal', 'NotEqual', 'IncludesAll', 'IncludesNone']], // Composite and unsupported types ['Json', presence], diff --git a/packages/datasource-sql/CHANGELOG.md b/packages/datasource-sql/CHANGELOG.md index 84fc37fc32..34e33f0c17 100644 --- a/packages/datasource-sql/CHANGELOG.md +++ b/packages/datasource-sql/CHANGELOG.md @@ -1,3 +1,14 @@ +## @forestadmin/datasource-sql [1.7.45](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sql@1.7.44...@forestadmin/datasource-sql@1.7.45) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-sequelize:** upgraded to 1.6.0 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + ## @forestadmin/datasource-sql [1.7.44](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-sql@1.7.43...@forestadmin/datasource-sql@1.7.44) (2024-01-17) diff --git a/packages/datasource-sql/package.json b/packages/datasource-sql/package.json index 8950bae210..7bb9c1f6a9 100644 --- a/packages/datasource-sql/package.json +++ b/packages/datasource-sql/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-sql", - "version": "1.7.44", + "version": "1.7.45", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -12,8 +12,8 @@ "directory": "packages/datasource-sql" }, "dependencies": { - "@forestadmin/datasource-sequelize": "1.5.27", - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/datasource-sequelize": "1.6.0", + "@forestadmin/datasource-toolkit": "1.30.0", "pluralize": "^8.0.0", "sequelize": "^6.28.0", "socks": "^2.7.1", diff --git a/packages/datasource-toolkit/CHANGELOG.md b/packages/datasource-toolkit/CHANGELOG.md index 15baef16ae..d63ad7d6a1 100644 --- a/packages/datasource-toolkit/CHANGELOG.md +++ b/packages/datasource-toolkit/CHANGELOG.md @@ -1,3 +1,10 @@ +# @forestadmin/datasource-toolkit [1.30.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-toolkit@1.29.2...@forestadmin/datasource-toolkit@1.30.0) (2024-01-26) + + +### Features + +* **datasource-customizer:** implement gmail-style search ([#780](https://github.com/ForestAdmin/agent-nodejs/issues/780)) ([3ad8ed8](https://github.com/ForestAdmin/agent-nodejs/commit/3ad8ed895c44ec17959e062dacf085691d42e528)) + ## @forestadmin/datasource-toolkit [1.29.2](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-toolkit@1.29.1...@forestadmin/datasource-toolkit@1.29.2) (2024-01-17) diff --git a/packages/datasource-toolkit/package.json b/packages/datasource-toolkit/package.json index 32096c11e0..2886708711 100644 --- a/packages/datasource-toolkit/package.json +++ b/packages/datasource-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/datasource-toolkit", - "version": "1.29.2", + "version": "1.30.0", "main": "dist/src/index.js", "license": "GPL-3.0", "publishConfig": { diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/equivalence.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/equivalence.ts index a69c0cda4c..8aa8a65022 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/equivalence.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/equivalence.ts @@ -25,7 +25,11 @@ export default class ConditionTreeEquivalent { ): ConditionTree { const { operator } = leaf; - return ConditionTreeEquivalent.getReplacer(operator, operators, columnType)(leaf, timezone); + const replacer = ConditionTreeEquivalent.getReplacer(operator, operators, columnType); + + if (!replacer) return leaf; + + return replacer ? replacer(leaf, timezone) : null; } static hasEquivalentTree( @@ -33,7 +37,7 @@ export default class ConditionTreeEquivalent { operators: Set, columnType: ColumnType, ): boolean { - return !!ConditionTreeEquivalent.getReplacer(operator, operators, columnType); + return Boolean(ConditionTreeEquivalent.getReplacer(operator, operators, columnType)); } /** Find a way to replace an operator by recursively exploring the transforms graph */ diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/factory.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/factory.ts index 6e84327b83..0006e14b0e 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/factory.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/factory.ts @@ -40,6 +40,8 @@ export default class ConditionTreeFactory { } static union(...trees: ConditionTree[]): ConditionTree { + if (trees.length === 1) return trees[0]; + return ConditionTreeFactory.group('Or', trees); } diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts index 44ed6e625a..2cf3077fe4 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts @@ -85,7 +85,15 @@ export default class ConditionTreeLeaf extends ConditionTree { const { columnType } = CollectionUtils.getFieldSchema(collection, this.field) as ColumnSchema; const supported = [ ...['In', 'Equal', 'LessThan', 'GreaterThan', 'Match', 'StartsWith', 'EndsWith'], - ...['LongerThan', 'ShorterThan', 'IncludesAll', 'NotIn', 'NotEqual', 'NotContains'], + ...[ + 'LongerThan', + 'ShorterThan', + 'IncludesAll', + 'IncludesNone', + 'NotIn', + 'NotEqual', + 'NotContains', + ], ] as const; switch (this.operator) { @@ -109,9 +117,12 @@ export default class ConditionTreeLeaf extends ConditionTree { return typeof fieldValue === 'string' ? fieldValue.length < this.value : false; case 'IncludesAll': return !!(this.value as unknown[])?.every(v => (fieldValue as unknown[])?.includes(v)); + case 'IncludesNone': + return !(this.value as unknown[])?.some(v => (fieldValue as unknown[])?.includes(v)); case 'NotIn': case 'NotEqual': case 'NotContains': + case 'NotIContains': return !this.inverse().match(record, collection, timezone); default: diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts index 3373b0709b..9bc19d561a 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/operators.ts @@ -9,11 +9,13 @@ export const uniqueOperators = [ // Strings 'Match', 'NotContains', + 'NotIContains', 'LongerThan', 'ShorterThan', // Arrays 'IncludesAll', + 'IncludesNone', ] as const; export const intervalOperators = [ diff --git a/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts b/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts index 8c6fe95145..2315adb2e4 100644 --- a/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts +++ b/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts @@ -2,7 +2,7 @@ import ConditionTree, { PlainConditionTree } from '../condition-tree/nodes/base' export type FilterComponents = { conditionTree?: ConditionTree; - search?: string; + search?: string | null; searchExtended?: boolean; segment?: string; }; diff --git a/packages/datasource-toolkit/src/validation/rules.ts b/packages/datasource-toolkit/src/validation/rules.ts index 31fccfc862..ba6b7f41da 100644 --- a/packages/datasource-toolkit/src/validation/rules.ts +++ b/packages/datasource-toolkit/src/validation/rules.ts @@ -3,7 +3,7 @@ import { PrimitiveTypes } from '../interfaces/schema'; const BASE_OPERATORS: Operator[] = ['Blank', 'Equal', 'Missing', 'NotEqual', 'Present']; -const ARRAY_OPERATORS: Operator[] = ['In', 'NotIn', 'IncludesAll']; +const ARRAY_OPERATORS: Operator[] = ['In', 'NotIn', 'IncludesAll', 'IncludesNone']; const BASE_DATEONLY_OPERATORS: Operator[] = [ 'Today', @@ -39,6 +39,7 @@ export const MAP_ALLOWED_OPERATORS_FOR_COLUMN_TYPE: Readonly< 'Like', 'ILike', 'IContains', + 'NotIContains', 'IEndsWith', 'IStartsWith', ], @@ -94,6 +95,7 @@ export const MAP_ALLOWED_TYPES_FOR_OPERATOR_CONDITION_TREE: Readonly< In: [...defaultsOperators.In, null], NotIn: [...defaultsOperators.NotIn, null], IncludesAll: [...defaultsOperators.IncludesAll, null], + IncludesNone: [...defaultsOperators.IncludesNone, null], Blank: NO_TYPES_ALLOWED, Missing: NO_TYPES_ALLOWED, diff --git a/packages/datasource-toolkit/test/validation/condition-tree/index.test.ts b/packages/datasource-toolkit/test/validation/condition-tree/index.test.ts index 7cb8351123..5e6ffe209b 100644 --- a/packages/datasource-toolkit/test/validation/condition-tree/index.test.ts +++ b/packages/datasource-toolkit/test/validation/condition-tree/index.test.ts @@ -32,10 +32,10 @@ describe('ConditionTreeValidation', () => { describe('Invalid conditions on branch', () => { it('should throw an error', () => { const collection = factories.collection.build(); - const conditionTree = factories.conditionTreeBranch.build({ conditions: null }); + const conditionTree = factories.conditionTreeBranch.build({ conditions: undefined }); expect(() => ConditionTreeValidator.validate(conditionTree, collection)).toThrow( - `The given conditions 'null' were expected to be an array`, + `The given conditions 'undefined' were expected to be an array`, ); }); }); @@ -215,7 +215,8 @@ describe('ConditionTreeValidation', () => { expect(() => ConditionTreeValidator.validate(conditionTree, collection)).toThrow( "The given operator 'Contains' is not allowed with the columnType schema: 'Number'.\n" + 'The allowed types are: ' + - '[Blank,Equal,Missing,NotEqual,Present,In,NotIn,IncludesAll,GreaterThan,LessThan]', + // eslint-disable-next-line max-len + '[Blank,Equal,Missing,NotEqual,Present,In,NotIn,IncludesAll,IncludesNone,GreaterThan,LessThan]', ); }); }); diff --git a/packages/forestadmin-client/CHANGELOG.md b/packages/forestadmin-client/CHANGELOG.md index 095979c336..a2acbd0e42 100644 --- a/packages/forestadmin-client/CHANGELOG.md +++ b/packages/forestadmin-client/CHANGELOG.md @@ -1,3 +1,20 @@ +## @forestadmin/forestadmin-client [1.25.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.25.5...@forestadmin/forestadmin-client@1.25.6) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## @forestadmin/forestadmin-client [1.25.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.25.4...@forestadmin/forestadmin-client@1.25.5) (2024-01-23) + + +### Bug Fixes + +* return more details in errors due to certificate validation to help debugging ([#917](https://github.com/ForestAdmin/agent-nodejs/issues/917)) ([58aaaec](https://github.com/ForestAdmin/agent-nodejs/commit/58aaaec5f441505a568b18f1dfe21306191ff024)) + ## @forestadmin/forestadmin-client [1.25.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/forestadmin-client@1.25.3...@forestadmin/forestadmin-client@1.25.4) (2024-01-17) diff --git a/packages/forestadmin-client/package.json b/packages/forestadmin-client/package.json index 0c0a0d076b..9af1f343cd 100644 --- a/packages/forestadmin-client/package.json +++ b/packages/forestadmin-client/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/forestadmin-client", - "version": "1.25.4", + "version": "1.25.6", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -31,7 +31,7 @@ "test": "jest" }, "devDependencies": { - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/datasource-toolkit": "1.30.0", "@types/json-api-serializer": "^2.6.3", "@types/jsonwebtoken": "^9.0.1", "@types/superagent": "^4.1.16" diff --git a/packages/forestadmin-client/src/utils/server.ts b/packages/forestadmin-client/src/utils/server.ts index 9a9b08c660..d7f98731ce 100644 --- a/packages/forestadmin-client/src/utils/server.ts +++ b/packages/forestadmin-client/src/utils/server.ts @@ -38,7 +38,8 @@ export default class ServerUtils { if (/certificate/i.test(e.message)) throw new Error( 'ForestAdmin server TLS certificate cannot be verified. ' + - 'Please check that your system time is set properly.', + 'Please check that your system time is set properly. ' + + `Original error: ${e.message}`, ); if ((e as ResponseError).response) { diff --git a/packages/forestadmin-client/test/utils/server.test.ts b/packages/forestadmin-client/test/utils/server.test.ts index 31651fcf5b..73e48bc49a 100644 --- a/packages/forestadmin-client/test/utils/server.test.ts +++ b/packages/forestadmin-client/test/utils/server.test.ts @@ -80,7 +80,8 @@ describe('ServerUtils', () => { await expect(ServerUtils.query(options, 'get', '/endpoint')).rejects.toThrow( 'ForestAdmin server TLS certificate cannot be verified. ' + - 'Please check that your system time is set properly.', + 'Please check that your system time is set properly. ' + + 'Original error: Certificate is invalid', ); }); diff --git a/packages/plugin-aws-s3/CHANGELOG.md b/packages/plugin-aws-s3/CHANGELOG.md index a5db45154d..8b2228185a 100644 --- a/packages/plugin-aws-s3/CHANGELOG.md +++ b/packages/plugin-aws-s3/CHANGELOG.md @@ -1,3 +1,34 @@ +## @forestadmin/plugin-aws-s3 [1.3.59](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.58...@forestadmin/plugin-aws-s3@1.3.59) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.41.0 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## @forestadmin/plugin-aws-s3 [1.3.58](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.57...@forestadmin/plugin-aws-s3@1.3.58) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.4 + +## @forestadmin/plugin-aws-s3 [1.3.57](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.56...@forestadmin/plugin-aws-s3@1.3.57) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.3 + ## @forestadmin/plugin-aws-s3 [1.3.56](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-aws-s3@1.3.55...@forestadmin/plugin-aws-s3@1.3.56) (2024-01-18) diff --git a/packages/plugin-aws-s3/package.json b/packages/plugin-aws-s3/package.json index c7b84f59d9..5904278edd 100644 --- a/packages/plugin-aws-s3/package.json +++ b/packages/plugin-aws-s3/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-aws-s3", - "version": "1.3.56", + "version": "1.3.59", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -16,8 +16,8 @@ "@aws-sdk/s3-request-presigner": "^3.157.0" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.40.2", - "@forestadmin/datasource-toolkit": "1.29.2" + "@forestadmin/datasource-customizer": "1.41.0", + "@forestadmin/datasource-toolkit": "1.30.0" }, "files": [ "dist/**/*.js", diff --git a/packages/plugin-export-advanced/CHANGELOG.md b/packages/plugin-export-advanced/CHANGELOG.md index de8d23019f..8e0e2f5659 100644 --- a/packages/plugin-export-advanced/CHANGELOG.md +++ b/packages/plugin-export-advanced/CHANGELOG.md @@ -1,3 +1,34 @@ +## @forestadmin/plugin-export-advanced [1.0.71](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.70...@forestadmin/plugin-export-advanced@1.0.71) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.41.0 +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 + +## @forestadmin/plugin-export-advanced [1.0.70](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.69...@forestadmin/plugin-export-advanced@1.0.70) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.4 + +## @forestadmin/plugin-export-advanced [1.0.69](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.68...@forestadmin/plugin-export-advanced@1.0.69) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.3 + ## @forestadmin/plugin-export-advanced [1.0.68](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-export-advanced@1.0.67...@forestadmin/plugin-export-advanced@1.0.68) (2024-01-18) diff --git a/packages/plugin-export-advanced/package.json b/packages/plugin-export-advanced/package.json index 20582aadef..66049c4a67 100644 --- a/packages/plugin-export-advanced/package.json +++ b/packages/plugin-export-advanced/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-export-advanced", - "version": "1.0.68", + "version": "1.0.71", "main": "dist/index.js", "license": "GPL-3.0", "publishConfig": { @@ -15,8 +15,8 @@ "excel4node": "^1.8.0" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.40.2", - "@forestadmin/datasource-toolkit": "1.29.2" + "@forestadmin/datasource-customizer": "1.41.0", + "@forestadmin/datasource-toolkit": "1.30.0" }, "files": [ "dist/**/*.js", diff --git a/packages/plugin-flattener/CHANGELOG.md b/packages/plugin-flattener/CHANGELOG.md index 7127e2b42d..2578f9c085 100644 --- a/packages/plugin-flattener/CHANGELOG.md +++ b/packages/plugin-flattener/CHANGELOG.md @@ -1,3 +1,34 @@ +## @forestadmin/plugin-flattener [1.0.84](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.83...@forestadmin/plugin-flattener@1.0.84) (2024-01-26) + + + + + +### Dependencies + +* **@forestadmin/datasource-toolkit:** upgraded to 1.30.0 +* **@forestadmin/datasource-customizer:** upgraded to 1.41.0 + +## @forestadmin/plugin-flattener [1.0.83](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.82...@forestadmin/plugin-flattener@1.0.83) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.4 + +## @forestadmin/plugin-flattener [1.0.82](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.81...@forestadmin/plugin-flattener@1.0.82) (2024-01-22) + + + + + +### Dependencies + +* **@forestadmin/datasource-customizer:** upgraded to 1.40.3 + ## @forestadmin/plugin-flattener [1.0.81](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/plugin-flattener@1.0.80...@forestadmin/plugin-flattener@1.0.81) (2024-01-18) diff --git a/packages/plugin-flattener/package.json b/packages/plugin-flattener/package.json index 9e7080899b..54df432f2c 100644 --- a/packages/plugin-flattener/package.json +++ b/packages/plugin-flattener/package.json @@ -1,6 +1,6 @@ { "name": "@forestadmin/plugin-flattener", - "version": "1.0.81", + "version": "1.0.84", "description": "A plugin that allows to flatten columns and relations in Forest Admin", "main": "dist/index.js", "license": "GPL-3.0", @@ -24,11 +24,11 @@ "test": "jest" }, "devDependencies": { - "@forestadmin/datasource-customizer": "1.40.2", + "@forestadmin/datasource-customizer": "1.41.0", "@types/object-hash": "^3.0.2" }, "dependencies": { - "@forestadmin/datasource-toolkit": "1.29.2", + "@forestadmin/datasource-toolkit": "1.30.0", "object-hash": "^3.0.0" } } diff --git a/yarn.lock b/yarn.lock index 57601ff51f..99b8bc2fcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1864,38 +1864,109 @@ path-to-regexp "^6.1.0" reusify "^1.0.4" -"@forestadmin/context@1.37.1": - version "1.37.1" - resolved "https://registry.yarnpkg.com/@forestadmin/context/-/context-1.37.1.tgz#301486c456061d43cb653b3e8be60644edb3f71a" - integrity sha512-H4U1fAkzC3pm44Cdb/RoRZytI4SZlqb9YNv72ChUnUPJkNUSo2+o5JxDpjWRM1OmIb87Bv4274U3W3AmVhuwQQ== +"@forestadmin/agent@1.36.11": + version "1.36.11" + resolved "https://registry.yarnpkg.com/@forestadmin/agent/-/agent-1.36.11.tgz#17b830f901b17a47d183906f721c11232fcf10b5" + integrity sha512-WB1L5EpreVzjR2YhlxcRAP2TAUzwy79pdeffTHzueAkTUf0r0yRFUn+0eK6QMbkNqdmJbxNqZlCtxZo9wRrPTg== + dependencies: + "@fast-csv/format" "^4.3.5" + "@fastify/express" "^1.1.0" + "@forestadmin/datasource-customizer" "1.39.3" + "@forestadmin/datasource-toolkit" "1.29.1" + "@forestadmin/forestadmin-client" "1.25.3" + "@koa/cors" "^5.0.0" + "@koa/router" "^12.0.0" + forest-ip-utils "^1.0.1" + json-api-serializer "^2.6.6" + json-stringify-pretty-compact "^3.0.0" + jsonwebtoken "^9.0.0" + koa "^2.14.1" + koa-bodyparser "^4.3.0" + koa-jwt "^4.0.4" + luxon "^3.2.1" + object-hash "^3.0.0" + superagent "^8.0.6" + uuid "^9.0.0" -"@forestadmin/datasource-sequelize@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@forestadmin/datasource-sequelize/-/datasource-sequelize-1.3.1.tgz#4a9018e88d2c52bf06581fdcb8161910241029ef" - integrity sha512-J3sQEtUgAw5jtuoa4VXXn5j8Bz8ywHpkdIO3HXysK+CDkdUiXmNkD63iq45qr6dWtBp8pVbGF4Be+PtXsgePNQ== +"@forestadmin/datasource-customizer@1.39.3": + version "1.39.3" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-customizer/-/datasource-customizer-1.39.3.tgz#10365e79d3100f5a2e347d8cc39e3b98f8bc2856" + integrity sha512-gjFMqCSWU8uPYSETsC22Dkk3kbk9VV7V1FSNjhipkPyjO+duoM9IafuOb5AwJa8rFhF6y853xngXL8WBr71ugw== + dependencies: + "@forestadmin/datasource-toolkit" "1.29.1" + file-type "^16.5.4" + luxon "^3.2.1" + object-hash "^3.0.0" + uuid "^9.0.0" + +"@forestadmin/datasource-customizer@1.40.0": + version "1.40.0" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-customizer/-/datasource-customizer-1.40.0.tgz#b7ab00c3fc13441567f1ff1924f9f147f6160517" + integrity sha512-WEKv6wIlYvmMq2CEQRiwxfPbLf99NAAZfpBFLct8Vjt3ofYydw+qEsjbRZqB9RruIdeF2I1tg6Xgr00pr27jYg== + dependencies: + "@forestadmin/datasource-toolkit" "1.29.1" + file-type "^16.5.4" + luxon "^3.2.1" + object-hash "^3.0.0" + uuid "^9.0.0" + +"@forestadmin/datasource-dummy@1.0.90": + version "1.0.90" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-dummy/-/datasource-dummy-1.0.90.tgz#a4310f1417808a7f2c3d60452478cb54b352ac5e" + integrity sha512-n2/ls1/ms5qxTQst4HaCgh9Ol4po9oWQtdSxOPoFu0vOVywmy43GyP28OY4+Wir4oMmGXqXugd96R+XulUC3uA== + dependencies: + "@forestadmin/datasource-customizer" "1.40.0" + "@forestadmin/datasource-toolkit" "1.29.1" + +"@forestadmin/datasource-mongoose@1.5.32": + version "1.5.32" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-mongoose/-/datasource-mongoose-1.5.32.tgz#dfdad4dd0d63f93ba256d1136f990c334e3b3007" + integrity sha512-qkgYlr9+wQc+O9gXrHaQ6gI29SOlFWvp1CsEZ/wkjeH0k5GRQXSnkRkiDL4lVH1xb6Lk6OeVKdQX9la6sHef/g== + dependencies: + "@forestadmin/datasource-toolkit" "1.29.1" + luxon "^3.2.1" + +"@forestadmin/datasource-sequelize@1.5.26": + version "1.5.26" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-sequelize/-/datasource-sequelize-1.5.26.tgz#061b59d6c048c0642f8fd267d3f297a6abf8058d" + integrity sha512-wWaDi49NIJVxb2oglV/mIDK895Gi3A13o1ndt+9wuWMERlYQgOUEbySS5RntONzSsJ2OfdDsExIcKLmF6Btnrg== dependencies: - "@forestadmin/datasource-toolkit" "1.5.0" + "@forestadmin/datasource-toolkit" "1.29.1" -"@forestadmin/datasource-sql@1.6.4": - version "1.6.4" - resolved "https://registry.yarnpkg.com/@forestadmin/datasource-sql/-/datasource-sql-1.6.4.tgz#b11d6606163a15a8bc4aeb5c6cc9d5148a63bbeb" - integrity sha512-VhfoaNTprVMW6t6j0HaIFoolABg8eb1BbzTgcsw0ZCZ+PoJJuL2RmIgLRntVIYEci/KSitpju/OylhKon1XRbg== +"@forestadmin/datasource-sql@1.7.43": + version "1.7.43" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-sql/-/datasource-sql-1.7.43.tgz#a6e2b7f90d2cb8a547888c48736a6322e8245772" + integrity sha512-Z0U5OTBtL6QWJgTLhzK9AurBhVqg/ZzRTMCLA/bOtV9zRuObz4WiqAT6O6L0og0KEtzYtmMioUfAhoOHYSIy0A== dependencies: - "@forestadmin/datasource-sequelize" "1.3.1" - "@forestadmin/datasource-toolkit" "1.5.0" + "@forestadmin/datasource-sequelize" "1.5.26" + "@forestadmin/datasource-toolkit" "1.29.1" + "@types/ssh2" "^1.11.11" pluralize "^8.0.0" sequelize "^6.28.0" socks "^2.7.1" + ssh2 "^1.14.0" -"@forestadmin/datasource-toolkit@1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@forestadmin/datasource-toolkit/-/datasource-toolkit-1.5.0.tgz#d585b41dd9645a86344f7a63d826d64245be86a5" - integrity sha512-CljPPN25dyF85XDUgyrPh0u/fRM6Z0iMrD//XcYNsdgM29AfzjHci8yjcuZjlMVOZai2U1nTmmCB1uon8PGYug== +"@forestadmin/datasource-toolkit@1.29.1": + version "1.29.1" + resolved "https://registry.yarnpkg.com/@forestadmin/datasource-toolkit/-/datasource-toolkit-1.29.1.tgz#bf1cda9d3684208509a891367b39ec7ff112618c" + integrity sha512-JJnxRJyvZUIemp+mU0sZwIieiJys2RAAMb8JPNqmSWDmkXgVV0t0mbdmegF3xwk8lQvYbRqcTNOrT9Q0wjLXoQ== dependencies: luxon "^3.2.1" object-hash "^3.0.0" uuid "^9.0.0" +"@forestadmin/forestadmin-client@1.25.3": + version "1.25.3" + resolved "https://registry.yarnpkg.com/@forestadmin/forestadmin-client/-/forestadmin-client-1.25.3.tgz#1924a8c3e52e18282d633c2aa92137519ca9ce30" + integrity sha512-c/0igkHStVem+7SVgQGmiPR3HR6WGjQBknFdBlHUypb7qADdBeMh81meMsaq5rT74czafdb5u4VWUDu29i7f5Q== + dependencies: + eventsource "2.0.2" + json-api-serializer "^2.6.6" + jsonwebtoken "^9.0.0" + object-hash "^3.0.0" + openid-client "^5.3.1" + superagent "^8.0.6" + "@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -5079,6 +5150,11 @@ ansicolors@~0.3.2: resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" integrity sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg== +antlr4@^4.13.1-patch-1: + version "4.13.1-patch-1" + resolved "https://registry.yarnpkg.com/antlr4/-/antlr4-4.13.1-patch-1.tgz#946176f863f890964a050c4f18c47fd6f7e57602" + integrity sha512-OjFLWWLzDMV9rdFhpvroCWR4ooktNg9/nvVYSA5z28wuVpU36QUNuioR1XLnQtcjVlf8npjyz593PxnU/f/Cow== + anymatch@^3.0.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"