json-rest-api 1.0.22 → 1.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -2
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
- package/README.md +22 -1
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
- package/docs/API.md +196 -38
- package/docs/COMPARISON.md +2 -2
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +111 -5
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
- package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +370 -0
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
- package/docs/GUIDE/index.md +18 -9
- package/docs/QUICKSTART.md +5 -1
- package/docs/index.md +1 -1
- package/index.js +3 -2
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +26 -34
- package/plugins/core/connectors/express-plugin.js +97 -190
- package/plugins/core/connectors/fastify-plugin.js +206 -0
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +195 -0
- package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
- package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
- package/plugins/core/lib/anyapi/schema-utils.js +43 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
- package/plugins/core/lib/dbIntrospection.js +1078 -0
- package/plugins/core/lib/dbTablesOperations.js +1111 -160
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -3
- package/plugins/core/lib/querying/knex-process-includes.js +3 -4
- package/plugins/core/lib/querying/knex-query-helpers.js +21 -76
- package/plugins/core/lib/querying/knex-relationship-includes.js +468 -215
- package/plugins/core/lib/querying/query-builder-utils.js +19 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
- package/plugins/core/lib/querying/sort-helpers.js +33 -0
- package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
- package/plugins/core/lib/querying/url-helpers.js +8 -2
- package/plugins/core/lib/querying-writing/compile-schemas.js +20 -5
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -14
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
- package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +133 -0
- package/plugins/core/lib/storage/storage-adapter.js +82 -4
- package/plugins/core/lib/storage/storage-mapping.js +274 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
- package/plugins/core/rest-api-anyapi-knex-plugin.js +531 -312
- package/plugins/core/rest-api-autofilter-plugin.js +414 -0
- package/plugins/core/rest-api-knex-plugin.js +271 -226
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +148 -92
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
- package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
- package/plugins/core/rest-api-plugin-methods/get.js +29 -14
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
- package/plugins/core/rest-api-plugin-methods/patch.js +56 -12
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
- package/plugins/core/rest-api-plugin-methods/post.js +21 -8
- package/plugins/core/rest-api-plugin-methods/put.js +73 -21
- package/plugins/core/rest-api-plugin-methods/query.js +15 -53
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +6 -0
- package/plugins/core/rest-api-positioning-plugin.js +2 -5
- package/plugins/core/rest-api-query-projections-plugin.js +130 -0
- package/plugins/core/socketio-plugin.js +1 -4
- package/plugins/storage/local-storage.js +1 -1
- package/plugins/storage/s3-storage.js +5 -2
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +7 -0
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/autofilter.test.js +463 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/custom-id-simplified-post.test.js +75 -0
- package/tests/custom-idproperty-relationships.test.js +33 -0
- package/tests/db-introspection.test.js +521 -0
- package/tests/db-table-operations.test.js +786 -0
- package/tests/fastify-plugin.test.js +156 -0
- package/tests/field-getters.test.js +1 -2
- package/tests/field-setters.test.js +0 -4
- package/tests/fixtures/api-configs.js +278 -176
- package/tests/helpers/fake-fastify.js +99 -0
- package/tests/http-connectors-parity.test.js +235 -0
- package/tests/id-normalization.test.js +444 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +11 -11
- package/tests/projected-fields.test.js +319 -0
- package/tests/relationships.test.js +3 -3
- package/tests/searchschema-knex-behavior.test.js +187 -0
- package/tests/searchschema-merge.test.js +21 -69
- package/tests/storage-mapping.test.js +259 -0
- package/audit/00-charter.md +0 -103
- package/audit/01-scope-map.md +0 -84
- package/audit/02-coverage-matrix.csv +0 -174
- package/audit/03-invariants.md +0 -123
- package/audit/04-standards-rubric.md +0 -56
- package/audit/05-competitor-rubric.md +0 -77
- package/audit/06-findings.md +0 -160
- package/audit/07-risk-register.csv +0 -10
- package/audit/08-remediation-roadmap.md +0 -177
- package/audit/09-signoff-check.md +0 -23
- package/audit/10-final-synthesis.md +0 -29
- package/audit/evidence/code/all-files-json-rest-api.txt +0 -168
- package/audit/evidence/code/all-files-json-rest-schema.txt +0 -12
- package/audit/evidence/code/boundary-leak-checks.txt +0 -72
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__bulk-operations-plugin.js.txt +0 -480
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__express-plugin.js.txt +0 -532
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__busboy-detector.js.txt +0 -175
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__formidable-detector.js.txt +0 -138
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__request-helpers.js.txt +0 -222
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__file-handling-plugin.js.txt +0 -342
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__dbTablesOperations.js.txt +0 -304
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__fractional-positioning.js.txt +0 -188
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__compile-schemas.js.txt +0 -336
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__connectors-query-parser.js.txt +0 -165
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-capabilities.js.txt +0 -265
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-value-normalizers.js.txt +0 -369
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__default-data-helpers.js.txt +0 -72
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__field-utils.js.txt +0 -139
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-constants.js.txt +0 -45
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-field-helpers.js.txt +0 -396
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-json-api-transformers.js.txt +0 -162
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__payload-validators.js.txt +0 -1314
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__schema-helpers.js.txt +0 -488
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__scope-validations.js.txt +0 -484
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__simplified-helpers.js.txt +0 -857
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-cross-table-search.js.txt +0 -565
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-json-api-transformers-querying.js.txt +0 -485
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-pagination-helpers.js.txt +0 -560
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-process-includes.js.txt +0 -167
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers-base.js.txt +0 -109
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers.js.txt +0 -1064
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-relationship-includes.js.txt +0 -1983
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-window-queries.js.txt +0 -328
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__url-helpers.js.txt +0 -119
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__storage__storage-adapter.js.txt +0 -160
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__many-to-many-manipulations.js.txt +0 -300
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__relationship-processor.js.txt +0 -245
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-access.js.txt +0 -459
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-cors-plugin.js.txt +0 -237
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-knex-plugin.js.txt +0 -1135
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-multihome-plugin.js.txt +0 -314
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__compile-resource-schemas.js.txt +0 -7
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-relationship-routes.js.txt +0 -71
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-scope-routes.js.txt +0 -102
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__turn-scope-init-into-vars.js.txt +0 -49
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__validate-include-configurations.js.txt +0 -39
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__add-route.js.txt +0 -23
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__check-permissions.js.txt +0 -13
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__common.js.txt +0 -639
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete-relationship.js.txt +0 -117
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete.js.txt +0 -104
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__enrich-attributes.js.txt +0 -199
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-related.js.txt +0 -353
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-relationship.js.txt +0 -61
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get.js.txt +0 -184
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch-relationship.js.txt +0 -55
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch.js.txt +0 -191
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post-relationship.js.txt +0 -110
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post.js.txt +0 -168
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__put.js.txt +0 -253
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__query.js.txt +0 -207
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__release.js.txt +0 -12
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin.js.txt +0 -165
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-positioning-plugin.js.txt +0 -668
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__socketio-plugin.js.txt +0 -621
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__local-storage.js.txt +0 -218
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__s3-storage.js.txt +0 -124
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__CorePlugin.js.txt +0 -269
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__Schema.js.txt +0 -242
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__index.js.txt +0 -77
- package/audit/evidence/code/critical-pass1-risk-keywords.txt +0 -238
- package/audit/evidence/code/critical-pass1-summary.csv +0 -67
- package/audit/evidence/code/critical-pass2-summary.csv +0 -67
- package/audit/evidence/code/early-silent-zones-no-test-token.txt +0 -57
- package/audit/evidence/code/error-model-signals.txt +0 -98
- package/audit/evidence/code/git-files-json-rest-api.txt +0 -171
- package/audit/evidence/code/git-files-json-rest-schema.txt +0 -27
- package/audit/evidence/code/hook-flow-signals.txt +0 -282
- package/audit/evidence/code/idproperty-signals.txt +0 -157
- package/audit/evidence/code/import-cycles-non-anyapi.txt +0 -3
- package/audit/evidence/code/import-hotspots-non-anyapi.txt +0 -96
- package/audit/evidence/code/imports-json-rest-api.txt +0 -354
- package/audit/evidence/code/imports-json-rest-schema.txt +0 -15
- package/audit/evidence/code/in-scope-files-json-rest-api.txt +0 -160
- package/audit/evidence/code/in-scope-files-json-rest-schema.txt +0 -13
- package/audit/evidence/code/non-anyapi-core-files.txt +0 -101
- package/audit/evidence/code/permission-signals.txt +0 -193
- package/audit/evidence/code/query-include-signals.txt +0 -687
- package/audit/evidence/code/transaction-signals.txt +0 -28
- package/audit/evidence/code/validation-signals.txt +0 -650
- package/audit/evidence/code/write-path-signals.txt +0 -61
- package/audit/evidence/docs/docs-claims-signals.txt +0 -1075
- package/audit/evidence/docs/schema-docs-claims-signals.txt +0 -179
- package/audit/evidence/external/corpus-freeze.md +0 -21
- package/audit/evidence/external/npm-competitor-metadata.txt +0 -70
- package/audit/evidence/external/npm-download-signals.txt +0 -23
- package/audit/evidence/external/npm-jsonapi-server.txt +0 -8
- package/audit/evidence/external/npm-modern-jsonapi-metadata.txt +0 -38
- package/audit/evidence/external/readme-fortune-signals.txt +0 -26
- package/audit/evidence/external/readme-fortune.md +0 -165
- package/audit/evidence/external/readme-json-api-serializer-signals.txt +0 -80
- package/audit/evidence/external/readme-json-api-serializer.md +0 -690
- package/audit/evidence/external/readme-jsonapi-serde-server-signals.txt +0 -0
- package/audit/evidence/external/readme-jsonapi-serde-server.md +0 -22
- package/audit/evidence/external/readme-jsonapi-serializer-signals.txt +0 -63
- package/audit/evidence/external/readme-jsonapi-serializer.md +0 -315
- package/audit/evidence/external/readme-jsonapi-server-signals.txt +0 -19
- package/audit/evidence/external/readme-jsonapi-server.md +0 -104
- package/audit/evidence/external/readme-nestjs-json-api-signals.txt +0 -4
- package/audit/evidence/external/readme-nestjs-json-api.md +0 -94
- package/audit/evidence/external/readme-yaser-json-api-nestjs-signals.txt +0 -47
- package/audit/evidence/external/readme-yaser-json-api-nestjs.md +0 -447
- package/audit/evidence/external/standard-jsonapi-snippets.txt +0 -84
- package/audit/evidence/external/standard-rfc-snippets.txt +0 -168
- package/audit/evidence/external/standards-sources.txt +0 -6
- package/audit/evidence/runtime/baseline-commits.txt +0 -2
- package/audit/evidence/runtime/db-adapter-assumptions.txt +0 -3
- package/audit/evidence/runtime/environment.txt +0 -4
- package/audit/evidence/runtime/invariant-runtime-mapping.md +0 -38
- package/audit/evidence/runtime/probe-patch-relationship-error.exitcode +0 -1
- package/audit/evidence/runtime/probe-patch-relationship-error.log +0 -76
- package/audit/evidence/runtime/probe-patch-relationship-error.mjs +0 -67
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.exitcode +0 -1
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.log +0 -62
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.mjs +0 -47
- package/audit/evidence/runtime/test-exitcodes.txt +0 -4
- package/audit/evidence/runtime/test-summary.md +0 -107
- package/audit/evidence/runtime/trace-anchors.txt +0 -61
- package/audit/evidence/tests/access-multitenancy-test-signals.txt +0 -125
- package/audit/evidence/tests/failing-tests-summary.txt +0 -25
- package/audit/evidence/tests/npm-test-json-rest-api.exitcode +0 -1
- package/audit/evidence/tests/npm-test-json-rest-api.log +0 -5885
- package/audit/evidence/tests/npm-test-json-rest-schema.exitcode +0 -1
- package/audit/evidence/tests/npm-test-json-rest-schema.log +0 -356
- package/audit/evidence/tests/query-include-test-signals.txt +0 -948
- package/audit/evidence/tests/relationship-test-signals.txt +0 -223
- package/audit/evidence/tests/skipped-tests.txt +0 -10
- package/audit/evidence/tests/test-suite-list-non-anyapi.txt +0 -30
- package/audit/evidence/tests/test-suite-list.txt +0 -32
- package/audit/subsystems/docs-drift.md +0 -49
- package/audit/subsystems/pass2-cross-review.md +0 -131
- package/audit/subsystems/plugins-access.md +0 -51
- package/audit/subsystems/plugins-bulk.md +0 -50
- package/audit/subsystems/plugins-cors.md +0 -48
- package/audit/subsystems/plugins-file.md +0 -53
- package/audit/subsystems/plugins-multihome.md +0 -50
- package/audit/subsystems/plugins-positioning.md +0 -54
- package/audit/subsystems/plugins-socketio.md +0 -50
- package/audit/subsystems/query-engine.md +0 -61
- package/audit/subsystems/relationships.md +0 -55
- package/audit/subsystems/rest-orchestration.md +0 -66
- package/audit/subsystems/schema-core.md +0 -64
- package/audit/subsystems/storage-knex.md +0 -57
- package/audit/subsystems/tests.md +0 -48
- package/audit/subsystems/transport-express.md +0 -55
- package/audit/traces/trace-read-get.md +0 -22
- package/audit/traces/trace-read-query.md +0 -25
- package/audit/traces/trace-relationship-endpoints.md +0 -31
- package/audit/traces/trace-write-delete.md +0 -17
- package/audit/traces/trace-write-patch.md +0 -26
- package/audit/traces/trace-write-post.md +0 -22
- package/audit/traces/trace-write-put.md +0 -23
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
- package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
- package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
- package/plugins/core/rest-api-access.js +0 -459
- package/plugins/core/rest-api-multihome-plugin.js +0 -314
- package/tests/multihome.test.js +0 -553
- package/tests/multihome.test.js.bak +0 -550
- package/tests/ownership-optout.test.js +0 -76
- package/tests/rest-api-access.test.js +0 -206
|
@@ -1,1064 +0,0 @@
|
|
|
1
|
-
1 import { analyzeRequiredIndexes, buildJoinChain } from './knex-cross-table-search.js'
|
|
2
|
-
2
|
|
3
|
-
3 // Resolve operator with sensible defaults for fields declared in searchSchema.
|
|
4
|
-
4 // - If filterOperator is provided, use it as-is
|
|
5
|
-
5 // - If field type is string and no operator provided, default to 'like' (contains)
|
|
6
|
-
6 // - Otherwise default to '='
|
|
7
|
-
7 export function resolveSearchOperator (fieldDef) {
|
|
8
|
-
8 if (fieldDef && fieldDef.filterOperator) return String(fieldDef.filterOperator)
|
|
9
|
-
9 if (fieldDef && fieldDef.type === 'string') return 'like'
|
|
10
|
-
10 return '='
|
|
11
|
-
11 }
|
|
12
|
-
12
|
|
13
|
-
13 // Apply a comparison for a single field/operator/value onto a query builder.
|
|
14
|
-
14 // Handles contains/startsWith/endsWith, IN, BETWEEN, =/== null semantics,
|
|
15
|
-
15 // and uses ILIKE for case-insensitive matching on Postgres.
|
|
16
|
-
16 export function applyWhereForOperator ({ builder, columnRef, operator, value, knex, or = false }) {
|
|
17
|
-
17 const method = or ? 'orWhere' : 'where'
|
|
18
|
-
18 const methodNull = or ? 'orWhereNull' : 'whereNull'
|
|
19
|
-
19 const methodIn = or ? 'orWhereIn' : 'whereIn'
|
|
20
|
-
20 const likeOp = 'like'
|
|
21
|
-
21
|
|
22
|
-
22 const op = typeof operator === 'string' ? operator.toLowerCase() : operator
|
|
23
|
-
23
|
|
24
|
-
24 // Normalize scalar from possibly array input
|
|
25
|
-
25 const firstVal = Array.isArray(value) ? value[0] : value
|
|
26
|
-
26
|
|
27
|
-
27 // Null handling for equality and string ops
|
|
28
|
-
28 if (firstVal === null || firstVal === undefined) {
|
|
29
|
-
29 if (op === 'like' || op === 'contains' || op === 'startswith' || op === 'endswith') {
|
|
30
|
-
30 builder[methodNull](columnRef)
|
|
31
|
-
31 return
|
|
32
|
-
32 }
|
|
33
|
-
33 if (op === '=' || op === '==') {
|
|
34
|
-
34 builder[methodNull](columnRef)
|
|
35
|
-
35 return
|
|
36
|
-
36 }
|
|
37
|
-
37 }
|
|
38
|
-
38
|
|
39
|
-
39 // Text search operators
|
|
40
|
-
40 if (op === 'like' || op === 'contains') {
|
|
41
|
-
41 builder[method](columnRef, likeOp, `%${String(firstVal)}%`)
|
|
42
|
-
42 return
|
|
43
|
-
43 }
|
|
44
|
-
44 if (op === 'startswith') {
|
|
45
|
-
45 builder[method](columnRef, likeOp, `${String(firstVal)}%`)
|
|
46
|
-
46 return
|
|
47
|
-
47 }
|
|
48
|
-
48 if (op === 'endswith') {
|
|
49
|
-
49 builder[method](columnRef, likeOp, `%${String(firstVal)}`)
|
|
50
|
-
50 return
|
|
51
|
-
51 }
|
|
52
|
-
52
|
|
53
|
-
53 // Array-based operators
|
|
54
|
-
54 if (op === 'in') {
|
|
55
|
-
55 const values = Array.isArray(value) ? value : [value]
|
|
56
|
-
56 builder[methodIn](columnRef, values)
|
|
57
|
-
57 return
|
|
58
|
-
58 }
|
|
59
|
-
59 if (op === 'between') {
|
|
60
|
-
60 const values = Array.isArray(value) ? value : [value]
|
|
61
|
-
61 if (values.length === 2) {
|
|
62
|
-
62 builder.whereBetween(columnRef, values)
|
|
63
|
-
63 } else if (values.length === 1) {
|
|
64
|
-
64 if (values[0] === null || values[0] === undefined) {
|
|
65
|
-
65 builder[methodNull](columnRef)
|
|
66
|
-
66 } else {
|
|
67
|
-
67 builder[method](columnRef, '=', values[0])
|
|
68
|
-
68 }
|
|
69
|
-
69 }
|
|
70
|
-
70 return
|
|
71
|
-
71 }
|
|
72
|
-
72
|
|
73
|
-
73 // Default
|
|
74
|
-
74 builder[method](columnRef, operator || '=', firstVal)
|
|
75
|
-
75 }
|
|
76
|
-
76
|
|
77
|
-
77 const createAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
|
|
78
|
-
78 const context = hookParams.context || {}
|
|
79
|
-
79 const storageCache = new Map()
|
|
80
|
-
80
|
|
81
|
-
81 const fetchStorageAdapter = (scopeName) => {
|
|
82
|
-
82 if (!scopeName) return null
|
|
83
|
-
83 if (storageCache.has(scopeName)) return storageCache.get(scopeName)
|
|
84
|
-
84
|
|
85
|
-
85 let adapter = null
|
|
86
|
-
86 if (scopeName === context?.knexQuery?.scopeName) {
|
|
87
|
-
87 adapter = context.storageAdapter ||
|
|
88
|
-
88 context.knexQuery?.storageAdapter ||
|
|
89
|
-
89 (typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null)
|
|
90
|
-
90 if (adapter && !context.storageAdapter) {
|
|
91
|
-
91 context.storageAdapter = adapter
|
|
92
|
-
92 }
|
|
93
|
-
93 } else {
|
|
94
|
-
94 adapter = typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null
|
|
95
|
-
95 }
|
|
96
|
-
96
|
|
97
|
-
97 storageCache.set(scopeName, adapter)
|
|
98
|
-
98 return adapter
|
|
99
|
-
99 }
|
|
100
|
-
100
|
|
101
|
-
101 const defaultAliasForScope = (scopeName) => {
|
|
102
|
-
102 if (scopeName === context?.knexQuery?.scopeName) {
|
|
103
|
-
103 return context.knexQuery?.tableName || scopeName
|
|
104
|
-
104 }
|
|
105
|
-
105 return scopeName
|
|
106
|
-
106 }
|
|
107
|
-
107
|
|
108
|
-
108 const translateColumn = (scopeName, field, alias = defaultAliasForScope(scopeName)) => {
|
|
109
|
-
109 const adapter = fetchStorageAdapter(scopeName)
|
|
110
|
-
110 const translated = adapter?.translateColumn?.(field) ?? field
|
|
111
|
-
111 if (!alias) return translated
|
|
112
|
-
112 return `${alias}.${translated}`
|
|
113
|
-
113 }
|
|
114
|
-
114
|
|
115
|
-
115 const translateFilterValue = (scopeName, field, value) => {
|
|
116
|
-
116 const adapter = fetchStorageAdapter(scopeName)
|
|
117
|
-
117 if (!adapter?.translateFilterValue) return value
|
|
118
|
-
118 return adapter.translateFilterValue(field, value)
|
|
119
|
-
119 }
|
|
120
|
-
120
|
|
121
|
-
121 return {
|
|
122
|
-
122 fetchStorageAdapter,
|
|
123
|
-
123 defaultAliasForScope,
|
|
124
|
-
124 translateColumn,
|
|
125
|
-
125 translateFilterValue,
|
|
126
|
-
126 }
|
|
127
|
-
127 }
|
|
128
|
-
128
|
|
129
|
-
129 /**
|
|
130
|
-
130 * Processes filters that target polymorphic relationships where a single relationship can point to different types of resources
|
|
131
|
-
131 *
|
|
132
|
-
132 * @param {Object} hookParams - Hook parameters containing context
|
|
133
|
-
133 * @param {Object} dependencies - Dependencies injected by the plugin
|
|
134
|
-
134 *
|
|
135
|
-
135 * @example
|
|
136
|
-
136 * // Input: Search schema with polymorphic filter
|
|
137
|
-
137 * const searchSchema = {
|
|
138
|
-
138 * commentable_title: {
|
|
139
|
-
139 * type: 'string',
|
|
140
|
-
140 * polymorphicField: 'commentable', // Points to the polymorphic relationship
|
|
141
|
-
141 * targetFields: {
|
|
142
|
-
142 * posts: 'title', // When commentable_type='posts', search posts.title
|
|
143
|
-
143 * videos: 'title', // When commentable_type='videos', search videos.title
|
|
144
|
-
144 * articles: 'headline' // When commentable_type='articles', search articles.headline
|
|
145
|
-
145 * },
|
|
146
|
-
146 * filterOperator: 'like'
|
|
147
|
-
147 * }
|
|
148
|
-
148 * };
|
|
149
|
-
149 *
|
|
150
|
-
150 * // Filter request: { commentable_title: 'JavaScript' }
|
|
151
|
-
151 *
|
|
152
|
-
152 * // Result: Adds conditional LEFT JOINs and WHERE conditions
|
|
153
|
-
153 * // SQL generated:
|
|
154
|
-
154 * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
|
|
155
|
-
155 * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
|
|
156
|
-
156 * // LEFT JOIN articles ON comments.commentable_type = 'articles' AND comments.commentable_id = articles.id
|
|
157
|
-
157 * // WHERE (
|
|
158
|
-
158 * // (comments.commentable_type = 'posts' AND posts.title LIKE '%JavaScript%') OR
|
|
159
|
-
159 * // (comments.commentable_type = 'videos' AND videos.title LIKE '%JavaScript%') OR
|
|
160
|
-
160 * // (comments.commentable_type = 'articles' AND articles.headline LIKE '%JavaScript%')
|
|
161
|
-
161 * // )
|
|
162
|
-
162 *
|
|
163
|
-
163 * @example
|
|
164
|
-
164 * // Input: Complex polymorphic filter with cross-table paths
|
|
165
|
-
165 * const searchSchema = {
|
|
166
|
-
166 * commentable_author: {
|
|
167
|
-
167 * type: 'string',
|
|
168
|
-
168 * polymorphicField: 'commentable',
|
|
169
|
-
169 * targetFields: {
|
|
170
|
-
170 * posts: 'author.name', // Search post author's name
|
|
171
|
-
171 * videos: 'creator.name' // Search video creator's name
|
|
172
|
-
172 * }
|
|
173
|
-
173 * }
|
|
174
|
-
174 * };
|
|
175
|
-
175 *
|
|
176
|
-
176 * // Result: Creates nested JOINs for each polymorphic type
|
|
177
|
-
177 * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
|
|
178
|
-
178 * // LEFT JOIN users AS posts_author ON posts.author_id = posts_author.id
|
|
179
|
-
179 * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
|
|
180
|
-
180 * // LEFT JOIN users AS videos_creator ON videos.creator_id = videos_creator.id
|
|
181
|
-
181 *
|
|
182
|
-
182 * @description
|
|
183
|
-
183 * Used by:
|
|
184
|
-
184 * - rest-api-knex-plugin calls this hook first during query building
|
|
185
|
-
185 * - Must run before other filter hooks to establish JOINs
|
|
186
|
-
186 * - Applied when searchSchema contains polymorphicField definitions
|
|
187
|
-
187 *
|
|
188
|
-
188 * Purpose:
|
|
189
|
-
189 * - Enables filtering on polymorphic relationships without knowing the concrete type
|
|
190
|
-
190 * - Searches across multiple tables with a single filter parameter
|
|
191
|
-
191 * - Supports queries like "find all comments on content containing 'JavaScript'"
|
|
192
|
-
192 * - Uses conditional JOINs that only match when type field equals expected value
|
|
193
|
-
193 * - Maintains performance by leveraging indexed type/id columns
|
|
194
|
-
194 *
|
|
195
|
-
195 * Data flow:
|
|
196
|
-
196 * 1. Identifies filters with polymorphicField in searchSchema
|
|
197
|
-
197 * 2. For each polymorphic type, adds conditional LEFT JOIN
|
|
198
|
-
198 * 3. Builds OR conditions checking type field and target field together
|
|
199
|
-
199 * 4. Sets hasJoins flag for subsequent hooks
|
|
200
|
-
200 * 5. Returns modified query with polymorphic search capabilities
|
|
201
|
-
201 */
|
|
202
|
-
202 export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
203
|
-
203 const { log, scopes, knex } = dependencies
|
|
204
|
-
204 const adapterUtils = createAdapterUtilities(hookParams, dependencies)
|
|
205
|
-
205
|
|
206
|
-
206 // Extract context
|
|
207
|
-
207 const scopeName = hookParams.context?.knexQuery?.scopeName
|
|
208
|
-
208 const filters = hookParams.context?.knexQuery?.filters
|
|
209
|
-
209 const query = hookParams.context?.knexQuery?.query
|
|
210
|
-
210 const db = hookParams.context?.knexQuery?.db || knex
|
|
211
|
-
211
|
|
212
|
-
212 const schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
213
|
-
213 const tableAlias = adapterUtils.defaultAliasForScope(scopeName)
|
|
214
|
-
214
|
|
215
|
-
215 if (!filters) {
|
|
216
|
-
216 return
|
|
217
|
-
217 }
|
|
218
|
-
218
|
|
219
|
-
219 // Step 1: Identify polymorphic searches
|
|
220
|
-
220 const polymorphicSearches = new Map()
|
|
221
|
-
221 const polymorphicJoins = new Map()
|
|
222
|
-
222
|
|
223
|
-
223 for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
224
|
-
224 const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
|
|
225
|
-
225
|
|
226
|
-
226 if (fieldDef?.polymorphicField && fieldDef?.targetFields && filterValue !== undefined) {
|
|
227
|
-
227 log.trace('[POLYMORPHIC-SEARCH] Found polymorphic search:', {
|
|
228
|
-
228 filterKey,
|
|
229
|
-
229 polymorphicField: fieldDef.polymorphicField
|
|
230
|
-
230 })
|
|
231
|
-
231
|
|
232
|
-
232 polymorphicSearches.set(filterKey, {
|
|
233
|
-
233 fieldDef,
|
|
234
|
-
234 filterValue,
|
|
235
|
-
235 polymorphicField: fieldDef.polymorphicField
|
|
236
|
-
236 })
|
|
237
|
-
237 }
|
|
238
|
-
238 }
|
|
239
|
-
239
|
|
240
|
-
240 if (polymorphicSearches.size === 0) {
|
|
241
|
-
241 return
|
|
242
|
-
242 }
|
|
243
|
-
243
|
|
244
|
-
244 // Step 2: Build polymorphic JOINs
|
|
245
|
-
245 log.trace('[POLYMORPHIC-SEARCH] Building JOINs for polymorphic searches')
|
|
246
|
-
246
|
|
247
|
-
247 for (const [filterKey, searchInfo] of polymorphicSearches) {
|
|
248
|
-
248 const { fieldDef, polymorphicField } = searchInfo
|
|
249
|
-
249
|
|
250
|
-
250 // Get the relationship definition
|
|
251
|
-
251 const relationships = scopes[scopeName].vars.schemaInfo.schemaRelationships
|
|
252
|
-
252 const polyRel = relationships[polymorphicField]
|
|
253
|
-
253
|
|
254
|
-
254 if (!polyRel?.belongsToPolymorphic) {
|
|
255
|
-
255 throw new Error(
|
|
256
|
-
256 `Polymorphic field '${polymorphicField}' not found in relationships for scope '${scopeName}'`
|
|
257
|
-
257 )
|
|
258
|
-
258 }
|
|
259
|
-
259
|
|
260
|
-
260 const { typeField, idField } = polyRel.belongsToPolymorphic
|
|
261
|
-
261
|
|
262
|
-
262 // Build JOINs for each target type
|
|
263
|
-
263 for (const [targetType, targetFieldPath] of Object.entries(fieldDef.targetFields)) {
|
|
264
|
-
264 const baseAlias = `${tableAlias}_${polymorphicField}_${targetType}`
|
|
265
|
-
265
|
|
266
|
-
266 // Skip if we already added this JOIN
|
|
267
|
-
267 if (!polymorphicJoins.has(baseAlias)) {
|
|
268
|
-
268 const targetSchema = scopes[targetType].vars.schemaInfo.schemaInstance
|
|
269
|
-
269 const targetTable = targetSchema?.tableName || targetType
|
|
270
|
-
270 const targetIdField = scopes[targetType].vars.schemaInfo.idProperty || 'id'
|
|
271
|
-
271
|
|
272
|
-
272 log.trace('[POLYMORPHIC-SEARCH] Adding conditional JOIN:', {
|
|
273
|
-
273 targetType,
|
|
274
|
-
274 alias: baseAlias
|
|
275
|
-
275 })
|
|
276
|
-
276
|
|
277
|
-
277 // Conditional JOIN - only matches when type is correct
|
|
278
|
-
278 query.leftJoin(`${targetTable} as ${baseAlias}`, function () {
|
|
279
|
-
279 const typeColumn = adapterUtils.translateColumn(scopeName, typeField, tableAlias)
|
|
280
|
-
280 const idColumn = adapterUtils.translateColumn(scopeName, idField, tableAlias)
|
|
281
|
-
281 const targetIdColumn = adapterUtils.translateColumn(targetType, targetIdField, baseAlias)
|
|
282
|
-
282
|
|
283
|
-
283 this.on(typeColumn, db.raw('?', [adapterUtils.translateFilterValue(scopeName, typeField, targetType)]))
|
|
284
|
-
284 .andOn(idColumn, targetIdColumn)
|
|
285
|
-
285 })
|
|
286
|
-
286
|
|
287
|
-
287 polymorphicJoins.set(baseAlias, {
|
|
288
|
-
288 targetType,
|
|
289
|
-
289 targetTable,
|
|
290
|
-
290 targetFieldPath
|
|
291
|
-
291 })
|
|
292
|
-
292 polymorphicJoins.get(baseAlias).baseAlias = baseAlias
|
|
293
|
-
293 polymorphicJoins.get(baseAlias).targetIdField = targetIdField
|
|
294
|
-
294
|
|
295
|
-
295 if (!polymorphicJoins.get(baseAlias).aliasScopeMap) {
|
|
296
|
-
296 polymorphicJoins.get(baseAlias).aliasScopeMap = new Map()
|
|
297
|
-
297 }
|
|
298
|
-
298 polymorphicJoins.get(baseAlias).aliasScopeMap.set(baseAlias, targetType)
|
|
299
|
-
299
|
|
300
|
-
300 // Handle cross-table paths
|
|
301
|
-
301 if (targetFieldPath.includes('.')) {
|
|
302
|
-
302 log.trace('[POLYMORPHIC-SEARCH] Building cross-table JOINs for path:', targetFieldPath)
|
|
303
|
-
303
|
|
304
|
-
304 const pathParts = targetFieldPath.split('.')
|
|
305
|
-
305 let currentAlias = baseAlias
|
|
306
|
-
306 let currentScope = targetType
|
|
307
|
-
307
|
|
308
|
-
308 // Build JOIN for each segment except the last
|
|
309
|
-
309 for (let i = 0; i < pathParts.length - 1; i++) {
|
|
310
|
-
310 const relationshipName = pathParts[i]
|
|
311
|
-
311
|
|
312
|
-
312 // Find the foreign key for this relationship
|
|
313
|
-
313 const currentSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
|
|
314
|
-
314 let foreignKeyField = null
|
|
315
|
-
315 let nextScope = null
|
|
316
|
-
316
|
|
317
|
-
317 // Search schema for matching belongsTo
|
|
318
|
-
318 for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
|
|
319
|
-
319 if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
|
|
320
|
-
320 foreignKeyField = fieldName
|
|
321
|
-
321 nextScope = fieldDef.belongsTo
|
|
322
|
-
322 break
|
|
323
|
-
323 }
|
|
324
|
-
324 }
|
|
325
|
-
325
|
|
326
|
-
326 if (!foreignKeyField) {
|
|
327
|
-
327 // Check relationships for hasOne
|
|
328
|
-
328 const currentRelationships = scopes[currentScope].vars.schemaInfo.schemaRelationships
|
|
329
|
-
329 const rel = currentRelationships?.[relationshipName]
|
|
330
|
-
330 if (rel?.hasOne) {
|
|
331
|
-
331 // Handle hasOne - more complex
|
|
332
|
-
332 throw new Error(
|
|
333
|
-
333 'Cross-table polymorphic search through hasOne relationships not yet supported'
|
|
334
|
-
334 )
|
|
335
|
-
335 }
|
|
336
|
-
336
|
|
337
|
-
337 throw new Error(
|
|
338
|
-
338 `Cannot resolve relationship '${relationshipName}' in path '${targetFieldPath}' for scope '${currentScope}'`
|
|
339
|
-
339 )
|
|
340
|
-
340 }
|
|
341
|
-
341
|
|
342
|
-
342 // Build next JOIN
|
|
343
|
-
343 const nextAlias = `${currentAlias}_${relationshipName}`
|
|
344
|
-
344 const nextSchema = scopes[nextScope].vars.schemaInfo.schemaInstance
|
|
345
|
-
345 const nextTable = nextSchema?.tableName || nextScope
|
|
346
|
-
346
|
|
347
|
-
347 log.trace('[POLYMORPHIC-SEARCH] Adding cross-table JOIN:', {
|
|
348
|
-
348 from: currentAlias,
|
|
349
|
-
349 to: nextAlias,
|
|
350
|
-
350 table: nextTable
|
|
351
|
-
351 })
|
|
352
|
-
352
|
|
353
|
-
353 const nextIdField = scopes[nextScope].vars.schemaInfo.idProperty || 'id'
|
|
354
|
-
354 const sourceColumn = adapterUtils.translateColumn(currentScope, foreignKeyField, currentAlias)
|
|
355
|
-
355 const targetColumn = adapterUtils.translateColumn(nextScope, nextIdField, nextAlias)
|
|
356
|
-
356
|
|
357
|
-
357 query.leftJoin(`${nextTable} as ${nextAlias}`, sourceColumn, targetColumn)
|
|
358
|
-
358
|
|
359
|
-
359 currentAlias = nextAlias
|
|
360
|
-
360 currentScope = nextScope
|
|
361
|
-
361
|
|
362
|
-
362 polymorphicJoins.get(baseAlias).aliasScopeMap.set(currentAlias, currentScope)
|
|
363
|
-
363 }
|
|
364
|
-
364 }
|
|
365
|
-
365 }
|
|
366
|
-
366 }
|
|
367
|
-
367 }
|
|
368
|
-
368
|
|
369
|
-
369 // Pre-fetch relationships for WHERE clause processing
|
|
370
|
-
370 const polymorphicRelationships = new Map()
|
|
371
|
-
371 const relationships = scopes[scopeName].vars.schemaInfo.schemaRelationships
|
|
372
|
-
372 for (const [filterKey, searchInfo] of polymorphicSearches) {
|
|
373
|
-
373 const { polymorphicField } = searchInfo
|
|
374
|
-
374 const polyRel = relationships[polymorphicField]
|
|
375
|
-
375 if (polyRel?.belongsToPolymorphic) {
|
|
376
|
-
376 polymorphicRelationships.set(filterKey, polyRel)
|
|
377
|
-
377 }
|
|
378
|
-
378 }
|
|
379
|
-
379
|
|
380
|
-
380 // Mark that we have JOINs for other hooks
|
|
381
|
-
381 hookParams.context.knexQuery.hasJoins = true
|
|
382
|
-
382
|
|
383
|
-
383 // Step 3: Apply WHERE conditions
|
|
384
|
-
384 query.where(function applyPolymorphicWhere () {
|
|
385
|
-
385 const applyComparison = (builder, scope, alias, field, operator, rawValue) => {
|
|
386
|
-
386 const columnRef = adapterUtils.translateColumn(scope, field, alias)
|
|
387
|
-
387 const normalizedValue = adapterUtils.translateFilterValue(scope, field, rawValue)
|
|
388
|
-
388 applyWhereForOperator({ builder, columnRef, operator, value: normalizedValue, knex })
|
|
389
|
-
389 }
|
|
390
|
-
390
|
|
391
|
-
391 for (const [filterKey] of Object.entries(filters)) {
|
|
392
|
-
392 if (!polymorphicSearches.has(filterKey)) continue
|
|
393
|
-
393
|
|
394
|
-
394 const searchInfo = polymorphicSearches.get(filterKey)
|
|
395
|
-
395 const polyRel = polymorphicRelationships.get(filterKey)
|
|
396
|
-
396 if (!polyRel) continue
|
|
397
|
-
397
|
|
398
|
-
398 const { typeField } = polyRel.belongsToPolymorphic
|
|
399
|
-
399
|
|
400
|
-
400 this.where(function applyTypeOrBranch () {
|
|
401
|
-
401 for (const [targetType, targetFieldPath] of Object.entries(searchInfo.fieldDef.targetFields)) {
|
|
402
|
-
402 this.orWhere(function applyTargetBranch () {
|
|
403
|
-
403 const typeColumnAlias = tableAlias
|
|
404
|
-
404 applyComparison(this, scopeName, typeColumnAlias, typeField, '=', targetType)
|
|
405
|
-
405
|
|
406
|
-
406 const baseAlias = `${tableAlias}_${searchInfo.polymorphicField}_${targetType}`
|
|
407
|
-
407 const joinMeta = polymorphicJoins.get(baseAlias)
|
|
408
|
-
408 const aliasScopeMap = joinMeta?.aliasScopeMap || new Map([[baseAlias, targetType]])
|
|
409
|
-
409
|
|
410
|
-
410 if (targetFieldPath.includes('.')) {
|
|
411
|
-
411 const pathParts = targetFieldPath.split('.')
|
|
412
|
-
412 const fieldName = pathParts[pathParts.length - 1]
|
|
413
|
-
413
|
|
414
|
-
414 let finalAlias = baseAlias
|
|
415
|
-
415 for (let i = 0; i < pathParts.length - 1; i++) {
|
|
416
|
-
416 finalAlias = `${finalAlias}_${pathParts[i]}`
|
|
417
|
-
417 }
|
|
418
|
-
418
|
|
419
|
-
419 const finalScope = aliasScopeMap.get(finalAlias) || targetType
|
|
420
|
-
420 const operator = resolveSearchOperator(searchInfo.fieldDef)
|
|
421
|
-
421 applyComparison(this, finalScope, finalAlias, fieldName, operator, searchInfo.filterValue)
|
|
422
|
-
422 } else {
|
|
423
|
-
423 const operator = resolveSearchOperator(searchInfo.fieldDef)
|
|
424
|
-
424 const finalScope = aliasScopeMap.get(baseAlias) || targetType
|
|
425
|
-
425 applyComparison(this, finalScope, baseAlias, targetFieldPath, operator, searchInfo.filterValue)
|
|
426
|
-
426 }
|
|
427
|
-
427 })
|
|
428
|
-
428 }
|
|
429
|
-
429 })
|
|
430
|
-
430 }
|
|
431
|
-
431 })
|
|
432
|
-
432 }
|
|
433
|
-
433
|
|
434
|
-
434 /**
|
|
435
|
-
435 * Processes filters that require JOINs to access fields in related tables using dot notation
|
|
436
|
-
436 *
|
|
437
|
-
437 * @param {Object} hookParams - Hook parameters containing context
|
|
438
|
-
438 * @param {Object} dependencies - Dependencies injected by the plugin
|
|
439
|
-
439 *
|
|
440
|
-
440 * @example
|
|
441
|
-
441 * // Input: Simple cross-table filter
|
|
442
|
-
442 * const searchSchema = {
|
|
443
|
-
443 * author_name: {
|
|
444
|
-
444 * type: 'string',
|
|
445
|
-
445 * actualField: 'author.name', // Dot notation indicates JOIN needed
|
|
446
|
-
446 * filterOperator: 'like'
|
|
447
|
-
447 * }
|
|
448
|
-
448 * };
|
|
449
|
-
449 *
|
|
450
|
-
450 * // Filter request: { author_name: 'Smith' }
|
|
451
|
-
451 * // Query before: SELECT * FROM articles
|
|
452
|
-
452 *
|
|
453
|
-
453 * // Result: Adds JOIN and qualified WHERE
|
|
454
|
-
454 * // Query after:
|
|
455
|
-
455 * // SELECT * FROM articles
|
|
456
|
-
456 * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
|
|
457
|
-
457 * // WHERE articles_author.name LIKE '%Smith%'
|
|
458
|
-
458 *
|
|
459
|
-
459 * @example
|
|
460
|
-
460 * // Input: Multi-field search across tables
|
|
461
|
-
461 * const searchSchema = {
|
|
462
|
-
462 * search: {
|
|
463
|
-
463 * type: 'string',
|
|
464
|
-
464 * oneOf: [
|
|
465
|
-
465 * 'title', // Local field
|
|
466
|
-
466 * 'content', // Local field
|
|
467
|
-
467 * 'author.name', // Requires JOIN to users
|
|
468
|
-
468 * 'category.title' // Requires JOIN to categories
|
|
469
|
-
469 * ],
|
|
470
|
-
470 * filterOperator: 'like'
|
|
471
|
-
471 * }
|
|
472
|
-
472 * };
|
|
473
|
-
473 *
|
|
474
|
-
474 * // Filter request: { search: 'JavaScript' }
|
|
475
|
-
475 *
|
|
476
|
-
476 * // Result: Multiple JOINs and OR conditions
|
|
477
|
-
477 * // SELECT DISTINCT * FROM articles
|
|
478
|
-
478 * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
|
|
479
|
-
479 * // LEFT JOIN categories AS articles_category ON articles.category_id = articles_category.id
|
|
480
|
-
480 * // WHERE (
|
|
481
|
-
481 * // articles.title LIKE '%JavaScript%' OR
|
|
482
|
-
482 * // articles.content LIKE '%JavaScript%' OR
|
|
483
|
-
483 * // articles_author.name LIKE '%JavaScript%' OR
|
|
484
|
-
484 * // articles_category.title LIKE '%JavaScript%'
|
|
485
|
-
485 * // )
|
|
486
|
-
486 *
|
|
487
|
-
487 * @example
|
|
488
|
-
488 * // Input: Deep nested relationships (3 levels)
|
|
489
|
-
489 * const searchSchema = {
|
|
490
|
-
490 * company_country: {
|
|
491
|
-
491 * type: 'string',
|
|
492
|
-
492 * actualField: 'author.company.country.name'
|
|
493
|
-
493 * }
|
|
494
|
-
494 * };
|
|
495
|
-
495 *
|
|
496
|
-
496 * // Filter request: { company_country: 'USA' }
|
|
497
|
-
497 *
|
|
498
|
-
498 * // Result: Chain of JOINs following relationships
|
|
499
|
-
499 * // SELECT * FROM articles
|
|
500
|
-
500 * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
|
|
501
|
-
501 * // LEFT JOIN companies AS articles_author_company ON articles_author.company_id = articles_author_company.id
|
|
502
|
-
502 * // LEFT JOIN countries AS articles_author_company_country ON articles_author_company.country_id = articles_author_company_country.id
|
|
503
|
-
503 * // WHERE articles_author_company_country.name = 'USA'
|
|
504
|
-
504 *
|
|
505
|
-
505 * @description
|
|
506
|
-
506 * Used by:
|
|
507
|
-
507 * - rest-api-knex-plugin calls this hook second during query building
|
|
508
|
-
508 * - Runs after polymorphicFiltersHook but before basicFiltersHook
|
|
509
|
-
509 * - Applied when filters contain dot notation in actualField or oneOf
|
|
510
|
-
510 *
|
|
511
|
-
511 * Purpose:
|
|
512
|
-
512 * - Enables filtering on related table fields without manual JOIN writing
|
|
513
|
-
513 * - Automatically builds JOIN chains from dot notation paths
|
|
514
|
-
514 * - Detects one-to-many relationships and adds DISTINCT to prevent duplicates
|
|
515
|
-
515 * - Creates unique aliases to avoid naming conflicts
|
|
516
|
-
516 * - Validates that target fields are indexed for performance
|
|
517
|
-
517 *
|
|
518
|
-
518 * Data flow:
|
|
519
|
-
519 * 1. Scans filters for dot notation in actualField or oneOf arrays
|
|
520
|
-
520 * 2. For each cross-table reference, builds JOIN chain via buildJoinChain
|
|
521
|
-
521 * 3. Applies JOINs to query with proper aliasing
|
|
522
|
-
522 * 4. Adds DISTINCT if any one-to-many JOINs detected
|
|
523
|
-
523 * 5. Applies WHERE conditions using qualified field names
|
|
524
|
-
524 * 6. Sets hasJoins flag for basicFiltersHook to use
|
|
525
|
-
525 */
|
|
526
|
-
526 export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
527
|
-
527 const { log, scopes, knex } = dependencies
|
|
528
|
-
528 const adapterUtils = createAdapterUtilities(hookParams, dependencies)
|
|
529
|
-
529
|
|
530
|
-
530 // Extract context
|
|
531
|
-
531 const scopeName = hookParams.context?.knexQuery?.scopeName
|
|
532
|
-
532 const filters = hookParams.context?.knexQuery?.filters
|
|
533
|
-
533 const query = hookParams.context?.knexQuery?.query
|
|
534
|
-
534 const db = hookParams.context?.knexQuery?.db || knex
|
|
535
|
-
535
|
|
536
|
-
536 const schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
537
|
-
537 const tableName = schemaInfo.tableName
|
|
538
|
-
538 const tableAlias = adapterUtils.defaultAliasForScope(scopeName)
|
|
539
|
-
539 const aliasScopeMap = new Map()
|
|
540
|
-
540 aliasScopeMap.set(tableAlias, scopeName)
|
|
541
|
-
541
|
|
542
|
-
542 if (!filters) {
|
|
543
|
-
543 return
|
|
544
|
-
544 }
|
|
545
|
-
545
|
|
546
|
-
546 // Step 1: Analyze indexes
|
|
547
|
-
547 const requiredIndexes = analyzeRequiredIndexes(scopes, log, scopeName, schemaInfo)
|
|
548
|
-
548 if (requiredIndexes.length > 0) {
|
|
549
|
-
549 log.debug('Cross-table search requires indexes:', requiredIndexes)
|
|
550
|
-
550 }
|
|
551
|
-
551
|
|
552
|
-
552 // Step 2: Build JOIN maps
|
|
553
|
-
553 const joinMap = new Map()
|
|
554
|
-
554 const fieldPathMap = new Map()
|
|
555
|
-
555 let hasCrossTableFilters = false
|
|
556
|
-
556
|
|
557
|
-
557 for (const [filterKey, fieldDef] of Object.entries(schemaInfo.searchSchemaStructure)) {
|
|
558
|
-
558 if (filters[filterKey] === undefined) continue
|
|
559
|
-
559
|
|
560
|
-
560 // Skip polymorphic filters
|
|
561
|
-
561 if (fieldDef.polymorphicField) continue
|
|
562
|
-
562
|
|
563
|
-
563 // Check actualField for cross-table references
|
|
564
|
-
564 if (fieldDef.actualField?.includes('.')) {
|
|
565
|
-
565 hasCrossTableFilters = true
|
|
566
|
-
566 log.trace('[JOIN-DETECTION] Cross-table actualField found', { filterKey, actualField: fieldDef.actualField, scopeName })
|
|
567
|
-
567 const joinInfo = await buildJoinChain(scopes, log, scopeName, fieldDef.actualField)
|
|
568
|
-
568 if (!joinMap.has(joinInfo.joinAlias)) {
|
|
569
|
-
569 joinMap.set(joinInfo.joinAlias, joinInfo)
|
|
570
|
-
570 }
|
|
571
|
-
571 fieldPathMap.set(fieldDef.actualField, `${joinInfo.joinAlias}.${joinInfo.targetField}`)
|
|
572
|
-
572 }
|
|
573
|
-
573
|
|
574
|
-
574 // Check oneOf for cross-table references
|
|
575
|
-
575 if (fieldDef.oneOf && Array.isArray(fieldDef.oneOf)) {
|
|
576
|
-
576 for (const field of fieldDef.oneOf) {
|
|
577
|
-
577 if (field.includes('.')) {
|
|
578
|
-
578 hasCrossTableFilters = true
|
|
579
|
-
579 log.trace('[JOIN-DETECTION] Cross-table oneOf field found', { filterKey, field, scopeName })
|
|
580
|
-
580 const joinInfo = await buildJoinChain(scopes, log, scopeName, field)
|
|
581
|
-
581 if (!joinMap.has(joinInfo.joinAlias)) {
|
|
582
|
-
582 joinMap.set(joinInfo.joinAlias, joinInfo)
|
|
583
|
-
583 }
|
|
584
|
-
584 fieldPathMap.set(field, `${joinInfo.joinAlias}.${joinInfo.targetField}`)
|
|
585
|
-
585 }
|
|
586
|
-
586 }
|
|
587
|
-
587 }
|
|
588
|
-
588 }
|
|
589
|
-
589
|
|
590
|
-
590 if (!hasCrossTableFilters) {
|
|
591
|
-
591 return
|
|
592
|
-
592 }
|
|
593
|
-
593
|
|
594
|
-
594 joinMap.forEach((joinInfo) => {
|
|
595
|
-
595 if (joinInfo.joinAlias) {
|
|
596
|
-
596 aliasScopeMap.set(joinInfo.joinAlias, joinInfo.targetScopeName || joinInfo.targetTableName)
|
|
597
|
-
597 }
|
|
598
|
-
598 if (joinInfo.isMultiLevel && Array.isArray(joinInfo.joinChain)) {
|
|
599
|
-
599 joinInfo.joinChain.forEach((join) => {
|
|
600
|
-
600 if (join.joinAlias) {
|
|
601
|
-
601 aliasScopeMap.set(join.joinAlias, join.targetScopeName || join.targetTableName)
|
|
602
|
-
602 }
|
|
603
|
-
603 })
|
|
604
|
-
604 }
|
|
605
|
-
605 })
|
|
606
|
-
606
|
|
607
|
-
607 const translateQualifiedColumn = (qualified) => {
|
|
608
|
-
608 const trimmed = qualified.trim()
|
|
609
|
-
609 if (!trimmed.includes('.')) {
|
|
610
|
-
610 return adapterUtils.translateColumn(scopeName, trimmed, tableAlias)
|
|
611
|
-
611 }
|
|
612
|
-
612
|
|
613
|
-
613 const [alias, ...fieldParts] = trimmed.split('.')
|
|
614
|
-
614 const field = fieldParts.join('.')
|
|
615
|
-
615 const scopeForAlias = aliasScopeMap.get(alias) || scopeName
|
|
616
|
-
616 return adapterUtils.translateColumn(scopeForAlias, field, alias)
|
|
617
|
-
617 }
|
|
618
|
-
618
|
|
619
|
-
619 const resolveFieldColumn = (field) => {
|
|
620
|
-
620 if (field.includes('.')) {
|
|
621
|
-
621 const qualified = fieldPathMap.get(field) || field
|
|
622
|
-
622 return translateQualifiedColumn(qualified)
|
|
623
|
-
623 }
|
|
624
|
-
624 return adapterUtils.translateColumn(scopeName, field, tableAlias)
|
|
625
|
-
625 }
|
|
626
|
-
626
|
|
627
|
-
627 const normalizeFieldValue = (field, value) => {
|
|
628
|
-
628 if (field.includes('.')) {
|
|
629
|
-
629 const qualified = fieldPathMap.get(field) || field
|
|
630
|
-
630 const [alias, ...rest] = qualified.split('.')
|
|
631
|
-
631 const fieldName = rest.join('.')
|
|
632
|
-
632 const valueScope = aliasScopeMap.get(alias) || scopeName
|
|
633
|
-
633 if (Array.isArray(value)) {
|
|
634
|
-
634 return value.map((entry) => adapterUtils.translateFilterValue(valueScope, fieldName, entry))
|
|
635
|
-
635 }
|
|
636
|
-
636 return adapterUtils.translateFilterValue(valueScope, fieldName, value)
|
|
637
|
-
637 }
|
|
638
|
-
638 if (Array.isArray(value)) {
|
|
639
|
-
639 return value.map((entry) => adapterUtils.translateFilterValue(scopeName, field, entry))
|
|
640
|
-
640 }
|
|
641
|
-
641 return adapterUtils.translateFilterValue(scopeName, field, value)
|
|
642
|
-
642 }
|
|
643
|
-
643
|
|
644
|
-
644 // Step 3: Apply JOINs
|
|
645
|
-
645 const appliedJoins = new Set()
|
|
646
|
-
646
|
|
647
|
-
647 const applyPolymorphicJoin = (join) => {
|
|
648
|
-
648 query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function () {
|
|
649
|
-
649 const parts = join.joinCondition.split(' AND ')
|
|
650
|
-
650 const [typeCondition, idCondition] = parts
|
|
651
|
-
651
|
|
652
|
-
652 const typeMatch = typeCondition.match(/(.+?)\s*=\s*'(.+?)'/)
|
|
653
|
-
653 if (typeMatch) {
|
|
654
|
-
654 const typeColumnToken = typeMatch[1].trim()
|
|
655
|
-
655 const typeAlias = typeColumnToken.split('.')[0]
|
|
656
|
-
656 const typeField = typeColumnToken.split('.').slice(1).join('.')
|
|
657
|
-
657 const typeScope = aliasScopeMap.get(typeAlias) || scopeName
|
|
658
|
-
658 const translatedColumn = translateQualifiedColumn(typeColumnToken)
|
|
659
|
-
659 const translatedValue = adapterUtils.translateFilterValue(typeScope, typeField, typeMatch[2])
|
|
660
|
-
660 this.on(translatedColumn, db.raw('?', [translatedValue]))
|
|
661
|
-
661 }
|
|
662
|
-
662
|
|
663
|
-
663 const idMatch = idCondition.match(/(.+?)\s*=\s*(.+)/)
|
|
664
|
-
664 if (idMatch) {
|
|
665
|
-
665 const leftToken = idMatch[1].trim()
|
|
666
|
-
666 const rightToken = idMatch[2].trim()
|
|
667
|
-
667 const leftColumn = translateQualifiedColumn(leftToken)
|
|
668
|
-
668 const rightColumn = translateQualifiedColumn(rightToken)
|
|
669
|
-
669 this.andOn(leftColumn, rightColumn)
|
|
670
|
-
670 }
|
|
671
|
-
671 })
|
|
672
|
-
672 }
|
|
673
|
-
673
|
|
674
|
-
674 const applyStandardJoin = (join) => {
|
|
675
|
-
675 const [leftSide, rightSide] = join.joinCondition.split(' = ')
|
|
676
|
-
676 const translatedLeft = translateQualifiedColumn(leftSide.trim())
|
|
677
|
-
677 const translatedRight = translateQualifiedColumn(rightSide.trim())
|
|
678
|
-
678 query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function () {
|
|
679
|
-
679 this.on(translatedLeft, translatedRight)
|
|
680
|
-
680 })
|
|
681
|
-
681 }
|
|
682
|
-
682
|
|
683
|
-
683 const processJoin = (join) => {
|
|
684
|
-
684 const joinKey = `${join.joinAlias}:${join.joinCondition}`
|
|
685
|
-
685 if (appliedJoins.has(joinKey)) return
|
|
686
|
-
686
|
|
687
|
-
687 if (join.isPolymorphic && join.joinCondition.includes(' AND ')) {
|
|
688
|
-
688 applyPolymorphicJoin(join)
|
|
689
|
-
689 } else {
|
|
690
|
-
690 applyStandardJoin(join)
|
|
691
|
-
691 }
|
|
692
|
-
692
|
|
693
|
-
693 appliedJoins.add(joinKey)
|
|
694
|
-
694 }
|
|
695
|
-
695
|
|
696
|
-
696 joinMap.forEach((joinInfo) => {
|
|
697
|
-
697 if (joinInfo.isMultiLevel && Array.isArray(joinInfo.joinChain)) {
|
|
698
|
-
698 joinInfo.joinChain.forEach(processJoin)
|
|
699
|
-
699 } else {
|
|
700
|
-
700 processJoin(joinInfo)
|
|
701
|
-
701 }
|
|
702
|
-
702 })
|
|
703
|
-
703
|
|
704
|
-
704 // Step 4: Handle DISTINCT
|
|
705
|
-
705 let hasOneToManyJoins = false
|
|
706
|
-
706 joinMap.forEach((joinInfo) => {
|
|
707
|
-
707 if (joinInfo.isOneToMany) {
|
|
708
|
-
708 hasOneToManyJoins = true
|
|
709
|
-
709 } else if (joinInfo.isMultiLevel && joinInfo.joinChain) {
|
|
710
|
-
710 joinInfo.joinChain.forEach(join => {
|
|
711
|
-
711 if (join.isOneToMany) hasOneToManyJoins = true
|
|
712
|
-
712 })
|
|
713
|
-
713 }
|
|
714
|
-
714 })
|
|
715
|
-
715
|
|
716
|
-
716 if (hasOneToManyJoins) {
|
|
717
|
-
717 log.trace('[DISTINCT] Adding DISTINCT to query due to one-to-many JOINs')
|
|
718
|
-
718 query.distinct()
|
|
719
|
-
719 }
|
|
720
|
-
720
|
|
721
|
-
721 // Store state for basic filters hook
|
|
722
|
-
722 hookParams.context.knexQuery.hasJoins = true
|
|
723
|
-
723
|
|
724
|
-
724 // Step 5: Apply WHERE conditions for cross-table filters
|
|
725
|
-
725 query.where(function () {
|
|
726
|
-
726 for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
727
|
-
727 const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
|
|
728
|
-
728 if (!fieldDef) continue
|
|
729
|
-
729
|
|
730
|
-
730 // Skip non-cross-table and polymorphic filters
|
|
731
|
-
731 if (fieldDef.polymorphicField) continue
|
|
732
|
-
732 if (!fieldDef.actualField?.includes('.') &&
|
|
733
|
-
733 !fieldDef.oneOf?.some(f => f.includes('.'))) {
|
|
734
|
-
734 continue
|
|
735
|
-
735 }
|
|
736
|
-
736
|
|
737
|
-
737 // Process cross-table filters
|
|
738
|
-
738 switch (true) {
|
|
739
|
-
739 case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
|
|
740
|
-
740 const operator = resolveSearchOperator(fieldDef)
|
|
741
|
-
741
|
|
742
|
-
742 let searchTerms = [filterValue]
|
|
743
|
-
743 if (fieldDef.splitBy && typeof filterValue === 'string') {
|
|
744
|
-
744 searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim())
|
|
745
|
-
745 } else if (Array.isArray(filterValue)) {
|
|
746
|
-
746 searchTerms = filterValue
|
|
747
|
-
747 }
|
|
748
|
-
748
|
|
749
|
-
749 const applyTermComparison = (builder, method, field, raw) => {
|
|
750
|
-
750 const columnRef = resolveFieldColumn(field)
|
|
751
|
-
751 const normalized = normalizeFieldValue(field, raw)
|
|
752
|
-
752 applyWhereForOperator({
|
|
753
|
-
753 builder,
|
|
754
|
-
754 columnRef,
|
|
755
|
-
755 operator,
|
|
756
|
-
756 value: normalized,
|
|
757
|
-
757 knex,
|
|
758
|
-
758 or: method === 'or',
|
|
759
|
-
759 })
|
|
760
|
-
760 }
|
|
761
|
-
761
|
|
762
|
-
762 this.where(function () {
|
|
763
|
-
763 if (fieldDef.matchAll && searchTerms.length > 1) {
|
|
764
|
-
764 searchTerms.forEach(term => {
|
|
765
|
-
765 this.andWhere(function () {
|
|
766
|
-
766 fieldDef.oneOf.forEach((field, index) => {
|
|
767
|
-
767 const method = index === 0 ? 'and' : 'or'
|
|
768
|
-
768 applyTermComparison(this, method, field, term)
|
|
769
|
-
769 })
|
|
770
|
-
770 })
|
|
771
|
-
771 })
|
|
772
|
-
772 } else {
|
|
773
|
-
773 fieldDef.oneOf.forEach((field, index) => {
|
|
774
|
-
774 const method = index === 0 ? 'and' : 'or'
|
|
775
|
-
775 applyTermComparison(this, method, field, filterValue)
|
|
776
|
-
776 })
|
|
777
|
-
777 }
|
|
778
|
-
778 })
|
|
779
|
-
779 break
|
|
780
|
-
780 }
|
|
781
|
-
781
|
|
782
|
-
782 case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
|
|
783
|
-
783 fieldDef.applyFilter.call(this, this, filterValue)
|
|
784
|
-
784 break
|
|
785
|
-
785
|
|
786
|
-
786 default:
|
|
787
|
-
787 const targetField = fieldDef.actualField || filterKey
|
|
788
|
-
788 const columnRef = resolveFieldColumn(targetField)
|
|
789
|
-
789 const operator = resolveSearchOperator(fieldDef)
|
|
790
|
-
790 const normalized = normalizeFieldValue(targetField, filterValue)
|
|
791
|
-
791 applyWhereForOperator({ builder: this, columnRef, operator, value: normalized, knex })
|
|
792
|
-
792 break
|
|
793
|
-
793 }
|
|
794
|
-
794 }
|
|
795
|
-
795 })
|
|
796
|
-
796 }
|
|
797
|
-
797
|
|
798
|
-
798 /**
|
|
799
|
-
799 * Processes filters that apply directly to fields on the main table
|
|
800
|
-
800 *
|
|
801
|
-
801 * @param {Object} hookParams - Hook parameters containing context
|
|
802
|
-
802 * @param {Object} dependencies - Dependencies injected by the plugin
|
|
803
|
-
803 *
|
|
804
|
-
804 * @example
|
|
805
|
-
805 * // Input: Basic equality filter
|
|
806
|
-
806 * const searchSchema = {
|
|
807
|
-
807 * status: {
|
|
808
|
-
808 * type: 'string',
|
|
809
|
-
809 * filterOperator: '=' // Default is '=' if not specified
|
|
810
|
-
810 * }
|
|
811
|
-
811 * };
|
|
812
|
-
812 *
|
|
813
|
-
813 * // Filter request: { status: 'published' }
|
|
814
|
-
814 * // Query before: SELECT * FROM articles
|
|
815
|
-
815 *
|
|
816
|
-
816 * // Result: Adds qualified WHERE clause
|
|
817
|
-
817 * // Query after: SELECT * FROM articles WHERE articles.status = 'published'
|
|
818
|
-
818 *
|
|
819
|
-
819 * @example
|
|
820
|
-
820 * // Input: LIKE filter for partial text matching
|
|
821
|
-
821 * const searchSchema = {
|
|
822
|
-
822 * title: {
|
|
823
|
-
823 * type: 'string',
|
|
824
|
-
824 * filterOperator: 'like'
|
|
825
|
-
825 * },
|
|
826
|
-
826 * content: {
|
|
827
|
-
827 * type: 'string',
|
|
828
|
-
828 * filterOperator: 'like'
|
|
829
|
-
829 * }
|
|
830
|
-
830 * };
|
|
831
|
-
831 *
|
|
832
|
-
832 * // Filter request: { title: 'JavaScript', content: 'async' }
|
|
833
|
-
833 *
|
|
834
|
-
834 * // Result: Multiple LIKE conditions
|
|
835
|
-
835 * // WHERE articles.title LIKE '%JavaScript%'
|
|
836
|
-
836 * // AND articles.content LIKE '%async%'
|
|
837
|
-
837 *
|
|
838
|
-
838 * @example
|
|
839
|
-
839 * // Input: Multi-field OR search with oneOf
|
|
840
|
-
840 * const searchSchema = {
|
|
841
|
-
841 * search: {
|
|
842
|
-
842 * type: 'string',
|
|
843
|
-
843 * oneOf: ['title', 'content', 'summary'],
|
|
844
|
-
844 * filterOperator: 'like',
|
|
845
|
-
845 * splitBy: ' ', // Split search terms by space
|
|
846
|
-
846 * matchAll: true // All terms must match somewhere
|
|
847
|
-
847 * }
|
|
848
|
-
848 * };
|
|
849
|
-
849 *
|
|
850
|
-
850 * // Filter request: { search: 'REST API' }
|
|
851
|
-
851 *
|
|
852
|
-
852 * // Result: Each term must match in at least one field
|
|
853
|
-
853 * // WHERE (
|
|
854
|
-
854 * // (articles.title LIKE '%REST%' OR articles.content LIKE '%REST%' OR articles.summary LIKE '%REST%')
|
|
855
|
-
855 * // AND
|
|
856
|
-
856 * // (articles.title LIKE '%API%' OR articles.content LIKE '%API%' OR articles.summary LIKE '%API%')
|
|
857
|
-
857 * // )
|
|
858
|
-
858 *
|
|
859
|
-
859 * @example
|
|
860
|
-
860 * // Input: Advanced operators - IN and BETWEEN
|
|
861
|
-
861 * const searchSchema = {
|
|
862
|
-
862 * category_id: {
|
|
863
|
-
863 * type: 'array',
|
|
864
|
-
864 * filterOperator: 'in'
|
|
865
|
-
865 * },
|
|
866
|
-
866 * price: {
|
|
867
|
-
867 * type: 'array',
|
|
868
|
-
868 * filterOperator: 'between'
|
|
869
|
-
869 * },
|
|
870
|
-
870 * tags: {
|
|
871
|
-
871 * type: 'array',
|
|
872
|
-
872 * filterOperator: 'in'
|
|
873
|
-
873 * }
|
|
874
|
-
874 * };
|
|
875
|
-
875 *
|
|
876
|
-
876 * // Filter request: {
|
|
877
|
-
877 * // category_id: [1, 2, 3],
|
|
878
|
-
878 * // price: [10.00, 99.99],
|
|
879
|
-
879 * // tags: ['javascript', 'nodejs']
|
|
880
|
-
880 * // }
|
|
881
|
-
881 *
|
|
882
|
-
882 * // Result: IN and BETWEEN clauses
|
|
883
|
-
883 * // WHERE articles.category_id IN (1, 2, 3)
|
|
884
|
-
884 * // AND articles.price BETWEEN 10.00 AND 99.99
|
|
885
|
-
885 * // AND articles.tags IN ('javascript', 'nodejs')
|
|
886
|
-
886 *
|
|
887
|
-
887 * @example
|
|
888
|
-
888 * // Input: Custom filter function
|
|
889
|
-
889 * const searchSchema = {
|
|
890
|
-
890 * has_comments: {
|
|
891
|
-
891 * type: 'boolean',
|
|
892
|
-
892 * applyFilter: function(query, value) {
|
|
893
|
-
893 * if (value === true) {
|
|
894
|
-
894 * query.whereExists(function() {
|
|
895
|
-
895 * this.select('id')
|
|
896
|
-
896 * .from('comments')
|
|
897
|
-
897 * .whereRaw('comments.article_id = articles.id');
|
|
898
|
-
898 * });
|
|
899
|
-
899 * } else if (value === false) {
|
|
900
|
-
900 * query.whereNotExists(function() {
|
|
901
|
-
901 * this.select('id')
|
|
902
|
-
902 * .from('comments')
|
|
903
|
-
903 * .whereRaw('comments.article_id = articles.id');
|
|
904
|
-
904 * });
|
|
905
|
-
905 * }
|
|
906
|
-
906 * }
|
|
907
|
-
907 * }
|
|
908
|
-
908 * };
|
|
909
|
-
909 *
|
|
910
|
-
910 * // Filter request: { has_comments: true }
|
|
911
|
-
911 *
|
|
912
|
-
912 * // Result: Subquery to check existence
|
|
913
|
-
913 * // WHERE EXISTS (
|
|
914
|
-
914 * // SELECT id FROM comments WHERE comments.article_id = articles.id
|
|
915
|
-
915 * // )
|
|
916
|
-
916 *
|
|
917
|
-
917 * @description
|
|
918
|
-
918 * Used by:
|
|
919
|
-
919 * - rest-api-knex-plugin calls this hook last during query building
|
|
920
|
-
920 * - Runs after all JOINs have been established by previous hooks
|
|
921
|
-
921 * - Handles all non-cross-table, non-polymorphic filters
|
|
922
|
-
922 *
|
|
923
|
-
923 * Purpose:
|
|
924
|
-
924 * - Implements standard SQL filtering operations safely via Knex
|
|
925
|
-
925 * - Always qualifies field names with table name to prevent ambiguity
|
|
926
|
-
926 * - Supports various operators: =, like, in, between, and custom
|
|
927
|
-
927 * - Enables multi-field OR searches with optional term splitting
|
|
928
|
-
928 * - Handles null values appropriately (using whereNull)
|
|
929
|
-
929 * - Allows custom filter logic via applyFilter functions
|
|
930
|
-
930 *
|
|
931
|
-
931 * Data flow:
|
|
932
|
-
932 * 1. Skips filters already handled by polymorphic/cross-table hooks
|
|
933
|
-
933 * 2. Qualifies all field names with table name (e.g., articles.title)
|
|
934
|
-
934 * 3. Applies appropriate WHERE clause based on filterOperator
|
|
935
|
-
935 * 4. For oneOf filters, creates OR conditions across specified fields
|
|
936
|
-
936 * 5. For custom filters, delegates to applyFilter function
|
|
937
|
-
937 * 6. Returns query with all basic filters applied
|
|
938
|
-
938 */
|
|
939
|
-
939 export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
940
|
-
940 const { log, scopes, knex } = dependencies
|
|
941
|
-
941 const adapterUtils = createAdapterUtilities(hookParams, dependencies)
|
|
942
|
-
942
|
|
943
|
-
943 // Extract context
|
|
944
|
-
944 const scopeName = hookParams.context?.knexQuery?.scopeName
|
|
945
|
-
945 const filters = hookParams.context?.knexQuery?.filters
|
|
946
|
-
946 const query = hookParams.context?.knexQuery?.query
|
|
947
|
-
947 const db = hookParams.context?.knexQuery?.db || knex
|
|
948
|
-
948
|
|
949
|
-
949 const schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
950
|
-
950 const tableName = schemaInfo.tableName
|
|
951
|
-
951 const tableAlias = adapterUtils.defaultAliasForScope(scopeName)
|
|
952
|
-
952
|
|
953
|
-
953 log.trace('[DEBUG basicFiltersHook] Called with:', {
|
|
954
|
-
954 scopeName,
|
|
955
|
-
955 hasFilters: !!filters,
|
|
956
|
-
956 filters,
|
|
957
|
-
957 searchSchemaKeys: Object.keys(schemaInfo.searchSchemaStructure || {}),
|
|
958
|
-
958 tableName
|
|
959
|
-
959 })
|
|
960
|
-
960
|
|
961
|
-
961 if (!filters) {
|
|
962
|
-
962 log.trace('[DEBUG basicFiltersHook] Returning early - no filters')
|
|
963
|
-
963 return
|
|
964
|
-
964 }
|
|
965
|
-
965
|
|
966
|
-
966 // Check if we have any JOINs applied (to know if we need to qualify fields)
|
|
967
|
-
967 // Instead of relying on hasJoins flag, always qualify fields for safety
|
|
968
|
-
968 const qualifyField = (field) => adapterUtils.translateColumn(scopeName, field, tableAlias)
|
|
969
|
-
969 const normalizeValue = (field, value) => {
|
|
970
|
-
970 if (Array.isArray(value)) {
|
|
971
|
-
971 return value.map((entry) => adapterUtils.translateFilterValue(scopeName, field, entry))
|
|
972
|
-
972 }
|
|
973
|
-
973 return adapterUtils.translateFilterValue(scopeName, field, value)
|
|
974
|
-
974 }
|
|
975
|
-
975
|
|
976
|
-
976 // Main WHERE group
|
|
977
|
-
977 query.where(function () {
|
|
978
|
-
978 for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
979
|
-
979 const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
|
|
980
|
-
980 if (!fieldDef) {
|
|
981
|
-
981 log.trace(`[DEBUG basicFiltersHook] No field definition for filter key: ${filterKey}`)
|
|
982
|
-
982 continue
|
|
983
|
-
983 }
|
|
984
|
-
984 log.trace(`[DEBUG basicFiltersHook] Processing filter: ${filterKey} = ${filterValue}, fieldDef:`, fieldDef)
|
|
985
|
-
985
|
|
986
|
-
986 // Skip if this is a cross-table filter
|
|
987
|
-
987 if (fieldDef.actualField?.includes('.') ||
|
|
988
|
-
988 fieldDef.oneOf?.some(f => f.includes('.')) ||
|
|
989
|
-
989 fieldDef.polymorphicField) {
|
|
990
|
-
990 log.trace(`[DEBUG basicFiltersHook] Skipping filter ${filterKey} - is cross-table or polymorphic`)
|
|
991
|
-
991 continue
|
|
992
|
-
992 }
|
|
993
|
-
993
|
|
994
|
-
994 // Process basic filters
|
|
995
|
-
995 switch (true) {
|
|
996
|
-
996 case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
|
|
997
|
-
997 // Multi-field OR search
|
|
998
|
-
998 const operator = resolveSearchOperator(fieldDef)
|
|
999
|
-
999
|
|
1000
|
-
1000 // Handle split search terms
|
|
1001
|
-
1001 let searchTerms = [filterValue]
|
|
1002
|
-
1002 if (fieldDef.splitBy && typeof filterValue === 'string') {
|
|
1003
|
-
1003 searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim())
|
|
1004
|
-
1004 }
|
|
1005
|
-
1005
|
|
1006
|
-
1006 this.where(function () {
|
|
1007
|
-
1007 if (fieldDef.matchAll && searchTerms.length > 1) {
|
|
1008
|
-
1008 searchTerms.forEach(term => {
|
|
1009
|
-
1009 const normalizedTerm = normalizeValue(fieldDef.oneOf[0], term)
|
|
1010
|
-
1010 this.andWhere(function () {
|
|
1011
|
-
1011 fieldDef.oneOf.forEach((field, index) => {
|
|
1012
|
-
1012 const columnRef = qualifyField(field)
|
|
1013
|
-
1013 const perFieldValue = Array.isArray(normalizedTerm)
|
|
1014
|
-
1014 ? normalizeValue(field, term)
|
|
1015
|
-
1015 : normalizeValue(field, term)
|
|
1016
|
-
1016 applyWhereForOperator({
|
|
1017
|
-
1017 builder: this,
|
|
1018
|
-
1018 columnRef,
|
|
1019
|
-
1019 operator,
|
|
1020
|
-
1020 value: perFieldValue,
|
|
1021
|
-
1021 knex,
|
|
1022
|
-
1022 or: index !== 0,
|
|
1023
|
-
1023 })
|
|
1024
|
-
1024 })
|
|
1025
|
-
1025 })
|
|
1026
|
-
1026 })
|
|
1027
|
-
1027 } else {
|
|
1028
|
-
1028 fieldDef.oneOf.forEach((field, index) => {
|
|
1029
|
-
1029 const columnRef = qualifyField(field)
|
|
1030
|
-
1030 const normalizedValue = normalizeValue(field, filterValue)
|
|
1031
|
-
1031 applyWhereForOperator({
|
|
1032
|
-
1032 builder: this,
|
|
1033
|
-
1033 columnRef,
|
|
1034
|
-
1034 operator,
|
|
1035
|
-
1035 value: normalizedValue,
|
|
1036
|
-
1036 knex,
|
|
1037
|
-
1037 or: index !== 0,
|
|
1038
|
-
1038 })
|
|
1039
|
-
1039 })
|
|
1040
|
-
1040 }
|
|
1041
|
-
1041 })
|
|
1042
|
-
1042 break
|
|
1043
|
-
1043 }
|
|
1044
|
-
1044
|
|
1045
|
-
1045 case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
|
|
1046
|
-
1046 // Custom filter
|
|
1047
|
-
1047 fieldDef.applyFilter.call(this, this, filterValue)
|
|
1048
|
-
1048 break
|
|
1049
|
-
1049
|
|
1050
|
-
1050 default:
|
|
1051
|
-
1051 // Standard filtering
|
|
1052
|
-
1052 const actualField = fieldDef.actualField || filterKey
|
|
1053
|
-
1053 // Always qualify field names
|
|
1054
|
-
1054 const dbField = qualifyField(actualField)
|
|
1055
|
-
1055
|
|
1056
|
-
1056 const operator = resolveSearchOperator(fieldDef)
|
|
1057
|
-
1057 const normalized = normalizeValue(actualField, filterValue)
|
|
1058
|
-
1058 log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator} ${filterValue}`)
|
|
1059
|
-
1059 applyWhereForOperator({ builder: this, columnRef: dbField, operator, value: normalized, knex })
|
|
1060
|
-
1060 break
|
|
1061
|
-
1061 }
|
|
1062
|
-
1062 }
|
|
1063
|
-
1063 })
|
|
1064
|
-
1064 }
|