json-rest-api 1.0.22 → 1.0.23
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/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
- package/docs/API.md +51 -14
- package/docs/COMPARISON.md +2 -2
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
- 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 +250 -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/package.json +2 -2
- package/plugins/core/connectors/express-plugin.js +97 -184
- package/plugins/core/connectors/fastify-plugin.js +206 -0
- 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/dbIntrospection.js +1078 -0
- package/plugins/core/lib/dbTablesOperations.js +1104 -160
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
- package/plugins/core/lib/querying/knex-process-includes.js +3 -4
- package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
- package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
- 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 +12 -1
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
- 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/storage/canonical-storage-mapping.js +113 -0
- package/plugins/core/lib/storage/storage-adapter.js +79 -2
- package/plugins/core/lib/storage/storage-mapping.js +204 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
- package/plugins/core/rest-api-autofilter-plugin.js +414 -0
- package/plugins/core/rest-api-knex-plugin.js +249 -211
- 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-methods/apply-query-filters.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +121 -83
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get.js +23 -14
- package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
- package/plugins/core/rest-api-plugin-methods/post.js +13 -8
- package/plugins/core/rest-api-plugin-methods/put.js +45 -21
- package/plugins/core/rest-api-plugin-methods/query.js +15 -53
- package/plugins/core/rest-api-plugin.js +2 -0
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/plugins/core/rest-api-query-projections-plugin.js +130 -0
- package/plugins/core/socketio-plugin.js +1 -3
- package/tests/autofilter.test.js +463 -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 +518 -0
- package/tests/db-table-operations.test.js +781 -0
- package/tests/fastify-plugin.test.js +141 -0
- package/tests/field-getters.test.js +1 -2
- package/tests/field-setters.test.js +0 -4
- package/tests/fixtures/api-configs.js +188 -219
- package/tests/helpers/fake-fastify.js +99 -0
- package/tests/http-connectors-parity.test.js +235 -0
- 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 +210 -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,1983 +0,0 @@
|
|
|
1
|
-
1 /**
|
|
2
|
-
2 * Provides utilities for loading related resources according to JSON:API specification
|
|
3
|
-
3 *
|
|
4
|
-
4 * @description
|
|
5
|
-
5 * This module implements the JSON:API include functionality, enabling compound documents
|
|
6
|
-
6 * that contain both primary data and related resources. It handles all relationship types
|
|
7
|
-
7 * and supports arbitrary nesting depths while preventing N+1 query problems.
|
|
8
|
-
8 *
|
|
9
|
-
9 * @example
|
|
10
|
-
10 * // Input: Simple include request
|
|
11
|
-
11 * // GET /articles?include=author
|
|
12
|
-
12 * // Records before:
|
|
13
|
-
13 * [
|
|
14
|
-
14 * { id: 1, title: 'Article 1', author_id: 10 },
|
|
15
|
-
15 * { id: 2, title: 'Article 2', author_id: 11 }
|
|
16
|
-
16 * ]
|
|
17
|
-
17 *
|
|
18
|
-
18 * // Result: 2 queries total (articles + authors)
|
|
19
|
-
19 * // Records after (with RELATIONSHIPS_KEY):
|
|
20
|
-
20 * [
|
|
21
|
-
21 * {
|
|
22
|
-
22 * id: 1,
|
|
23
|
-
23 * title: 'Article 1',
|
|
24
|
-
24 * author_id: 10,
|
|
25
|
-
25 * __$jsonrestapi_rel$__: {
|
|
26
|
-
26 * author: { data: { type: 'users', id: '10' } }
|
|
27
|
-
27 * }
|
|
28
|
-
28 * }
|
|
29
|
-
29 * ]
|
|
30
|
-
30 * // Included resources:
|
|
31
|
-
31 * [
|
|
32
|
-
32 * { type: 'users', id: '10', attributes: { name: 'John' } },
|
|
33
|
-
33 * { type: 'users', id: '11', attributes: { name: 'Jane' } }
|
|
34
|
-
34 * ]
|
|
35
|
-
35 *
|
|
36
|
-
36 * @example
|
|
37
|
-
37 * // Input: Nested includes
|
|
38
|
-
38 * // GET /articles?include=comments.author
|
|
39
|
-
39 * // Articles → Comments → Authors (3 queries total)
|
|
40
|
-
40 *
|
|
41
|
-
41 * // Result: Comments loaded with their authors
|
|
42
|
-
42 * // Included array contains both comments AND their authors
|
|
43
|
-
43 * // No duplicate authors even if multiple comments by same person
|
|
44
|
-
44 *
|
|
45
|
-
45 * @example
|
|
46
|
-
46 * // Input: Polymorphic includes
|
|
47
|
-
47 * // GET /activities?include=trackable
|
|
48
|
-
48 * // Where trackable can be 'posts', 'photos', or 'videos'
|
|
49
|
-
49 *
|
|
50
|
-
50 * // Groups by type and loads each type separately:
|
|
51
|
-
51 * // Query 1: SELECT * FROM posts WHERE id IN (1, 3, 5)
|
|
52
|
-
52 * // Query 2: SELECT * FROM photos WHERE id IN (2, 4)
|
|
53
|
-
53 * // Query 3: SELECT * FROM videos WHERE id IN (6)
|
|
54
|
-
54 *
|
|
55
|
-
55 * Used by:
|
|
56
|
-
56 * - rest-api-knex-plugin calls buildIncludedResources in dataGet and dataQuery
|
|
57
|
-
57 * - Called after primary records are fetched but before response assembly
|
|
58
|
-
58 * - Results go into the 'included' section of JSON:API response
|
|
59
|
-
59 *
|
|
60
|
-
60 * Purpose:
|
|
61
|
-
61 * - Implements JSON:API compound documents with primary data and included resources
|
|
62
|
-
62 * - Prevents N+1 queries through batch loading (1 query per resource type)
|
|
63
|
-
63 * - Supports deep nesting like article.author.company.country.continent
|
|
64
|
-
64 * - Deduplicates resources automatically (each resource appears once)
|
|
65
|
-
65 * - Handles circular references through processedPaths tracking
|
|
66
|
-
66 * - Applies sparse fieldsets to reduce payload size
|
|
67
|
-
67 *
|
|
68
|
-
68 * Data flow:
|
|
69
|
-
69 * 1. Parse include parameter into tree structure (author,comments.author)
|
|
70
|
-
70 * 2. For each relationship, determine type (belongsTo, hasMany, polymorphic)
|
|
71
|
-
71 * 3. Batch load all related records of same type in single query
|
|
72
|
-
72 * 4. Recursively process nested includes
|
|
73
|
-
73 * 5. Store all included resources in deduplication Map
|
|
74
|
-
74 * 6. Return array of unique included resources
|
|
75
|
-
75 */
|
|
76
|
-
76
|
|
77
|
-
77 import { buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
78
|
-
78 import { getForeignKeyFields } from '../querying-writing/field-utils.js'
|
|
79
|
-
79 import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
|
|
80
|
-
80 import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js'
|
|
81
|
-
81 import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
|
|
82
|
-
82 import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
83
|
-
83
|
|
84
|
-
84 const translateSelectFieldsForAdapter = (fields, adapter) => {
|
|
85
|
-
85 if (!adapter || !fields) return fields
|
|
86
|
-
86
|
|
87
|
-
87 const translateField = (field) => {
|
|
88
|
-
88 if (typeof field !== 'string') return field
|
|
89
|
-
89
|
|
90
|
-
90 if (field === '*') return '*'
|
|
91
|
-
91
|
|
92
|
-
92 const aliasMatch = field.match(/\s+as\s+/i)
|
|
93
|
-
93 if (aliasMatch) {
|
|
94
|
-
94 const [source, alias] = field.split(/\s+as\s+/i)
|
|
95
|
-
95 const translatedSource = adapter.translateColumn(source.trim()) || source.trim()
|
|
96
|
-
96 return `${translatedSource} as ${alias.trim()}`
|
|
97
|
-
97 }
|
|
98
|
-
98
|
|
99
|
-
99 const translated = adapter.translateColumn(field)
|
|
100
|
-
100 return translated || field
|
|
101
|
-
101 }
|
|
102
|
-
102
|
|
103
|
-
103 return fields.map(translateField)
|
|
104
|
-
104 }
|
|
105
|
-
105
|
|
106
|
-
106 /**
|
|
107
|
-
107 * Groups records by their polymorphic type for efficient batch loading
|
|
108
|
-
108 *
|
|
109
|
-
109 * @param {Array<Object>} records - Records containing polymorphic fields
|
|
110
|
-
110 * @param {string} typeField - Name of the type field (e.g., 'commentable_type')
|
|
111
|
-
111 * @param {string} idField - Name of the ID field (e.g., 'commentable_id')
|
|
112
|
-
112 * @returns {Object<string, Array<number|string>>} Map of type to array of unique IDs
|
|
113
|
-
113 *
|
|
114
|
-
114 * @example
|
|
115
|
-
115 * // Input: Comments with polymorphic commentable relationship
|
|
116
|
-
116 * const records = [
|
|
117
|
-
117 * { id: 1, commentable_type: 'articles', commentable_id: 10 },
|
|
118
|
-
118 * { id: 2, commentable_type: 'videos', commentable_id: 20 },
|
|
119
|
-
119 * { id: 3, commentable_type: 'articles', commentable_id: 11 },
|
|
120
|
-
120 * { id: 4, commentable_type: 'articles', commentable_id: 10 } // Duplicate
|
|
121
|
-
121 * ];
|
|
122
|
-
122 *
|
|
123
|
-
123 * const grouped = groupByPolymorphicType(records, 'commentable_type', 'commentable_id');
|
|
124
|
-
124 *
|
|
125
|
-
125 * // Output: IDs grouped by type, duplicates removed
|
|
126
|
-
126 * // {
|
|
127
|
-
127 * // articles: [10, 11], // Only unique IDs
|
|
128
|
-
128 * // videos: [20]
|
|
129
|
-
129 * // }
|
|
130
|
-
130 *
|
|
131
|
-
131 * @example
|
|
132
|
-
132 * // Input: Some records have null relationships
|
|
133
|
-
133 * const records = [
|
|
134
|
-
134 * { id: 1, item_type: 'products', item_id: 1 },
|
|
135
|
-
135 * { id: 2, item_type: null, item_id: null }, // Ignored
|
|
136
|
-
136 * { id: 3, item_type: 'products', item_id: null }, // Ignored
|
|
137
|
-
137 * { id: 4, item_type: 'categories', item_id: 5 }
|
|
138
|
-
138 * ];
|
|
139
|
-
139 *
|
|
140
|
-
140 * const grouped = groupByPolymorphicType(records, 'item_type', 'item_id');
|
|
141
|
-
141 *
|
|
142
|
-
142 * // Output: Only records with both type AND id
|
|
143
|
-
143 * // {
|
|
144
|
-
144 * // products: [1],
|
|
145
|
-
145 * // categories: [5]
|
|
146
|
-
146 * // }
|
|
147
|
-
147 *
|
|
148
|
-
148 * @description
|
|
149
|
-
149 * Used by:
|
|
150
|
-
150 * - loadPolymorphicBelongsTo uses this to group records before batch loading
|
|
151
|
-
151 * - Enables efficient loading with one query per type instead of per record
|
|
152
|
-
152 *
|
|
153
|
-
153 * Purpose:
|
|
154
|
-
154 * - Minimizes database queries when loading polymorphic relationships
|
|
155
|
-
155 * - Instead of N queries (one per record), makes T queries (one per type)
|
|
156
|
-
156 * - Automatically deduplicates IDs within each type
|
|
157
|
-
157 * - Safely handles null/undefined values by filtering them out
|
|
158
|
-
158 *
|
|
159
|
-
159 * Data flow:
|
|
160
|
-
160 * 1. Receives array of records with polymorphic fields
|
|
161
|
-
161 * 2. Groups records by their type field value
|
|
162
|
-
162 * 3. Collects unique IDs for each type
|
|
163
|
-
163 * 4. Returns map used for batch WHERE IN queries
|
|
164
|
-
164 */
|
|
165
|
-
165 export const groupByPolymorphicType = (records, typeField, idField) => {
|
|
166
|
-
166 const grouped = {}
|
|
167
|
-
167
|
|
168
|
-
168 records.forEach(record => {
|
|
169
|
-
169 const type = record[typeField]
|
|
170
|
-
170 const id = record[idField]
|
|
171
|
-
171
|
|
172
|
-
172 // Skip if either type or id is missing
|
|
173
|
-
173 if (!type || !id) return
|
|
174
|
-
174
|
|
175
|
-
175 if (!grouped[type]) {
|
|
176
|
-
176 grouped[type] = []
|
|
177
|
-
177 }
|
|
178
|
-
178
|
|
179
|
-
179 // Only add unique IDs
|
|
180
|
-
180 if (!grouped[type].includes(id)) {
|
|
181
|
-
181 grouped[type].push(id)
|
|
182
|
-
182 }
|
|
183
|
-
183 })
|
|
184
|
-
184
|
|
185
|
-
185 return grouped
|
|
186
|
-
186 }
|
|
187
|
-
187
|
|
188
|
-
188 /**
|
|
189
|
-
189 * Parses the include parameter string into a tree structure
|
|
190
|
-
190 *
|
|
191
|
-
191 * @param {string|Array<string>} includeParam - The include parameter value
|
|
192
|
-
192 * @returns {Object} Nested object representing the include tree
|
|
193
|
-
193 *
|
|
194
|
-
194 * @example
|
|
195
|
-
195 * // Input: Simple comma-separated includes
|
|
196
|
-
196 * const tree = parseIncludeTree('author,comments');
|
|
197
|
-
197 *
|
|
198
|
-
198 * // Output: Flat structure
|
|
199
|
-
199 * // {
|
|
200
|
-
200 * // author: {},
|
|
201
|
-
201 * // comments: {}
|
|
202
|
-
202 * // }
|
|
203
|
-
203 *
|
|
204
|
-
204 * @example
|
|
205
|
-
205 * // Input: Nested includes with dot notation
|
|
206
|
-
206 * const tree = parseIncludeTree('comments.author,comments.replies');
|
|
207
|
-
207 *
|
|
208
|
-
208 * // Output: Nested structure
|
|
209
|
-
209 * // {
|
|
210
|
-
210 * // comments: {
|
|
211
|
-
211 * // author: {},
|
|
212
|
-
212 * // replies: {}
|
|
213
|
-
213 * // }
|
|
214
|
-
214 * // }
|
|
215
|
-
215 *
|
|
216
|
-
216 * @example
|
|
217
|
-
217 * // Input: Complex mixed-depth includes
|
|
218
|
-
218 * const tree = parseIncludeTree('author,comments.author,comments.replies.author,tags');
|
|
219
|
-
219 *
|
|
220
|
-
220 * // Output: Multi-level nesting
|
|
221
|
-
221 * // {
|
|
222
|
-
222 * // author: {},
|
|
223
|
-
223 * // comments: {
|
|
224
|
-
224 * // author: {},
|
|
225
|
-
225 * // replies: {
|
|
226
|
-
226 * // author: {}
|
|
227
|
-
227 * // }
|
|
228
|
-
228 * // },
|
|
229
|
-
229 * // tags: {}
|
|
230
|
-
230 * // }
|
|
231
|
-
231 *
|
|
232
|
-
232 * @description
|
|
233
|
-
233 * Used by:
|
|
234
|
-
234 * - buildIncludedResources parses the include parameter before processing
|
|
235
|
-
235 * - processIncludes traverses this tree structure recursively
|
|
236
|
-
236 *
|
|
237
|
-
237 * Purpose:
|
|
238
|
-
238 * - Converts flat string format into hierarchical structure for processing
|
|
239
|
-
239 * - Enables recursive traversal of relationship includes
|
|
240
|
-
240 * - Handles both string and pre-split array inputs
|
|
241
|
-
241 * - Empty values are filtered out automatically
|
|
242
|
-
242 *
|
|
243
|
-
243 * Data flow:
|
|
244
|
-
244 * 1. Split comma-separated string into individual paths
|
|
245
|
-
245 * 2. For each path, split by dots to get nesting levels
|
|
246
|
-
246 * 3. Build nested object structure following the path
|
|
247
|
-
247 * 4. Return tree for recursive processing
|
|
248
|
-
248 */
|
|
249
|
-
249 export const parseIncludeTree = (includeParam) => {
|
|
250
|
-
250 if (!includeParam) return {}
|
|
251
|
-
251
|
|
252
|
-
252 // Handle array input (already split)
|
|
253
|
-
253 const includes = Array.isArray(includeParam)
|
|
254
|
-
254 ? includeParam
|
|
255
|
-
255 : includeParam.split(',').map(s => s.trim()).filter(Boolean)
|
|
256
|
-
256
|
|
257
|
-
257 const tree = {}
|
|
258
|
-
258
|
|
259
|
-
259 includes.forEach(include => {
|
|
260
|
-
260 const parts = include.split('.')
|
|
261
|
-
261 let current = tree
|
|
262
|
-
262
|
|
263
|
-
263 parts.forEach(part => {
|
|
264
|
-
264 if (!current[part]) {
|
|
265
|
-
265 current[part] = {}
|
|
266
|
-
266 }
|
|
267
|
-
267 current = current[part]
|
|
268
|
-
268 })
|
|
269
|
-
269 })
|
|
270
|
-
270
|
|
271
|
-
271 return tree
|
|
272
|
-
272 }
|
|
273
|
-
273
|
|
274
|
-
274 /**
|
|
275
|
-
275 * Loads relationship metadata for included resources
|
|
276
|
-
276 * This ensures included resources have complete JSON:API representation
|
|
277
|
-
277 *
|
|
278
|
-
278 * @param {Object} scopes - The hooked-api scopes object
|
|
279
|
-
279 * @param {Array<Object>} records - Records to add relationships to
|
|
280
|
-
280 * @param {string} scopeName - The scope/resource type name
|
|
281
|
-
281 */
|
|
282
|
-
282 const loadRelationshipMetadata = async (scopes, records, scopeName) => {
|
|
283
|
-
283 try {
|
|
284
|
-
284 // Get schema information
|
|
285
|
-
285 const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
286
|
-
286 if (!schemaInfo) return
|
|
287
|
-
287
|
|
288
|
-
288 const schema = schemaInfo.schemaInstance.structure || schemaInfo.schemaInstance
|
|
289
|
-
289 const relationships = schemaInfo.schemaRelationships || {}
|
|
290
|
-
290
|
|
291
|
-
291 // Process each record
|
|
292
|
-
292 records.forEach(record => {
|
|
293
|
-
293 record[RELATIONSHIP_METADATA_KEY] = {}
|
|
294
|
-
294
|
|
295
|
-
295 // Process belongsTo relationships from schema
|
|
296
|
-
296 for (const [fieldName, fieldDef] of Object.entries(schema)) {
|
|
297
|
-
297 if (fieldDef.belongsTo && fieldDef.as) {
|
|
298
|
-
298 const foreignKeyValue = record[fieldName]
|
|
299
|
-
299 if (foreignKeyValue != null) {
|
|
300
|
-
300 record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = {
|
|
301
|
-
301 data: {
|
|
302
|
-
302 type: fieldDef.belongsTo,
|
|
303
|
-
303 id: String(foreignKeyValue)
|
|
304
|
-
304 }
|
|
305
|
-
305 }
|
|
306
|
-
306 } else {
|
|
307
|
-
307 record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = { data: null }
|
|
308
|
-
308 }
|
|
309
|
-
309 }
|
|
310
|
-
310 }
|
|
311
|
-
311
|
|
312
|
-
312 // Process hasMany, hasOne and manyToMany relationships
|
|
313
|
-
313 for (const [relName, relDef] of Object.entries(relationships)) {
|
|
314
|
-
314 if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
315
|
-
315 // Add empty relationship data - will be populated if explicitly included
|
|
316
|
-
316 record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] }
|
|
317
|
-
317 } else if (relDef.type === 'hasOne') {
|
|
318
|
-
318 // hasOne expects a single object or null
|
|
319
|
-
319 record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
|
|
320
|
-
320 } else if (relDef.belongsToPolymorphic) {
|
|
321
|
-
321 // Process polymorphic relationships defined in relationships section
|
|
322
|
-
322 const { typeField, idField } = relDef.belongsToPolymorphic
|
|
323
|
-
323 const type = record[typeField]
|
|
324
|
-
324 const id = record[idField]
|
|
325
|
-
325
|
|
326
|
-
326 if (type && id) {
|
|
327
|
-
327 record[RELATIONSHIP_METADATA_KEY][relName] = {
|
|
328
|
-
328 data: { type, id: String(id) }
|
|
329
|
-
329 }
|
|
330
|
-
330 } else {
|
|
331
|
-
331 record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
|
|
332
|
-
332 }
|
|
333
|
-
333 }
|
|
334
|
-
334 }
|
|
335
|
-
335 })
|
|
336
|
-
336 } catch (error) {
|
|
337
|
-
337 // Log error with context and re-throw
|
|
338
|
-
338 const errorContext = {
|
|
339
|
-
339 scopeName,
|
|
340
|
-
340 recordCount: records?.length || 0,
|
|
341
|
-
341 error: error.message
|
|
342
|
-
342 }
|
|
343
|
-
343 console.error('[loadRelationshipMetadata] Error loading relationship metadata:', errorContext)
|
|
344
|
-
344 throw new Error(`Failed to load relationship metadata for scope '${scopeName}': ${error.message}`)
|
|
345
|
-
345 }
|
|
346
|
-
346 }
|
|
347
|
-
347
|
|
348
|
-
348 /**
|
|
349
|
-
349 * Loads belongsTo relationships (many-to-one)
|
|
350
|
-
350 *
|
|
351
|
-
351 * This function loads the "parent" side of a relationship. For example, if comments
|
|
352
|
-
352 * belong to articles, this loads the articles for a set of comments.
|
|
353
|
-
353 *
|
|
354
|
-
354 * @param {Object} scope - The hooked-api scope object containing:
|
|
355
|
-
355 * - records: Array<Object> - Records to load relationships for
|
|
356
|
-
356 * - fieldName: string - The foreign key field name (e.g., 'author_id')
|
|
357
|
-
357 * - fieldDef: Object - The field definition from the schema
|
|
358
|
-
358 * - includeName: string - The relationship name (e.g., 'author')
|
|
359
|
-
359 * - subIncludes: Object - Nested includes to process recursively
|
|
360
|
-
360 * - included: Map - Map of already included resources
|
|
361
|
-
361 * - processedPaths: Set - Set of already processed paths
|
|
362
|
-
362 * - currentPath: string - Current include path for tracking
|
|
363
|
-
363 * - fields: Object - Sparse fieldsets configuration
|
|
364
|
-
364 * - idProperty: string - The ID property name
|
|
365
|
-
365 * @param {Object} deps - Dependencies object containing:
|
|
366
|
-
366 * - context.scopes: Object - The hooked-api scopes object
|
|
367
|
-
367 * - context.log: Object - Logger instance
|
|
368
|
-
368 * - context.knex: Object - Knex instance
|
|
369
|
-
369 * @returns {Promise<void>}
|
|
370
|
-
370 */
|
|
371
|
-
371 export const loadBelongsTo = async (scope, deps) => {
|
|
372
|
-
372 const { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty } = scope
|
|
373
|
-
373 const { scopes, log, knex, capabilities } = deps.context
|
|
374
|
-
374 try {
|
|
375
|
-
375 log.trace('[INCLUDE] Loading belongsTo:', {
|
|
376
|
-
376 fieldName,
|
|
377
|
-
377 includeName,
|
|
378
|
-
378 recordCount: records.length
|
|
379
|
-
379 })
|
|
380
|
-
380
|
|
381
|
-
381 // Get the target scope name
|
|
382
|
-
382 const targetScope = fieldDef.belongsTo
|
|
383
|
-
383 if (!scopes[targetScope]) {
|
|
384
|
-
384 log.warn('[INCLUDE] Target scope not found:', targetScope)
|
|
385
|
-
385 return
|
|
386
|
-
386 }
|
|
387
|
-
387
|
|
388
|
-
388 // Collect all foreign key values
|
|
389
|
-
389 const foreignKeyValues = records
|
|
390
|
-
390 .map(r => r[fieldName])
|
|
391
|
-
391 .filter(val => val != null) // Filter out null/undefined
|
|
392
|
-
392
|
|
393
|
-
393 const uniqueIds = [...new Set(foreignKeyValues)]
|
|
394
|
-
394
|
|
395
|
-
395 log.debug('[INCLUDE] Loading belongsTo records:', {
|
|
396
|
-
396 targetScope,
|
|
397
|
-
397 uniqueIds
|
|
398
|
-
398 })
|
|
399
|
-
399
|
|
400
|
-
400 if (uniqueIds.length === 0) {
|
|
401
|
-
401 // No relationships to load, set all to null
|
|
402
|
-
402 records.forEach(record => {
|
|
403
|
-
403 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
404
|
-
404 const relationshipObject = { data: null }
|
|
405
|
-
405
|
|
406
|
-
406 // Add links if urlPrefix is configured
|
|
407
|
-
407 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
408
|
-
408 if (scopeName) {
|
|
409
|
-
409 relationshipObject.links = {
|
|
410
|
-
410 self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
411
|
-
411 related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
412
|
-
412 }
|
|
413
|
-
413 }
|
|
414
|
-
414
|
|
415
|
-
415 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
416
|
-
416 })
|
|
417
|
-
417 return
|
|
418
|
-
418 }
|
|
419
|
-
419
|
|
420
|
-
420 // Get target table info
|
|
421
|
-
421 const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
|
|
422
|
-
422 const targetIdProperty = scopes[targetScope].vars.schemaInfo.idProperty
|
|
423
|
-
423 const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
424
|
-
424
|
|
425
|
-
425 // Build field selection for sparse fieldsets
|
|
426
|
-
426 const targetScopeObject = scopes[targetScope]
|
|
427
|
-
427 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
428
|
-
428 const fieldSelectionInfo = fields?.[targetScope]
|
|
429
|
-
429 ? await buildFieldSelection(targetScopeObject, {
|
|
430
|
-
430 context: {
|
|
431
|
-
431 scopeName: targetScope,
|
|
432
|
-
432 queryParams: { fields: { [targetScope]: fields[targetScope] } },
|
|
433
|
-
433 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
434
|
-
434 storageAdapter,
|
|
435
|
-
435 }
|
|
436
|
-
436 })
|
|
437
|
-
437 : null
|
|
438
|
-
438
|
|
439
|
-
439 // Load the target records
|
|
440
|
-
440 let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds)
|
|
441
|
-
441 if (fieldSelectionInfo) {
|
|
442
|
-
442 const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
443
|
-
443 query = query.select(selectFields)
|
|
444
|
-
444 } else if (targetIdProperty !== 'id') {
|
|
445
|
-
445 // If no field selection but custom idProperty, we need to alias it
|
|
446
|
-
446 query = query.select('*', `${targetIdProperty} as id`)
|
|
447
|
-
447 }
|
|
448
|
-
448 const targetRecords = await query
|
|
449
|
-
449
|
|
450
|
-
450 // Load relationship metadata for all target records
|
|
451
|
-
451 await loadRelationshipMetadata(scopes, targetRecords, targetScope)
|
|
452
|
-
452
|
|
453
|
-
453 // Create lookup map
|
|
454
|
-
454 const targetById = {}
|
|
455
|
-
455 targetRecords.forEach(record => {
|
|
456
|
-
456 targetById[record.id || record[targetIdProperty]] = record
|
|
457
|
-
457 })
|
|
458
|
-
458
|
|
459
|
-
459 // Set relationships on original records
|
|
460
|
-
460 const targetRecordsToProcess = []
|
|
461
|
-
461
|
|
462
|
-
462 records.forEach(record => {
|
|
463
|
-
463 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
464
|
-
464
|
|
465
|
-
465 const targetId = record[fieldName]
|
|
466
|
-
466 const targetRecord = targetById[targetId]
|
|
467
|
-
467
|
|
468
|
-
468 if (targetRecord) {
|
|
469
|
-
469 // Convert to JSON:API format
|
|
470
|
-
470 const jsonApiRecord = toJsonApiRecord(
|
|
471
|
-
471 scopes[targetScope],
|
|
472
|
-
472 targetRecord,
|
|
473
|
-
473 targetScope
|
|
474
|
-
474 )
|
|
475
|
-
475
|
|
476
|
-
476 // Add relationships from metadata
|
|
477
|
-
477 if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
|
|
478
|
-
478 jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
|
|
479
|
-
479 // Clean up the temporary property
|
|
480
|
-
480 delete targetRecord[RELATIONSHIP_METADATA_KEY]
|
|
481
|
-
481 }
|
|
482
|
-
482
|
|
483
|
-
483 // Attach computed dependencies info if sparse fieldsets were used
|
|
484
|
-
484 if (fieldSelectionInfo?.computedDependencies) {
|
|
485
|
-
485 jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
|
|
486
|
-
486 }
|
|
487
|
-
487
|
|
488
|
-
488 const relationshipObject = { data: { type: targetScope, id: String(targetId) } }
|
|
489
|
-
489
|
|
490
|
-
490 // Add links if urlPrefix is configured
|
|
491
|
-
491 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
492
|
-
492 if (scopeName) {
|
|
493
|
-
493 relationshipObject.links = {
|
|
494
|
-
494 self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
495
|
-
495 related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
496
|
-
496 }
|
|
497
|
-
497 }
|
|
498
|
-
498
|
|
499
|
-
499 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
500
|
-
500
|
|
501
|
-
501 // Add to included if not already there
|
|
502
|
-
502 const resourceKey = `${targetScope}:${targetId}`
|
|
503
|
-
503 if (!included.has(resourceKey)) {
|
|
504
|
-
504 included.set(resourceKey, jsonApiRecord) // Now includes relationships!
|
|
505
|
-
505
|
|
506
|
-
506 // Collect for nested processing
|
|
507
|
-
507 if (Object.keys(subIncludes).length > 0) {
|
|
508
|
-
508 targetRecordsToProcess.push(targetRecord)
|
|
509
|
-
509 }
|
|
510
|
-
510 }
|
|
511
|
-
511 } else {
|
|
512
|
-
512 const relationshipObject = { data: null }
|
|
513
|
-
513
|
|
514
|
-
514 // Add links if urlPrefix is configured
|
|
515
|
-
515 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
516
|
-
516 if (scopeName) {
|
|
517
|
-
517 relationshipObject.links = {
|
|
518
|
-
518 self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
519
|
-
519 related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
520
|
-
520 }
|
|
521
|
-
521 }
|
|
522
|
-
522
|
|
523
|
-
523 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
524
|
-
524 }
|
|
525
|
-
525 })
|
|
526
|
-
526
|
|
527
|
-
527 // Process nested includes if any
|
|
528
|
-
528 if (targetRecordsToProcess.length > 0) {
|
|
529
|
-
529 const nextPath = `${currentPath}.${includeName}`
|
|
530
|
-
530 if (!processedPaths.has(nextPath)) {
|
|
531
|
-
531 await processIncludes(
|
|
532
|
-
532 { records: targetRecordsToProcess, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
|
|
533
|
-
533 { context: { scopes, log, knex, capabilities } }
|
|
534
|
-
534 )
|
|
535
|
-
535 }
|
|
536
|
-
536 }
|
|
537
|
-
537 } catch (error) {
|
|
538
|
-
538 // Log error with detailed context
|
|
539
|
-
539 log.error('[INCLUDE] Error loading belongsTo relationship:', {
|
|
540
|
-
540 fieldName,
|
|
541
|
-
541 includeName,
|
|
542
|
-
542 targetScope: fieldDef.belongsTo,
|
|
543
|
-
543 recordCount: records?.length || 0,
|
|
544
|
-
544 error: error.message,
|
|
545
|
-
545 stack: error.stack
|
|
546
|
-
546 })
|
|
547
|
-
547
|
|
548
|
-
548 // Re-throw with enhanced error message
|
|
549
|
-
549 const enhancedError = new Error(
|
|
550
|
-
550 `Failed to load belongsTo relationship '${includeName}' for field '${fieldName}': ${error.message}`
|
|
551
|
-
551 )
|
|
552
|
-
552 enhancedError.originalError = error
|
|
553
|
-
553 enhancedError.context = { fieldName, includeName, targetScope: fieldDef.belongsTo }
|
|
554
|
-
554 throw enhancedError
|
|
555
|
-
555 }
|
|
556
|
-
556 }
|
|
557
|
-
557
|
|
558
|
-
558 /**
|
|
559
|
-
559 * Loads hasMany relationships (one-to-many or many-to-many)
|
|
560
|
-
560 *
|
|
561
|
-
561 * This function loads the "child" side of a relationship. For example, if articles
|
|
562
|
-
562 * have many comments, this loads all comments for a set of articles. It also handles
|
|
563
|
-
563 * many-to-many relationships through a pivot table.
|
|
564
|
-
564 *
|
|
565
|
-
565 * @param {Object} scope - The hooked-api scope object containing:
|
|
566
|
-
566 * - records: Array<Object> - Parent records to load relationships for
|
|
567
|
-
567 * - scopeName: string - The parent scope name
|
|
568
|
-
568 * - includeName: string - The relationship name to include
|
|
569
|
-
569 * - relDef: Object - The relationship definition
|
|
570
|
-
570 * - subIncludes: Object - Nested includes to process recursively
|
|
571
|
-
571 * - included: Map - Map of already included resources
|
|
572
|
-
572 * - processedPaths: Set - Set of already processed paths
|
|
573
|
-
573 * - currentPath: string - Current include path for tracking
|
|
574
|
-
574 * - fields: Object - Sparse fieldsets configuration
|
|
575
|
-
575 * @param {Object} deps - Dependencies object containing:
|
|
576
|
-
576 * - context.scopes: Object - The hooked-api scopes object
|
|
577
|
-
577 * - context.log: Object - Logger instance
|
|
578
|
-
578 * - context.knex: Object - Knex instance
|
|
579
|
-
579 * @returns {Promise<void>}
|
|
580
|
-
580 */
|
|
581
|
-
581 export const loadHasMany = async (scope, deps) => {
|
|
582
|
-
582 const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
|
|
583
|
-
583 const { scopes, log, knex, capabilities } = deps.context
|
|
584
|
-
584 try {
|
|
585
|
-
585 log.trace('[INCLUDE] Loading hasMany/manyToMany relationship:', {
|
|
586
|
-
586 scopeName,
|
|
587
|
-
587 includeName,
|
|
588
|
-
588 recordCount: records.length,
|
|
589
|
-
589 relDef,
|
|
590
|
-
590 isManyToMany: relDef.type === 'manyToMany',
|
|
591
|
-
591 hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
|
|
592
|
-
592 })
|
|
593
|
-
593
|
|
594
|
-
594 // Collect all parent IDs
|
|
595
|
-
595 const mainIds = records.map(r => r.id).filter(Boolean)
|
|
596
|
-
596
|
|
597
|
-
597 if (mainIds.length === 0) {
|
|
598
|
-
598 log.trace('[INCLUDE] No parent IDs found, skipping hasMany load')
|
|
599
|
-
599 return
|
|
600
|
-
600 }
|
|
601
|
-
601
|
|
602
|
-
602 // Check if this is a many-to-many relationship
|
|
603
|
-
603 if (relDef.type === 'manyToMany') {
|
|
604
|
-
604 // Handle many-to-many relationship
|
|
605
|
-
605 const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName
|
|
606
|
-
606 const foreignKey = relDef.foreignKey
|
|
607
|
-
607 const otherKey = relDef.otherKey
|
|
608
|
-
608
|
|
609
|
-
609 if (!foreignKey || !otherKey) {
|
|
610
|
-
610 throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`)
|
|
611
|
-
611 }
|
|
612
|
-
612
|
|
613
|
-
613 // For manyToMany, the target scope is the relationship name itself
|
|
614
|
-
614 const targetScope = includeName
|
|
615
|
-
615 const targetTable = scopes[targetScope].vars.schemaInfo.tableName
|
|
616
|
-
616
|
|
617
|
-
617 log.debug(`[INCLUDE] Loading pivot records from ${pivotTable}:`, {
|
|
618
|
-
618 foreignKey,
|
|
619
|
-
619 whereIn: mainIds
|
|
620
|
-
620 })
|
|
621
|
-
621
|
|
622
|
-
622 // Step 1: Query the pivot table
|
|
623
|
-
623 const pivotRecords = await knex(pivotTable)
|
|
624
|
-
624 .whereIn(foreignKey, mainIds)
|
|
625
|
-
625 .orderBy(otherKey)
|
|
626
|
-
626
|
|
627
|
-
627 if (pivotRecords.length === 0) {
|
|
628
|
-
628 // No relationships found, set empty arrays for all records
|
|
629
|
-
629 records.forEach(record => {
|
|
630
|
-
630 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
631
|
-
631 const relationshipObject = { data: [] }
|
|
632
|
-
632
|
|
633
|
-
633 // Add links if urlPrefix is configured
|
|
634
|
-
634 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
635
|
-
635 if (scopeName) {
|
|
636
|
-
636 relationshipObject.links = {
|
|
637
|
-
637 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
638
|
-
638 related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
639
|
-
639 }
|
|
640
|
-
640 }
|
|
641
|
-
641
|
|
642
|
-
642 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
643
|
-
643 })
|
|
644
|
-
644 return
|
|
645
|
-
645 }
|
|
646
|
-
646
|
|
647
|
-
647 // Step 2: Extract target IDs from pivot records
|
|
648
|
-
648 const targetIds = [...new Set(pivotRecords.map(p => p[otherKey]).filter(Boolean))]
|
|
649
|
-
649
|
|
650
|
-
650 log.debug(`[INCLUDE] Loading ${targetScope} records:`, {
|
|
651
|
-
651 whereIn: targetIds,
|
|
652
|
-
652 includeConfig: relDef.include
|
|
653
|
-
653 })
|
|
654
|
-
654
|
|
655
|
-
655 // Step 3: Build field selection for sparse fieldsets
|
|
656
|
-
656 const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
657
|
-
657 const targetScopeObject = scopes[targetScope]
|
|
658
|
-
658 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
659
|
-
659 const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
|
|
660
|
-
660 const fieldSelectionInfo = fields?.[targetScope]
|
|
661
|
-
661 ? await buildFieldSelection(targetScopeObject, {
|
|
662
|
-
662 context: {
|
|
663
|
-
663 scopeName: targetScope,
|
|
664
|
-
664 queryParams: { fields: { [targetScope]: fields[targetScope] } },
|
|
665
|
-
665 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
666
|
-
666 storageAdapter,
|
|
667
|
-
667 }
|
|
668
|
-
668 })
|
|
669
|
-
669 : null
|
|
670
|
-
670
|
|
671
|
-
671 // Step 4: For many-to-many with limits, we need a different approach
|
|
672
|
-
672 let targetRecords
|
|
673
|
-
673
|
|
674
|
-
674 if (relDef.include?.strategy === 'window') {
|
|
675
|
-
675 // For many-to-many with window functions, we need to limit per parent
|
|
676
|
-
676 // This requires joining back through the pivot table
|
|
677
|
-
677
|
|
678
|
-
678 // Get target scope vars for defaults
|
|
679
|
-
679 const targetVars = scopes[targetScope].vars || {}
|
|
680
|
-
680
|
|
681
|
-
681 // Calculate effective limit with defaults
|
|
682
|
-
682 const effectiveLimit = relDef.include?.limit ?? targetVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT
|
|
683
|
-
683
|
|
684
|
-
684 // Validate against max
|
|
685
|
-
685 if (targetVars.queryMaxLimit && effectiveLimit > targetVars.queryMaxLimit) {
|
|
686
|
-
686 throw new RestApiResourceError({
|
|
687
|
-
687 title: 'Include Limit Exceeds Maximum',
|
|
688
|
-
688 detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${targetVars.queryMaxLimit})`,
|
|
689
|
-
689 status: 400
|
|
690
|
-
690 })
|
|
691
|
-
691 }
|
|
692
|
-
692
|
|
693
|
-
693 const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
|
|
694
|
-
694 const windowQuery = knex
|
|
695
|
-
695 .select(`${targetTable}.*`)
|
|
696
|
-
696
|
|
697
|
-
697 // Add id alias if needed
|
|
698
|
-
698 if (targetIdProperty !== 'id') {
|
|
699
|
-
699 windowQuery.select(`${targetTable}.${targetIdProperty} as id`)
|
|
700
|
-
700 }
|
|
701
|
-
701
|
|
702
|
-
702 windowQuery.select(
|
|
703
|
-
703 knex.raw(
|
|
704
|
-
704 'ROW_NUMBER() OVER (PARTITION BY pivot.?? ORDER BY ' +
|
|
705
|
-
705 buildOrderByClause(relDef.include?.orderBy || [targetIdProperty], targetTable) +
|
|
706
|
-
706 ') as ' + ROW_NUMBER_KEY,
|
|
707
|
-
707 [foreignKey]
|
|
708
|
-
708 )
|
|
709
|
-
709 )
|
|
710
|
-
710 .from(targetTable)
|
|
711
|
-
711 .join(`${pivotTable} as pivot`, `${targetTable}.${targetIdProperty}`, 'pivot.' + otherKey)
|
|
712
|
-
712 .whereIn(`pivot.${foreignKey}`, mainIds)
|
|
713
|
-
713
|
|
714
|
-
714 // Wrap to filter by row number
|
|
715
|
-
715 const limitedQuery = knex
|
|
716
|
-
716 .select('*')
|
|
717
|
-
717 .from(windowQuery.as('_windowed'))
|
|
718
|
-
718 .where(ROW_NUMBER_KEY, '<=', effectiveLimit)
|
|
719
|
-
719
|
|
720
|
-
720 targetRecords = await limitedQuery
|
|
721
|
-
721
|
|
722
|
-
722 // Remove row number column and restore proper pivot grouping
|
|
723
|
-
723 targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
|
|
724
|
-
724 } else {
|
|
725
|
-
725 // Standard query without per-parent limits
|
|
726
|
-
726 const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
|
|
727
|
-
727 let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
|
|
728
|
-
728
|
|
729
|
-
729 if (fieldSelectionInfo) {
|
|
730
|
-
730 const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
731
|
-
731 query = query.select(selectFields)
|
|
732
|
-
732 } else if (targetIdProperty !== 'id') {
|
|
733
|
-
733 // If no field selection but custom idProperty, we need to alias it
|
|
734
|
-
734 query = query.select('*', `${targetIdProperty} as id`)
|
|
735
|
-
735 }
|
|
736
|
-
736
|
|
737
|
-
737 // Apply standard include config (global limits)
|
|
738
|
-
738 if (relDef.include) {
|
|
739
|
-
739 const targetVars = scopes[targetScope].vars
|
|
740
|
-
740 query = applyStandardIncludeConfig(query, relDef.include, targetVars, log)
|
|
741
|
-
741 }
|
|
742
|
-
742
|
|
743
|
-
743 targetRecords = await query
|
|
744
|
-
744 }
|
|
745
|
-
745
|
|
746
|
-
746 log.trace('[INCLUDE] Loaded target records:', { count: targetRecords.length })
|
|
747
|
-
747
|
|
748
|
-
748 // Step 5: Load relationship metadata for all target records
|
|
749
|
-
749 await loadRelationshipMetadata(scopes, targetRecords, targetScope)
|
|
750
|
-
750
|
|
751
|
-
751 // Step 6: Create lookup map for target records
|
|
752
|
-
752 const targetById = {}
|
|
753
|
-
753 targetRecords.forEach(record => {
|
|
754
|
-
754 targetById[record.id] = record
|
|
755
|
-
755 })
|
|
756
|
-
756
|
|
757
|
-
757 // Step 7: Group pivot records by parent ID
|
|
758
|
-
758 const pivotsByParent = {}
|
|
759
|
-
759 pivotRecords.forEach(pivot => {
|
|
760
|
-
760 const parentId = pivot[foreignKey]
|
|
761
|
-
761 if (!pivotsByParent[parentId]) {
|
|
762
|
-
762 pivotsByParent[parentId] = []
|
|
763
|
-
763 }
|
|
764
|
-
764 pivotsByParent[parentId].push(pivot[otherKey])
|
|
765
|
-
765 })
|
|
766
|
-
766
|
|
767
|
-
767 // Step 7: Set relationships on parent records
|
|
768
|
-
768
|
|
769
|
-
769 records.forEach(record => {
|
|
770
|
-
770 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
771
|
-
771
|
|
772
|
-
772 const childIds = pivotsByParent[record.id] || []
|
|
773
|
-
773
|
|
774
|
-
774 const relData = childIds
|
|
775
|
-
775 .map(childId => targetById[childId])
|
|
776
|
-
776 .filter(Boolean)
|
|
777
|
-
777 .map(childRecord => {
|
|
778
|
-
778 // Add to included
|
|
779
|
-
779 const resourceKey = `${targetScope}:${childRecord.id}`
|
|
780
|
-
780 if (!included.has(resourceKey)) {
|
|
781
|
-
781 const jsonApiRecord = toJsonApiRecord(
|
|
782
|
-
782 scopes[targetScope],
|
|
783
|
-
783 childRecord,
|
|
784
|
-
784 targetScope
|
|
785
|
-
785 )
|
|
786
|
-
786
|
|
787
|
-
787 // Add relationships from metadata
|
|
788
|
-
788 if (childRecord[RELATIONSHIP_METADATA_KEY]) {
|
|
789
|
-
789 jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
|
|
790
|
-
790 // Clean up the temporary property
|
|
791
|
-
791 delete childRecord[RELATIONSHIP_METADATA_KEY]
|
|
792
|
-
792 }
|
|
793
|
-
793
|
|
794
|
-
794 // Attach computed dependencies info if sparse fieldsets were used
|
|
795
|
-
795 if (fieldSelectionInfo?.computedDependencies) {
|
|
796
|
-
796 jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
|
|
797
|
-
797 }
|
|
798
|
-
798
|
|
799
|
-
799 included.set(resourceKey, jsonApiRecord)
|
|
800
|
-
800 }
|
|
801
|
-
801 return { type: targetScope, id: String(childRecord.id) }
|
|
802
|
-
802 })
|
|
803
|
-
803
|
|
804
|
-
804 const relationshipObject = { data: relData }
|
|
805
|
-
805
|
|
806
|
-
806 // Add links if urlPrefix is configured
|
|
807
|
-
807 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
808
|
-
808 if (scopeName) {
|
|
809
|
-
809 relationshipObject.links = {
|
|
810
|
-
810 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
811
|
-
811 related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
812
|
-
812 }
|
|
813
|
-
813 }
|
|
814
|
-
814
|
|
815
|
-
815 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
816
|
-
816
|
|
817
|
-
817 // Update the record in the included Map if it exists
|
|
818
|
-
818 const recordKey = `${scopeName}:${record.id}`
|
|
819
|
-
819 if (included.has(recordKey)) {
|
|
820
|
-
820 const existingRecord = included.get(recordKey)
|
|
821
|
-
821 if (!existingRecord.relationships) {
|
|
822
|
-
822 existingRecord.relationships = {}
|
|
823
|
-
823 }
|
|
824
|
-
824 existingRecord.relationships[includeName] = relationshipObject
|
|
825
|
-
825 }
|
|
826
|
-
826 })
|
|
827
|
-
827
|
|
828
|
-
828 // Step 8: Process nested includes if any
|
|
829
|
-
829 if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
|
|
830
|
-
830 const nextPath = `${currentPath}.${includeName}`
|
|
831
|
-
831 if (!processedPaths.has(nextPath)) {
|
|
832
|
-
832 await processIncludes(
|
|
833
|
-
833 { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
|
|
834
|
-
834 { context: { scopes, log, knex, capabilities } }
|
|
835
|
-
835 )
|
|
836
|
-
836 }
|
|
837
|
-
837 }
|
|
838
|
-
838 } else if (relDef.type === 'hasMany') {
|
|
839
|
-
839 // Handle one-to-many relationship
|
|
840
|
-
840 const targetScope = relDef.target
|
|
841
|
-
841 const targetTable = scopes[targetScope].vars.schemaInfo.tableName
|
|
842
|
-
842 const foreignKey = relDef.foreignKey
|
|
843
|
-
843
|
|
844
|
-
844 if (!foreignKey) {
|
|
845
|
-
845 throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
|
|
846
|
-
846 }
|
|
847
|
-
847
|
|
848
|
-
848 log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
|
|
849
|
-
849 whereIn: mainIds,
|
|
850
|
-
850 includeConfig: relDef.include
|
|
851
|
-
851 })
|
|
852
|
-
852
|
|
853
|
-
853 // Build field selection for sparse fieldsets
|
|
854
|
-
854 const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
855
|
-
855 const targetScopeObject = scopes[targetScope]
|
|
856
|
-
856 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
857
|
-
857 const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
|
|
858
|
-
858 const fieldSelectionInfo = fields?.[targetScope]
|
|
859
|
-
859 ? await buildFieldSelection(targetScopeObject, {
|
|
860
|
-
860 context: {
|
|
861
|
-
861 scopeName: targetScope,
|
|
862
|
-
862 queryParams: { fields: { [targetScope]: fields[targetScope] } },
|
|
863
|
-
863 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
864
|
-
864 storageAdapter,
|
|
865
|
-
865 }
|
|
866
|
-
866 })
|
|
867
|
-
867 : null
|
|
868
|
-
868
|
|
869
|
-
869 let query
|
|
870
|
-
870 let usingWindowFunction = false
|
|
871
|
-
871
|
|
872
|
-
872 // Check if we should use window functions
|
|
873
|
-
873 if (relDef.include?.strategy === 'window') {
|
|
874
|
-
874 try {
|
|
875
|
-
875 // Try to build window function query
|
|
876
|
-
876 const selectFields = fieldSelectionInfo
|
|
877
|
-
877 ? translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
878
|
-
878 : null
|
|
879
|
-
879
|
|
880
|
-
880 query = buildWindowedIncludeQuery(
|
|
881
|
-
881 knex,
|
|
882
|
-
882 targetTable,
|
|
883
|
-
883 foreignKey,
|
|
884
|
-
884 mainIds,
|
|
885
|
-
885 selectFields,
|
|
886
|
-
886 relDef.include || {},
|
|
887
|
-
887 capabilities,
|
|
888
|
-
888 targetScopeObject.vars // Pass target scope vars
|
|
889
|
-
889 )
|
|
890
|
-
890 usingWindowFunction = true
|
|
891
|
-
891 log.debug('[INCLUDE] Using window function strategy with limits')
|
|
892
|
-
892 } catch (error) {
|
|
893
|
-
893 // If window functions not supported, this will throw a clear error
|
|
894
|
-
894 if (error.details?.requiredFeature === 'window_functions') {
|
|
895
|
-
895 throw error // Re-throw the descriptive error
|
|
896
|
-
896 }
|
|
897
|
-
897 // For other errors, fall back to standard query
|
|
898
|
-
898 log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
|
|
899
|
-
899 error: error.message,
|
|
900
|
-
900 stack: error.stack
|
|
901
|
-
901 })
|
|
902
|
-
902 usingWindowFunction = false
|
|
903
|
-
903 }
|
|
904
|
-
904 }
|
|
905
|
-
905
|
|
906
|
-
906 // Build standard query if not using window functions
|
|
907
|
-
907 if (!usingWindowFunction) {
|
|
908
|
-
908 query = knex(targetTable).whereIn(foreignKey, mainIds)
|
|
909
|
-
909
|
|
910
|
-
910 if (fieldSelectionInfo) {
|
|
911
|
-
911 const selectFieldsStd = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
912
|
-
912 query = query.select(selectFieldsStd)
|
|
913
|
-
913 }
|
|
914
|
-
914
|
|
915
|
-
915 // Apply standard config with defaults
|
|
916
|
-
916 const targetVars = scopes[targetScope].vars
|
|
917
|
-
917 query = applyStandardIncludeConfig(
|
|
918
|
-
918 query,
|
|
919
|
-
919 relDef.include || {},
|
|
920
|
-
920 targetVars,
|
|
921
|
-
921 log
|
|
922
|
-
922 )
|
|
923
|
-
923 }
|
|
924
|
-
924
|
|
925
|
-
925 // Execute query
|
|
926
|
-
926 const targetRecords = await query
|
|
927
|
-
927
|
|
928
|
-
928 // If using window functions, remove the row number column
|
|
929
|
-
929 if (usingWindowFunction) {
|
|
930
|
-
930 targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
|
|
931
|
-
931 }
|
|
932
|
-
932
|
|
933
|
-
933 log.trace('[INCLUDE] Loaded hasMany records:', {
|
|
934
|
-
934 count: targetRecords.length,
|
|
935
|
-
935 usingWindowFunction
|
|
936
|
-
936 })
|
|
937
|
-
937
|
|
938
|
-
938 // Load relationship metadata for all target records
|
|
939
|
-
939 await loadRelationshipMetadata(scopes, targetRecords, targetScope)
|
|
940
|
-
940
|
|
941
|
-
941 // Group by parent ID
|
|
942
|
-
942 const childrenByParent = {}
|
|
943
|
-
943 targetRecords.forEach(record => {
|
|
944
|
-
944 const parentId = record[foreignKey]
|
|
945
|
-
945 if (!childrenByParent[parentId]) {
|
|
946
|
-
946 childrenByParent[parentId] = []
|
|
947
|
-
947 }
|
|
948
|
-
948 childrenByParent[parentId].push(record)
|
|
949
|
-
949 })
|
|
950
|
-
950
|
|
951
|
-
951 // Set relationships on parent records
|
|
952
|
-
952 records.forEach(record => {
|
|
953
|
-
953 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
954
|
-
954
|
|
955
|
-
955 const children = childrenByParent[record.id] || []
|
|
956
|
-
956 const relData = children.map(childRecord => {
|
|
957
|
-
957 // Add to included
|
|
958
|
-
958 const resourceKey = `${targetScope}:${childRecord.id}`
|
|
959
|
-
959 if (!included.has(resourceKey)) {
|
|
960
|
-
960 const jsonApiRecord = toJsonApiRecord(
|
|
961
|
-
961 scopes[targetScope],
|
|
962
|
-
962 childRecord,
|
|
963
|
-
963 targetScope
|
|
964
|
-
964 )
|
|
965
|
-
965
|
|
966
|
-
966 // Add relationships from metadata
|
|
967
|
-
967 if (childRecord[RELATIONSHIP_METADATA_KEY]) {
|
|
968
|
-
968 jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
|
|
969
|
-
969 // Clean up the temporary property
|
|
970
|
-
970 delete childRecord[RELATIONSHIP_METADATA_KEY]
|
|
971
|
-
971 }
|
|
972
|
-
972
|
|
973
|
-
973 // Attach computed dependencies info if sparse fieldsets were used
|
|
974
|
-
974 if (fieldSelectionInfo?.computedDependencies) {
|
|
975
|
-
975 jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
|
|
976
|
-
976 }
|
|
977
|
-
977
|
|
978
|
-
978 included.set(resourceKey, jsonApiRecord)
|
|
979
|
-
979 }
|
|
980
|
-
980 return { type: targetScope, id: String(childRecord.id) }
|
|
981
|
-
981 })
|
|
982
|
-
982
|
|
983
|
-
983 const relationshipObject = { data: relData }
|
|
984
|
-
984
|
|
985
|
-
985 // Add links if urlPrefix is configured
|
|
986
|
-
986 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
987
|
-
987 if (scopeName) {
|
|
988
|
-
988 relationshipObject.links = {
|
|
989
|
-
989 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
990
|
-
990 related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
991
|
-
991 }
|
|
992
|
-
992 }
|
|
993
|
-
993
|
|
994
|
-
994 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
995
|
-
995
|
|
996
|
-
996 // Update the record in the included Map if it exists
|
|
997
|
-
997 const recordKey = `${scopeName}:${record.id}`
|
|
998
|
-
998 if (included.has(recordKey)) {
|
|
999
|
-
999 const existingRecord = included.get(recordKey)
|
|
1000
|
-
1000 if (!existingRecord.relationships) {
|
|
1001
|
-
1001 existingRecord.relationships = {}
|
|
1002
|
-
1002 }
|
|
1003
|
-
1003 existingRecord.relationships[includeName] = relationshipObject
|
|
1004
|
-
1004 }
|
|
1005
|
-
1005 })
|
|
1006
|
-
1006
|
|
1007
|
-
1007 // Process nested includes
|
|
1008
|
-
1008 if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
|
|
1009
|
-
1009 const nextPath = `${currentPath}.${includeName}`
|
|
1010
|
-
1010 if (!processedPaths.has(nextPath)) {
|
|
1011
|
-
1011 await processIncludes(
|
|
1012
|
-
1012 { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
|
|
1013
|
-
1013 { context: { scopes, log, knex, capabilities } }
|
|
1014
|
-
1014 )
|
|
1015
|
-
1015 }
|
|
1016
|
-
1016 }
|
|
1017
|
-
1017 }
|
|
1018
|
-
1018 } catch (error) {
|
|
1019
|
-
1019 // Log error with detailed context
|
|
1020
|
-
1020 log.error('[INCLUDE] Error loading hasMany relationship:', {
|
|
1021
|
-
1021 scopeName,
|
|
1022
|
-
1022 includeName,
|
|
1023
|
-
1023 hasThrough: !!relDef.through,
|
|
1024
|
-
1024 recordCount: records?.length || 0,
|
|
1025
|
-
1025 error: error.message,
|
|
1026
|
-
1026 stack: error.stack
|
|
1027
|
-
1027 })
|
|
1028
|
-
1028
|
|
1029
|
-
1029 // Re-throw with enhanced error message
|
|
1030
|
-
1030 const enhancedError = new Error(
|
|
1031
|
-
1031 `Failed to load hasMany relationship '${includeName}' for scope '${scopeName}': ${error.message}`
|
|
1032
|
-
1032 )
|
|
1033
|
-
1033 enhancedError.originalError = error
|
|
1034
|
-
1034 enhancedError.context = { scopeName, includeName, hasThrough: !!relDef.through }
|
|
1035
|
-
1035 throw enhancedError
|
|
1036
|
-
1036 }
|
|
1037
|
-
1037 }
|
|
1038
|
-
1038
|
|
1039
|
-
1039 /**
|
|
1040
|
-
1040 * Loads hasOne relationships (one-to-one)
|
|
1041
|
-
1041 *
|
|
1042
|
-
1042 * Similar to hasMany but expects a single related record.
|
|
1043
|
-
1043 * For example, a user that has one profile.
|
|
1044
|
-
1044 *
|
|
1045
|
-
1045 * @param {Object} scope - The hooked-api scope object
|
|
1046
|
-
1046 * @param {Object} deps - Dependencies object
|
|
1047
|
-
1047 */
|
|
1048
|
-
1048 export const loadHasOne = async (scope, deps) => {
|
|
1049
|
-
1049 const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
|
|
1050
|
-
1050 const { context: { scopes, log, knex, capabilities } } = deps
|
|
1051
|
-
1051
|
|
1052
|
-
1052 log = log?.child ? log.child({ method: 'loadHasOne' }) : console
|
|
1053
|
-
1053
|
|
1054
|
-
1054 if (!records || records.length === 0) {
|
|
1055
|
-
1055 log.trace('[INCLUDE] No records to load hasOne for')
|
|
1056
|
-
1056 return
|
|
1057
|
-
1057 }
|
|
1058
|
-
1058
|
|
1059
|
-
1059 // Get parent IDs
|
|
1060
|
-
1060 const idProperty = scopes[scopeName]?.vars?.schemaInfo?.idProperty || 'id'
|
|
1061
|
-
1061 const mainIds = [...new Set(records.map(r => r[idProperty]).filter(Boolean))]
|
|
1062
|
-
1062
|
|
1063
|
-
1063 if (mainIds.length === 0) {
|
|
1064
|
-
1064 log.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
|
|
1065
|
-
1065 return
|
|
1066
|
-
1066 }
|
|
1067
|
-
1067
|
|
1068
|
-
1068 const targetScope = relDef.target
|
|
1069
|
-
1069 const targetTable = scopes[targetScope].vars.schemaInfo.tableName
|
|
1070
|
-
1070 const foreignKey = relDef.foreignKey
|
|
1071
|
-
1071
|
|
1072
|
-
1072 if (!foreignKey) {
|
|
1073
|
-
1073 throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`)
|
|
1074
|
-
1074 }
|
|
1075
|
-
1075
|
|
1076
|
-
1076 log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
|
|
1077
|
-
1077 whereIn: mainIds
|
|
1078
|
-
1078 })
|
|
1079
|
-
1079
|
|
1080
|
-
1080 // Build query for hasOne - expects single result per parent
|
|
1081
|
-
1081 const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
1082
|
-
1082 const targetScopeObject = scopes[targetScope]
|
|
1083
|
-
1083 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
1084
|
-
1084 const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
|
|
1085
|
-
1085
|
|
1086
|
-
1086 // Build field selection for sparse fieldsets
|
|
1087
|
-
1087 const fieldSelectionInfo = fields?.[targetScope]
|
|
1088
|
-
1088 ? await buildFieldSelection(targetScopeObject, {
|
|
1089
|
-
1089 context: {
|
|
1090
|
-
1090 scopeName: targetScope,
|
|
1091
|
-
1091 queryParams: { fields: { [targetScope]: fields[targetScope] } },
|
|
1092
|
-
1092 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
1093
|
-
1093 storageAdapter,
|
|
1094
|
-
1094 }
|
|
1095
|
-
1095 })
|
|
1096
|
-
1096 : null
|
|
1097
|
-
1097
|
|
1098
|
-
1098 // Query related records
|
|
1099
|
-
1099 let query = knex(targetTable).whereIn(foreignKey, mainIds)
|
|
1100
|
-
1100
|
|
1101
|
-
1101 if (fieldSelectionInfo?.fieldsToSelect) {
|
|
1102
|
-
1102 const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
1103
|
-
1103 query = query.select(selectFields)
|
|
1104
|
-
1104 }
|
|
1105
|
-
1105
|
|
1106
|
-
1106 const relatedRecords = await query
|
|
1107
|
-
1107
|
|
1108
|
-
1108 // Create a map of parent ID to related record (should be one-to-one)
|
|
1109
|
-
1109 const relatedByParentId = {}
|
|
1110
|
-
1110 for (const related of relatedRecords) {
|
|
1111
|
-
1111 const parentId = related[foreignKey]
|
|
1112
|
-
1112 if (relatedByParentId[parentId]) {
|
|
1113
|
-
1113 log.warn(`[INCLUDE] Multiple records found for hasOne relationship '${includeName}' with ${foreignKey}=${parentId}`)
|
|
1114
|
-
1114 }
|
|
1115
|
-
1115 relatedByParentId[parentId] = related
|
|
1116
|
-
1116 }
|
|
1117
|
-
1117
|
|
1118
|
-
1118 // Process each parent record
|
|
1119
|
-
1119 for (const record of records) {
|
|
1120
|
-
1120 const recordId = record[idProperty]
|
|
1121
|
-
1121 const relatedRecord = relatedByParentId[recordId]
|
|
1122
|
-
1122
|
|
1123
|
-
1123 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
1124
|
-
1124
|
|
1125
|
-
1125 if (relatedRecord) {
|
|
1126
|
-
1126 // Transform to JSON:API format
|
|
1127
|
-
1127 const transformed = await toJsonApiRecord(
|
|
1128
|
-
1128 targetScopeObject,
|
|
1129
|
-
1129 {
|
|
1130
|
-
1130 context: {
|
|
1131
|
-
1131 record: relatedRecord,
|
|
1132
|
-
1132 scopeName: targetScope,
|
|
1133
|
-
1133 schemaInfo: targetScopeObject.vars.schemaInfo
|
|
1134
|
-
1134 }
|
|
1135
|
-
1135 }
|
|
1136
|
-
1136 )
|
|
1137
|
-
1137
|
|
1138
|
-
1138 // Set single object (not array) for hasOne
|
|
1139
|
-
1139 record[RELATIONSHIPS_KEY][includeName] = {
|
|
1140
|
-
1140 data: { type: targetScope, id: String(relatedRecord[targetIdProperty]) }
|
|
1141
|
-
1141 }
|
|
1142
|
-
1142
|
|
1143
|
-
1143 // Add to included if not already there
|
|
1144
|
-
1144 const key = `${targetScope}:${relatedRecord[targetIdProperty]}`
|
|
1145
|
-
1145 if (!included.has(key)) {
|
|
1146
|
-
1146 included.set(key, transformed)
|
|
1147
|
-
1147 }
|
|
1148
|
-
1148
|
|
1149
|
-
1149 // Process nested includes if any
|
|
1150
|
-
1150 if (subIncludes && Object.keys(subIncludes).length > 0) {
|
|
1151
|
-
1151 const newPath = `${currentPath}.${includeName}`
|
|
1152
|
-
1152 if (!processedPaths.has(newPath)) {
|
|
1153
|
-
1153 processedPaths.add(newPath)
|
|
1154
|
-
1154 await processIncludes(
|
|
1155
|
-
1155 { records: [relatedRecord], scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: newPath, fields },
|
|
1156
|
-
1156 { context: { scopes, log, knex, capabilities } }
|
|
1157
|
-
1157 )
|
|
1158
|
-
1158 }
|
|
1159
|
-
1159 }
|
|
1160
|
-
1160 } else {
|
|
1161
|
-
1161 // No related record found
|
|
1162
|
-
1162 record[RELATIONSHIPS_KEY][includeName] = { data: null }
|
|
1163
|
-
1163 }
|
|
1164
|
-
1164 }
|
|
1165
|
-
1165 }
|
|
1166
|
-
1166
|
|
1167
|
-
1167 /**
|
|
1168
|
-
1168 * Loads polymorphic belongsTo relationships
|
|
1169
|
-
1169 *
|
|
1170
|
-
1170 * Handles relationships where a record can belong to different types of parent records.
|
|
1171
|
-
1171 * For example, comments that can belong to either articles or videos.
|
|
1172
|
-
1172 *
|
|
1173
|
-
1173 * @param {Object} scope - The hooked-api scope object containing:
|
|
1174
|
-
1174 * - records: Array<Object> - Records with polymorphic relationships
|
|
1175
|
-
1175 * - relName: string - The relationship name
|
|
1176
|
-
1176 * - relDef: Object - The relationship definition with belongsToPolymorphic
|
|
1177
|
-
1177 * - subIncludes: Object - Nested includes to process recursively
|
|
1178
|
-
1178 * - included: Map - Map of already included resources
|
|
1179
|
-
1179 * - processedPaths: Set - Set of already processed paths
|
|
1180
|
-
1180 * - currentPath: string - Current include path for tracking
|
|
1181
|
-
1181 * - fields: Object - Sparse fieldsets configuration
|
|
1182
|
-
1182 * @param {Object} deps - Dependencies object containing:
|
|
1183
|
-
1183 * - context.scopes: Object - The hooked-api scopes object
|
|
1184
|
-
1184 * - context.log: Object - Logger instance
|
|
1185
|
-
1185 * - context.knex: Object - Knex instance
|
|
1186
|
-
1186 * @returns {Promise<void>}
|
|
1187
|
-
1187 */
|
|
1188
|
-
1188 export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
1189
|
-
1189 const { records, scopeName, relName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
|
|
1190
|
-
1190 const { scopes, log, knex, capabilities } = deps.context
|
|
1191
|
-
1191 try {
|
|
1192
|
-
1192 log.trace('[INCLUDE] Loading polymorphic belongsTo:', {
|
|
1193
|
-
1193 relName,
|
|
1194
|
-
1194 recordCount: records.length
|
|
1195
|
-
1195 })
|
|
1196
|
-
1196
|
|
1197
|
-
1197 const { typeField, idField, types } = relDef.belongsToPolymorphic
|
|
1198
|
-
1198
|
|
1199
|
-
1199 // Group records by their target type using helper
|
|
1200
|
-
1200 const grouped = groupByPolymorphicType(
|
|
1201
|
-
1201 records,
|
|
1202
|
-
1202 typeField,
|
|
1203
|
-
1203 idField
|
|
1204
|
-
1204 )
|
|
1205
|
-
1205
|
|
1206
|
-
1206 log.trace('[INCLUDE] Grouped by type:', {
|
|
1207
|
-
1207 types: Object.keys(grouped),
|
|
1208
|
-
1208 counts: Object.entries(grouped).map(([t, ids]) => `${t}: ${ids.length}`)
|
|
1209
|
-
1209 })
|
|
1210
|
-
1210
|
|
1211
|
-
1211 // Load each type separately
|
|
1212
|
-
1212 for (const [targetType, targetIds] of Object.entries(grouped)) {
|
|
1213
|
-
1213 // Skip if type not allowed (shouldn't happen with validation, but be safe)
|
|
1214
|
-
1214 if (!types.includes(targetType)) {
|
|
1215
|
-
1215 log.warn('[INCLUDE] Skipping non-allowed type:', targetType)
|
|
1216
|
-
1216 continue
|
|
1217
|
-
1217 }
|
|
1218
|
-
1218
|
|
1219
|
-
1219 if (targetIds.length === 0) continue
|
|
1220
|
-
1220
|
|
1221
|
-
1221 // Get target table information
|
|
1222
|
-
1222 const targetSchemaInfo = scopes[targetType].vars.schemaInfo
|
|
1223
|
-
1223 const targetTable = targetSchemaInfo?.tableName || targetType
|
|
1224
|
-
1224
|
|
1225
|
-
1225 // Build field selection for sparse fieldsets
|
|
1226
|
-
1226 const targetScopeObject = scopes[targetType]
|
|
1227
|
-
1227 // const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
|
|
1228
|
-
1228 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
1229
|
-
1229 const fieldSelectionInfo = fields?.[targetType]
|
|
1230
|
-
1230 ? await buildFieldSelection(targetScopeObject, {
|
|
1231
|
-
1231 context: {
|
|
1232
|
-
1232 scopeName: targetType,
|
|
1233
|
-
1233 queryParams: { fields: { [targetType]: fields[targetType] } },
|
|
1234
|
-
1234 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
1235
|
-
1235 storageAdapter,
|
|
1236
|
-
1236 }
|
|
1237
|
-
1237 })
|
|
1238
|
-
1238 : null
|
|
1239
|
-
1239
|
|
1240
|
-
1240 log.debug(`[INCLUDE] Loading ${targetType} records:`, {
|
|
1241
|
-
1241 ids: targetIds,
|
|
1242
|
-
1242 fields: fieldSelectionInfo ? fieldSelectionInfo.fieldsToSelect : '*'
|
|
1243
|
-
1243 })
|
|
1244
|
-
1244
|
|
1245
|
-
1245 // Query for this type
|
|
1246
|
-
1246 const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id'
|
|
1247
|
-
1247 let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
|
|
1248
|
-
1248 if (fieldSelectionInfo) {
|
|
1249
|
-
1249 const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
1250
|
-
1250 query = query.select(selectFields)
|
|
1251
|
-
1251 } else if (targetIdProperty !== 'id') {
|
|
1252
|
-
1252 // If no field selection but custom idProperty, we need to alias it
|
|
1253
|
-
1253 query = query.select('*', `${targetIdProperty} as id`)
|
|
1254
|
-
1254 }
|
|
1255
|
-
1255 const targetRecords = await query
|
|
1256
|
-
1256
|
|
1257
|
-
1257 // Load relationship metadata for all target records
|
|
1258
|
-
1258 await loadRelationshipMetadata(scopes, targetRecords, targetType)
|
|
1259
|
-
1259
|
|
1260
|
-
1260 // Create lookup map
|
|
1261
|
-
1261 const targetById = {}
|
|
1262
|
-
1262 targetRecords.forEach(record => {
|
|
1263
|
-
1263 targetById[record.id] = record
|
|
1264
|
-
1264 })
|
|
1265
|
-
1265
|
|
1266
|
-
1266 // Add to included and set relationships
|
|
1267
|
-
1267 records.forEach(record => {
|
|
1268
|
-
1268 if (record[typeField] === targetType) {
|
|
1269
|
-
1269 const targetId = record[idField]
|
|
1270
|
-
1270 const targetRecord = targetById[targetId]
|
|
1271
|
-
1271
|
|
1272
|
-
1272 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
1273
|
-
1273
|
|
1274
|
-
1274 if (targetRecord) {
|
|
1275
|
-
1275 // Add to included
|
|
1276
|
-
1276 const resourceKey = `${targetType}:${targetId}`
|
|
1277
|
-
1277 if (!included.has(resourceKey)) {
|
|
1278
|
-
1278 const jsonApiRecord = toJsonApiRecord(
|
|
1279
|
-
1279 scopes[targetType],
|
|
1280
|
-
1280 targetRecord,
|
|
1281
|
-
1281 targetType
|
|
1282
|
-
1282 )
|
|
1283
|
-
1283
|
|
1284
|
-
1284 // Add relationships from metadata
|
|
1285
|
-
1285 if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
|
|
1286
|
-
1286 jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
|
|
1287
|
-
1287 // Clean up the temporary property
|
|
1288
|
-
1288 delete targetRecord[RELATIONSHIP_METADATA_KEY]
|
|
1289
|
-
1289 }
|
|
1290
|
-
1290
|
|
1291
|
-
1291 // Attach computed dependencies info if sparse fieldsets were used
|
|
1292
|
-
1292 if (fieldSelectionInfo?.computedDependencies) {
|
|
1293
|
-
1293 jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
|
|
1294
|
-
1294 }
|
|
1295
|
-
1295
|
|
1296
|
-
1296 included.set(resourceKey, jsonApiRecord)
|
|
1297
|
-
1297 }
|
|
1298
|
-
1298
|
|
1299
|
-
1299 const relationshipObject = {
|
|
1300
|
-
1300 data: { type: targetType, id: String(targetId) }
|
|
1301
|
-
1301 }
|
|
1302
|
-
1302
|
|
1303
|
-
1303 // Add links if urlPrefix is configured
|
|
1304
|
-
1304 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
1305
|
-
1305 if (scopeName) {
|
|
1306
|
-
1306 relationshipObject.links = {
|
|
1307
|
-
1307 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1308
|
-
1308 related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
1309
|
-
1309 }
|
|
1310
|
-
1310 }
|
|
1311
|
-
1311
|
|
1312
|
-
1312 record[RELATIONSHIPS_KEY][relName] = relationshipObject
|
|
1313
|
-
1313 } else {
|
|
1314
|
-
1314 const relationshipObject = { data: null }
|
|
1315
|
-
1315
|
|
1316
|
-
1316 // Add links if urlPrefix is configured
|
|
1317
|
-
1317 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
1318
|
-
1318 if (scopeName) {
|
|
1319
|
-
1319 relationshipObject.links = {
|
|
1320
|
-
1320 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1321
|
-
1321 related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
1322
|
-
1322 }
|
|
1323
|
-
1323 }
|
|
1324
|
-
1324
|
|
1325
|
-
1325 record[RELATIONSHIPS_KEY][relName] = relationshipObject
|
|
1326
|
-
1326 }
|
|
1327
|
-
1327 }
|
|
1328
|
-
1328 })
|
|
1329
|
-
1329
|
|
1330
|
-
1330 // Process nested includes for this type
|
|
1331
|
-
1331 if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
|
|
1332
|
-
1332 const nextPath = `${currentPath}.${relName}`
|
|
1333
|
-
1333 if (!processedPaths.has(nextPath)) {
|
|
1334
|
-
1334 await processIncludes(
|
|
1335
|
-
1335 { records: targetRecords, scopeName: targetType, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
|
|
1336
|
-
1336 { context: { scopes, log, knex, capabilities } }
|
|
1337
|
-
1337 )
|
|
1338
|
-
1338 }
|
|
1339
|
-
1339 }
|
|
1340
|
-
1340 }
|
|
1341
|
-
1341
|
|
1342
|
-
1342 // Set null for records without relationships
|
|
1343
|
-
1343 records.forEach(record => {
|
|
1344
|
-
1344 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
1345
|
-
1345 if (!record[RELATIONSHIPS_KEY][relName]) {
|
|
1346
|
-
1346 const relationshipObject = { data: null }
|
|
1347
|
-
1347
|
|
1348
|
-
1348 // Add links if urlPrefix is configured
|
|
1349
|
-
1349 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
1350
|
-
1350 if (scopeName) {
|
|
1351
|
-
1351 relationshipObject.links = {
|
|
1352
|
-
1352 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1353
|
-
1353 related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
1354
|
-
1354 }
|
|
1355
|
-
1355 }
|
|
1356
|
-
1356
|
|
1357
|
-
1357 record[RELATIONSHIPS_KEY][relName] = relationshipObject
|
|
1358
|
-
1358 }
|
|
1359
|
-
1359 })
|
|
1360
|
-
1360 } catch (error) {
|
|
1361
|
-
1361 // Log error with detailed context
|
|
1362
|
-
1362 log.error('[INCLUDE] Error loading polymorphic belongsTo relationship:', {
|
|
1363
|
-
1363 relName,
|
|
1364
|
-
1364 recordCount: records?.length || 0,
|
|
1365
|
-
1365 types: relDef?.belongsToPolymorphic?.types,
|
|
1366
|
-
1366 error: error.message,
|
|
1367
|
-
1367 stack: error.stack
|
|
1368
|
-
1368 })
|
|
1369
|
-
1369
|
|
1370
|
-
1370 // Re-throw with enhanced error message
|
|
1371
|
-
1371 const enhancedError = new Error(
|
|
1372
|
-
1372 `Failed to load polymorphic belongsTo relationship '${relName}': ${error.message}`
|
|
1373
|
-
1373 )
|
|
1374
|
-
1374 enhancedError.originalError = error
|
|
1375
|
-
1375 enhancedError.context = { relName, types: relDef?.belongsToPolymorphic?.types }
|
|
1376
|
-
1376 throw enhancedError
|
|
1377
|
-
1377 }
|
|
1378
|
-
1378 }
|
|
1379
|
-
1379
|
|
1380
|
-
1380 /**
|
|
1381
|
-
1381 * Loads reverse polymorphic relationships (via)
|
|
1382
|
-
1382 *
|
|
1383
|
-
1383 * Handles loading "child" records that have a polymorphic relationship back to the parent.
|
|
1384
|
-
1384 * For example, loading all comments (which can belong to articles or videos) for a specific article.
|
|
1385
|
-
1385 *
|
|
1386
|
-
1386 * @param {Object} scope - The hooked-api scope object containing:
|
|
1387
|
-
1387 * - records: Array<Object> - Parent records
|
|
1388
|
-
1388 * - scopeName: string - The parent scope name
|
|
1389
|
-
1389 * - includeName: string - The relationship name
|
|
1390
|
-
1390 * - relDef: Object - The relationship definition with 'via' property
|
|
1391
|
-
1391 * - subIncludes: Object - Nested includes to process recursively
|
|
1392
|
-
1392 * - included: Map - Map of already included resources
|
|
1393
|
-
1393 * - processedPaths: Set - Set of already processed paths
|
|
1394
|
-
1394 * - currentPath: string - Current include path for tracking
|
|
1395
|
-
1395 * - fields: Object - Sparse fieldsets configuration
|
|
1396
|
-
1396 * @param {Object} deps - Dependencies object containing:
|
|
1397
|
-
1397 * - context.scopes: Object - The hooked-api scopes object
|
|
1398
|
-
1398 * - context.log: Object - Logger instance
|
|
1399
|
-
1399 * - context.knex: Object - Knex instance
|
|
1400
|
-
1400 * @returns {Promise<void>}
|
|
1401
|
-
1401 */
|
|
1402
|
-
1402 export const loadReversePolymorphic = async (scope, deps) => {
|
|
1403
|
-
1403 const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
|
|
1404
|
-
1404 const { scopes, log, knex, capabilities } = deps.context
|
|
1405
|
-
1405 try {
|
|
1406
|
-
1406 log.trace('[INCLUDE] Loading reverse polymorphic (via):', {
|
|
1407
|
-
1407 scopeName,
|
|
1408
|
-
1408 includeName,
|
|
1409
|
-
1409 via: relDef.via,
|
|
1410
|
-
1410 recordCount: records.length
|
|
1411
|
-
1411 })
|
|
1412
|
-
1412
|
|
1413
|
-
1413 const targetScope = relDef.target
|
|
1414
|
-
1414 const viaRelName = relDef.via
|
|
1415
|
-
1415
|
|
1416
|
-
1416 // Get the polymorphic field info from target scope
|
|
1417
|
-
1417 const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships
|
|
1418
|
-
1418 const viaRel = targetRelationships?.[viaRelName]
|
|
1419
|
-
1419
|
|
1420
|
-
1420 if (!viaRel?.belongsToPolymorphic) {
|
|
1421
|
-
1421 log.warn('[INCLUDE] Via relationship not found or not polymorphic:', {
|
|
1422
|
-
1422 targetScope,
|
|
1423
|
-
1423 viaRelName
|
|
1424
|
-
1424 })
|
|
1425
|
-
1425 return
|
|
1426
|
-
1426 }
|
|
1427
|
-
1427
|
|
1428
|
-
1428 const { typeField, idField } = viaRel.belongsToPolymorphic
|
|
1429
|
-
1429 const targetTable = scopes[targetScope].vars.schemaInfo.tableName
|
|
1430
|
-
1430
|
|
1431
|
-
1431 // Collect parent IDs
|
|
1432
|
-
1432 const parentIds = records.map(r => r.id).filter(Boolean)
|
|
1433
|
-
1433 if (parentIds.length === 0) return
|
|
1434
|
-
1434
|
|
1435
|
-
1435 log.debug(`[INCLUDE] Loading ${targetScope} records via ${viaRelName}:`, {
|
|
1436
|
-
1436 typeField,
|
|
1437
|
-
1437 idField,
|
|
1438
|
-
1438 scopeName,
|
|
1439
|
-
1439 parentIds
|
|
1440
|
-
1440 })
|
|
1441
|
-
1441
|
|
1442
|
-
1442 // Build field selection for sparse fieldsets
|
|
1443
|
-
1443 const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
1444
|
-
1444 const targetScopeObject = scopes[targetScope]
|
|
1445
|
-
1445 const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
1446
|
-
1446 const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
|
|
1447
|
-
1447 const fieldSelectionInfo = fields?.[targetScope]
|
|
1448
|
-
1448 ? await buildFieldSelection(targetScopeObject, {
|
|
1449
|
-
1449 context: {
|
|
1450
|
-
1450 scopeName: targetScope,
|
|
1451
|
-
1451 queryParams: { fields: { [targetScope]: fields[targetScope] } },
|
|
1452
|
-
1452 schemaInfo: targetScopeObject.vars.schemaInfo,
|
|
1453
|
-
1453 storageAdapter,
|
|
1454
|
-
1454 }
|
|
1455
|
-
1455 })
|
|
1456
|
-
1456 : null
|
|
1457
|
-
1457
|
|
1458
|
-
1458 // Query for records pointing back to our scope
|
|
1459
|
-
1459 let query = knex(targetTable)
|
|
1460
|
-
1460 .where(typeField, scopeName)
|
|
1461
|
-
1461 .whereIn(idField, parentIds)
|
|
1462
|
-
1462
|
|
1463
|
-
1463 if (fieldSelectionInfo) {
|
|
1464
|
-
1464 const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
|
|
1465
|
-
1465 query = query.select(selectFields)
|
|
1466
|
-
1466 } else if (targetIdProperty !== 'id') {
|
|
1467
|
-
1467 // If no field selection but custom idProperty, we need to alias it
|
|
1468
|
-
1468 query = query.select('*', `${targetIdProperty} as id`)
|
|
1469
|
-
1469 }
|
|
1470
|
-
1470
|
|
1471
|
-
1471 // Apply include configuration (limits, ordering, etc.)
|
|
1472
|
-
1472 const targetVars = scopes[targetScope].vars
|
|
1473
|
-
1473 query = applyStandardIncludeConfig(
|
|
1474
|
-
1474 query,
|
|
1475
|
-
1475 relDef.include || {},
|
|
1476
|
-
1476 targetVars,
|
|
1477
|
-
1477 log
|
|
1478
|
-
1478 )
|
|
1479
|
-
1479
|
|
1480
|
-
1480 const targetRecords = await query
|
|
1481
|
-
1481
|
|
1482
|
-
1482 log.trace('[INCLUDE] Loaded reverse polymorphic records:', {
|
|
1483
|
-
1483 count: targetRecords.length
|
|
1484
|
-
1484 })
|
|
1485
|
-
1485
|
|
1486
|
-
1486 // Load relationship metadata for all target records
|
|
1487
|
-
1487 await loadRelationshipMetadata(scopes, targetRecords, targetScope)
|
|
1488
|
-
1488
|
|
1489
|
-
1489 // Group by parent ID
|
|
1490
|
-
1490 const childrenByParent = {}
|
|
1491
|
-
1491 targetRecords.forEach(record => {
|
|
1492
|
-
1492 const parentId = record[idField]
|
|
1493
|
-
1493 if (!childrenByParent[parentId]) {
|
|
1494
|
-
1494 childrenByParent[parentId] = []
|
|
1495
|
-
1495 }
|
|
1496
|
-
1496 childrenByParent[parentId].push(record)
|
|
1497
|
-
1497 })
|
|
1498
|
-
1498
|
|
1499
|
-
1499 // Set relationships on parent records
|
|
1500
|
-
1500 records.forEach(record => {
|
|
1501
|
-
1501 if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
|
|
1502
|
-
1502
|
|
1503
|
-
1503 const children = childrenByParent[record.id] || []
|
|
1504
|
-
1504 const relData = children.map(childRecord => {
|
|
1505
|
-
1505 // Add to included
|
|
1506
|
-
1506 const resourceKey = `${targetScope}:${childRecord.id}`
|
|
1507
|
-
1507 if (!included.has(resourceKey)) {
|
|
1508
|
-
1508 const jsonApiRecord = toJsonApiRecord(
|
|
1509
|
-
1509 scopes[targetScope],
|
|
1510
|
-
1510 childRecord,
|
|
1511
|
-
1511 targetScope
|
|
1512
|
-
1512 )
|
|
1513
|
-
1513
|
|
1514
|
-
1514 // Add relationships from metadata
|
|
1515
|
-
1515 if (childRecord[RELATIONSHIP_METADATA_KEY]) {
|
|
1516
|
-
1516 jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
|
|
1517
|
-
1517 // Clean up the temporary property
|
|
1518
|
-
1518 delete childRecord[RELATIONSHIP_METADATA_KEY]
|
|
1519
|
-
1519 }
|
|
1520
|
-
1520
|
|
1521
|
-
1521 // Attach computed dependencies info if sparse fieldsets were used
|
|
1522
|
-
1522 if (fieldSelectionInfo?.computedDependencies) {
|
|
1523
|
-
1523 jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
|
|
1524
|
-
1524 }
|
|
1525
|
-
1525
|
|
1526
|
-
1526 included.set(resourceKey, jsonApiRecord)
|
|
1527
|
-
1527 }
|
|
1528
|
-
1528 return { type: targetScope, id: String(childRecord.id) }
|
|
1529
|
-
1529 })
|
|
1530
|
-
1530
|
|
1531
|
-
1531 const relationshipObject = { data: relData }
|
|
1532
|
-
1532
|
|
1533
|
-
1533 // Add links if urlPrefix is configured
|
|
1534
|
-
1534 const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
|
|
1535
|
-
1535 if (scopeName) {
|
|
1536
|
-
1536 relationshipObject.links = {
|
|
1537
|
-
1537 self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
1538
|
-
1538 related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
1539
|
-
1539 }
|
|
1540
|
-
1540 }
|
|
1541
|
-
1541
|
|
1542
|
-
1542 record[RELATIONSHIPS_KEY][includeName] = relationshipObject
|
|
1543
|
-
1543 })
|
|
1544
|
-
1544
|
|
1545
|
-
1545 // Process nested includes
|
|
1546
|
-
1546 if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
|
|
1547
|
-
1547 const nextPath = `${currentPath}.${includeName}`
|
|
1548
|
-
1548 if (!processedPaths.has(nextPath)) {
|
|
1549
|
-
1549 await processIncludes(
|
|
1550
|
-
1550 { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
|
|
1551
|
-
1551 { context: { scopes, log, knex } }
|
|
1552
|
-
1552 )
|
|
1553
|
-
1553 }
|
|
1554
|
-
1554 }
|
|
1555
|
-
1555 } catch (error) {
|
|
1556
|
-
1556 // Log error with detailed context
|
|
1557
|
-
1557 log.error('[INCLUDE] Error loading reverse polymorphic relationship:', {
|
|
1558
|
-
1558 scopeName,
|
|
1559
|
-
1559 includeName,
|
|
1560
|
-
1560 via: relDef?.via,
|
|
1561
|
-
1561 targetScope: relDef?.target,
|
|
1562
|
-
1562 recordCount: records?.length || 0,
|
|
1563
|
-
1563 error: error.message,
|
|
1564
|
-
1564 stack: error.stack
|
|
1565
|
-
1565 })
|
|
1566
|
-
1566
|
|
1567
|
-
1567 // Re-throw with enhanced error message
|
|
1568
|
-
1568 const enhancedError = new Error(
|
|
1569
|
-
1569 `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}': ${error.message}`
|
|
1570
|
-
1570 )
|
|
1571
|
-
1571 enhancedError.originalError = error
|
|
1572
|
-
1572 enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target }
|
|
1573
|
-
1573 throw enhancedError
|
|
1574
|
-
1574 }
|
|
1575
|
-
1575 }
|
|
1576
|
-
1576
|
|
1577
|
-
1577 /**
|
|
1578
|
-
1578 * Processes includes for a set of records
|
|
1579
|
-
1579 *
|
|
1580
|
-
1580 * This is the main recursive function that processes the include tree. It examines
|
|
1581
|
-
1581 * the schema to determine relationship types and calls the appropriate loader function.
|
|
1582
|
-
1582 *
|
|
1583
|
-
1583 * @param {Object} scope - The hooked-api scope object containing:
|
|
1584
|
-
1584 * - records: Array<Object> - Records to process includes for
|
|
1585
|
-
1585 * - scopeName: string - The scope name of the records
|
|
1586
|
-
1586 * - includeTree: Object - Parsed include tree from parseIncludeTree
|
|
1587
|
-
1587 * - included: Map - Map storing all included resources
|
|
1588
|
-
1588 * - processedPaths: Set - Set tracking processed paths to prevent cycles
|
|
1589
|
-
1589 * - currentPath: string - Current path in the include tree (default '')
|
|
1590
|
-
1590 * - fields: Object - Sparse fieldsets configuration (default {})
|
|
1591
|
-
1591 * - idProperty: string - The ID property name
|
|
1592
|
-
1592 * @param {Object} deps - Dependencies object containing:
|
|
1593
|
-
1593 * - context.scopes: Object - The hooked-api scopes object
|
|
1594
|
-
1594 * - context.log: Object - Logger instance
|
|
1595
|
-
1595 * - context.knex: Object - Knex instance
|
|
1596
|
-
1596 * @returns {Promise<void>}
|
|
1597
|
-
1597 */
|
|
1598
|
-
1598 export const processIncludes = async (scope, deps) => {
|
|
1599
|
-
1599 const { records, scopeName, includeTree, included, processedPaths, currentPath = '', fields = {}, idProperty } = scope
|
|
1600
|
-
1600 const { scopes, log, knex, capabilities } = deps.context
|
|
1601
|
-
1601 try {
|
|
1602
|
-
1602 log.trace('[INCLUDE] Processing includes:', {
|
|
1603
|
-
1603 scopeName,
|
|
1604
|
-
1604 includes: Object.keys(includeTree),
|
|
1605
|
-
1605 recordCount: records.length,
|
|
1606
|
-
1606 currentPath
|
|
1607
|
-
1607 })
|
|
1608
|
-
1608
|
|
1609
|
-
1609 if (records.length === 0) return
|
|
1610
|
-
1610
|
|
1611
|
-
1611 // Get schema info for this scope
|
|
1612
|
-
1612 const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
1613
|
-
1613 if (!schemaInfo) {
|
|
1614
|
-
1614 log.warn('[INCLUDE] No schema info for scope:', scopeName)
|
|
1615
|
-
1615 return
|
|
1616
|
-
1616 }
|
|
1617
|
-
1617
|
|
1618
|
-
1618 const { schemaInstance, schemaRelationships } = schemaInfo
|
|
1619
|
-
1619
|
|
1620
|
-
1620 // Process each include
|
|
1621
|
-
1621 for (const [includeName, subIncludes] of Object.entries(includeTree)) {
|
|
1622
|
-
1622 const fullPath = currentPath ? `${currentPath}.${includeName}` : includeName
|
|
1623
|
-
1623
|
|
1624
|
-
1624 // Skip if already processed (prevents infinite loops)
|
|
1625
|
-
1625 if (processedPaths.has(fullPath)) {
|
|
1626
|
-
1626 log.trace('[INCLUDE] Skipping already processed path:', fullPath)
|
|
1627
|
-
1627 continue
|
|
1628
|
-
1628 }
|
|
1629
|
-
1629 processedPaths.add(fullPath)
|
|
1630
|
-
1630
|
|
1631
|
-
1631 // Check if it's a schema field (belongsTo)
|
|
1632
|
-
1632 let handled = false
|
|
1633
|
-
1633
|
|
1634
|
-
1634 try {
|
|
1635
|
-
1635 // Look for belongsTo relationships in schema fields
|
|
1636
|
-
1636 for (const [fieldName, fieldDef] of Object.entries(schemaInstance.structure || {})) {
|
|
1637
|
-
1637 if (fieldDef.as === includeName && fieldDef.belongsTo) {
|
|
1638
|
-
1638 await loadBelongsTo(
|
|
1639
|
-
1639 { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty },
|
|
1640
|
-
1640 { context: { scopes, log, knex, capabilities } }
|
|
1641
|
-
1641 )
|
|
1642
|
-
1642 handled = true
|
|
1643
|
-
1643 break
|
|
1644
|
-
1644 }
|
|
1645
|
-
1645 }
|
|
1646
|
-
1646
|
|
1647
|
-
1647 // Check relationships
|
|
1648
|
-
1648 if (!handled && schemaRelationships) {
|
|
1649
|
-
1649 const relDef = schemaRelationships[includeName]
|
|
1650
|
-
1650
|
|
1651
|
-
1651 if (relDef) {
|
|
1652
|
-
1652 if (relDef.type === 'hasOne') {
|
|
1653
|
-
1653 // Handle hasOne relationship
|
|
1654
|
-
1654 log.debug('[INCLUDE] About to call loadHasOne:', {
|
|
1655
|
-
1655 scopeName,
|
|
1656
|
-
1656 includeName,
|
|
1657
|
-
1657 target: relDef.target
|
|
1658
|
-
1658 })
|
|
1659
|
-
1659 await loadHasOne(
|
|
1660
|
-
1660 { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
|
|
1661
|
-
1661 { context: { scopes, log, knex, capabilities } }
|
|
1662
|
-
1662 )
|
|
1663
|
-
1663 handled = true
|
|
1664
|
-
1664 } else if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
1665
|
-
1665 // Check if it's a reverse polymorphic (via)
|
|
1666
|
-
1666 if (relDef.via) {
|
|
1667
|
-
1667 await loadReversePolymorphic(
|
|
1668
|
-
1668 { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
|
|
1669
|
-
1669 { context: { scopes, log, knex, capabilities } }
|
|
1670
|
-
1670 )
|
|
1671
|
-
1671 } else {
|
|
1672
|
-
1672 log.debug('[INCLUDE] About to call loadHasMany:', {
|
|
1673
|
-
1673 scopeName,
|
|
1674
|
-
1674 includeName,
|
|
1675
|
-
1675 relDefKeys: Object.keys(relDef || {}),
|
|
1676
|
-
1676 hasManyToMany: relDef.type === 'manyToMany',
|
|
1677
|
-
1677 hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
|
|
1678
|
-
1678 })
|
|
1679
|
-
1679 await loadHasMany(
|
|
1680
|
-
1680 { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
|
|
1681
|
-
1681 { context: { scopes, log, knex, capabilities } }
|
|
1682
|
-
1682 )
|
|
1683
|
-
1683 }
|
|
1684
|
-
1684 handled = true
|
|
1685
|
-
1685 } else if (relDef.belongsToPolymorphic) {
|
|
1686
|
-
1686 await loadPolymorphicBelongsTo(
|
|
1687
|
-
1687 { records, scopeName, relName: includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
|
|
1688
|
-
1688 { context: { scopes, log, knex, capabilities } }
|
|
1689
|
-
1689 )
|
|
1690
|
-
1690 handled = true
|
|
1691
|
-
1691 }
|
|
1692
|
-
1692 }
|
|
1693
|
-
1693 }
|
|
1694
|
-
1694 } catch (includeError) {
|
|
1695
|
-
1695 // Log specific include error and continue with other includes
|
|
1696
|
-
1696 log.error('[INCLUDE] Error processing include:', {
|
|
1697
|
-
1697 scopeName,
|
|
1698
|
-
1698 includeName,
|
|
1699
|
-
1699 fullPath,
|
|
1700
|
-
1700 error: includeError.message
|
|
1701
|
-
1701 })
|
|
1702
|
-
1702 // Re-throw to maintain existing behavior
|
|
1703
|
-
1703 throw includeError
|
|
1704
|
-
1704 }
|
|
1705
|
-
1705
|
|
1706
|
-
1706 if (!handled) {
|
|
1707
|
-
1707 log.warn('[INCLUDE] Unknown relationship:', {
|
|
1708
|
-
1708 scopeName,
|
|
1709
|
-
1709 includeName,
|
|
1710
|
-
1710 availableFields: Object.keys(schemaInstance.structure || {}).filter(k => schemaInstance.structure[k].as),
|
|
1711
|
-
1711 availableRelationships: Object.keys(schemaRelationships || {})
|
|
1712
|
-
1712 })
|
|
1713
|
-
1713 }
|
|
1714
|
-
1714 }
|
|
1715
|
-
1715 } catch (error) {
|
|
1716
|
-
1716 // Log error with full context
|
|
1717
|
-
1717 log.error('[INCLUDE] Error in processIncludes:', {
|
|
1718
|
-
1718 scopeName,
|
|
1719
|
-
1719 currentPath,
|
|
1720
|
-
1720 includeTree: Object.keys(includeTree || {}),
|
|
1721
|
-
1721 recordCount: records?.length || 0,
|
|
1722
|
-
1722 error: error.message,
|
|
1723
|
-
1723 stack: error.stack
|
|
1724
|
-
1724 })
|
|
1725
|
-
1725 throw error // Re-throw to maintain existing error propagation
|
|
1726
|
-
1726 }
|
|
1727
|
-
1727 }
|
|
1728
|
-
1728
|
|
1729
|
-
1729 /**
|
|
1730
|
-
1730 * Main entry point for building included resources
|
|
1731
|
-
1731 *
|
|
1732
|
-
1732 * Takes a set of records and an include parameter, loads all requested relationships,
|
|
1733
|
-
1733 * and returns both the included resources and the records with relationship data attached.
|
|
1734
|
-
1734 *
|
|
1735
|
-
1735 * @param {Object} scope - The hooked-api scope object containing:
|
|
1736
|
-
1736 * - records: Array<Object> - The main records to process
|
|
1737
|
-
1737 * - scopeName: string - The scope name of the main resources
|
|
1738
|
-
1738 * - includeParam: string - The include parameter value (e.g., "author,comments.author")
|
|
1739
|
-
1739 * - fields: Object - Sparse fieldsets configuration
|
|
1740
|
-
1740 * - idProperty: string - The ID property name
|
|
1741
|
-
1741 * @param {Object} deps - Dependencies object containing:
|
|
1742
|
-
1742 * - context.scopes: Object - The hooked-api scopes object
|
|
1743
|
-
1743 * - context.log: Object - Logger instance
|
|
1744
|
-
1744 * - context.knex: Object - Knex instance
|
|
1745
|
-
1745 * @returns {Promise<Object>} Object with included array and records with relationships
|
|
1746
|
-
1746 *
|
|
1747
|
-
1747 * @example
|
|
1748
|
-
1748 * const result = await buildIncludedResources(
|
|
1749
|
-
1749 * {
|
|
1750
|
-
1750 * records: articleRecords,
|
|
1751
|
-
1751 * scopeName: 'articles',
|
|
1752
|
-
1752 * includeParam: 'author,comments.author',
|
|
1753
|
-
1753 * fields: { articles: 'title,body', people: 'name' },
|
|
1754
|
-
1754 * idProperty: 'id'
|
|
1755
|
-
1755 * },
|
|
1756
|
-
1756 * { context: { scopes, log, knex } }
|
|
1757
|
-
1757 * );
|
|
1758
|
-
1758 *
|
|
1759
|
-
1759 * // result.included = [
|
|
1760
|
-
1760 * // { type: 'people', id: '1', attributes: {...} },
|
|
1761
|
-
1761 * // { type: 'comments', id: '1', attributes: {...} },
|
|
1762
|
-
1762 * // ...
|
|
1763
|
-
1763 * // ]
|
|
1764
|
-
1764 *
|
|
1765
|
-
1765 * // result.recordsWithRelationships = original records with _relationships added
|
|
1766
|
-
1766 */
|
|
1767
|
-
1767 export const buildIncludedResources = async (scope, deps) => {
|
|
1768
|
-
1768 const { records, scopeName, includeParam, fields, idProperty } = scope
|
|
1769
|
-
1769 const { scopes, log, knex, capabilities } = deps.context
|
|
1770
|
-
1770 try {
|
|
1771
|
-
1771 log.trace('[INCLUDE] Building included resources:', { scopeName, includeParam, recordCount: records.length })
|
|
1772
|
-
1772
|
|
1773
|
-
1773 // Check if includes are empty or records are empty
|
|
1774
|
-
1774 if (!includeParam || records.length === 0) {
|
|
1775
|
-
1775 log.trace('[INCLUDE] No includes requested or no records')
|
|
1776
|
-
1776 return {
|
|
1777
|
-
1777 included: [],
|
|
1778
|
-
1778 recordsWithRelationships: records
|
|
1779
|
-
1779 }
|
|
1780
|
-
1780 }
|
|
1781
|
-
1781
|
|
1782
|
-
1782 // Handle both string and array formats
|
|
1783
|
-
1783 if (Array.isArray(includeParam) && includeParam.length === 0) {
|
|
1784
|
-
1784 log.trace('[INCLUDE] Empty include array, no relationships to load')
|
|
1785
|
-
1785 return {
|
|
1786
|
-
1786 included: [],
|
|
1787
|
-
1787 recordsWithRelationships: records
|
|
1788
|
-
1788 }
|
|
1789
|
-
1789 }
|
|
1790
|
-
1790
|
|
1791
|
-
1791 // Parse the include parameter
|
|
1792
|
-
1792 const includeTree = parseIncludeTree(includeParam)
|
|
1793
|
-
1793
|
|
1794
|
-
1794 log.debug('[INCLUDE] Parsed include tree:', includeTree)
|
|
1795
|
-
1795
|
|
1796
|
-
1796 // Use a Map to track included resources by type:id
|
|
1797
|
-
1797 const included = new Map()
|
|
1798
|
-
1798 const processedPaths = new Set() // Prevent infinite loops
|
|
1799
|
-
1799
|
|
1800
|
-
1800 // Process all includes
|
|
1801
|
-
1801 await processIncludes(
|
|
1802
|
-
1802 { records, scopeName, includeTree, included, processedPaths, currentPath: '', fields, idProperty },
|
|
1803
|
-
1803 { context: { scopes, log, knex, capabilities } }
|
|
1804
|
-
1804 )
|
|
1805
|
-
1805
|
|
1806
|
-
1806 // Convert Map to array for JSON:API format
|
|
1807
|
-
1807 const includedArray = Array.from(included.values())
|
|
1808
|
-
1808
|
|
1809
|
-
1809 log.debug('[INCLUDE] Completed building includes:', {
|
|
1810
|
-
1810 includedCount: includedArray.length,
|
|
1811
|
-
1811 uniqueTypes: [...new Set(includedArray.map(r => r.type))]
|
|
1812
|
-
1812 })
|
|
1813
|
-
1813
|
|
1814
|
-
1814 return {
|
|
1815
|
-
1815 included: includedArray,
|
|
1816
|
-
1816 recordsWithRelationships: records
|
|
1817
|
-
1817 }
|
|
1818
|
-
1818 } catch (error) {
|
|
1819
|
-
1819 // Log comprehensive error information
|
|
1820
|
-
1820 log.error('[INCLUDE] Failed to build included resources:', {
|
|
1821
|
-
1821 scopeName,
|
|
1822
|
-
1822 includeParam,
|
|
1823
|
-
1823 recordCount: records?.length || 0,
|
|
1824
|
-
1824 error: error.message,
|
|
1825
|
-
1825 stack: error.stack
|
|
1826
|
-
1826 })
|
|
1827
|
-
1827
|
|
1828
|
-
1828 // Re-throw with additional context
|
|
1829
|
-
1829 const enhancedError = new Error(`Failed to build included resources for scope '${scopeName}': ${error.message}`)
|
|
1830
|
-
1830 enhancedError.originalError = error
|
|
1831
|
-
1831 enhancedError.context = {
|
|
1832
|
-
1832 scopeName,
|
|
1833
|
-
1833 includeParam,
|
|
1834
|
-
1834 recordCount: records?.length || 0
|
|
1835
|
-
1835 }
|
|
1836
|
-
1836 throw enhancedError
|
|
1837
|
-
1837 }
|
|
1838
|
-
1838 }
|
|
1839
|
-
1839
|
|
1840
|
-
1840 /**
|
|
1841
|
-
1841 * Loads relationship identifiers for all hasMany relationships without fetching full related records.
|
|
1842
|
-
1842 *
|
|
1843
|
-
1843 * ## Purpose
|
|
1844
|
-
1844 * This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
|
|
1845
|
-
1845 * always return resource identifiers in the JSON:API response, even when the related resources
|
|
1846
|
-
1846 * are not included via the ?include parameter.
|
|
1847
|
-
1847 *
|
|
1848
|
-
1848 * ## Why this is needed
|
|
1849
|
-
1849 * 1. **JSON:API Consistency**: The JSON:API spec allows servers to include relationship identifiers
|
|
1850
|
-
1850 * without including the full related resources. This provides a consistent API surface where
|
|
1851
|
-
1851 * clients always know what relationships exist and their IDs.
|
|
1852
|
-
1852 *
|
|
1853
|
-
1853 * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
|
|
1854
|
-
1854 * minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
|
|
1855
|
-
1855 * these objects only appear when using ?include, creating an inconsistent API where fields appear/disappear.
|
|
1856
|
-
1856 *
|
|
1857
|
-
1857 * 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
|
|
1858
|
-
1858 * clients the ability to know what relationships exist without the cost of fetching all data.
|
|
1859
|
-
1859 *
|
|
1860
|
-
1860 * ## What it does
|
|
1861
|
-
1861 * - Runs ONE query per relationship type (not per record) to fetch all related IDs
|
|
1862
|
-
1862 * - Populates the relationship data with resource identifiers: `{ type: 'resource', id: '123' }`
|
|
1863
|
-
1863 * - Handles all relationship types: one-to-many, many-to-many, and polymorphic
|
|
1864
|
-
1864 * - Works for both JSON:API and simplified modes (transformation happens elsewhere)
|
|
1865
|
-
1865 *
|
|
1866
|
-
1866 * ## When it runs
|
|
1867
|
-
1867 * This runs after the main records are fetched but before includes are processed.
|
|
1868
|
-
1868 * If includes ARE specified, they will overwrite these IDs with full data.
|
|
1869
|
-
1869 *
|
|
1870
|
-
1870 * @param {Array<Object>} records - The parent records to load relationships for
|
|
1871
|
-
1871 * @param {string} scopeName - The parent scope name (e.g., 'authors')
|
|
1872
|
-
1872 * @param {Object} scopes - All available scopes with their schemas
|
|
1873
|
-
1873 * @param {Object} knex - Knex instance for database queries
|
|
1874
|
-
1874 * @returns {Promise<void>} Modifies records in place by adding relationship data
|
|
1875
|
-
1875 */
|
|
1876
|
-
1876 export const loadRelationshipIdentifiers = async (records, scopeName, scopes, knex) => {
|
|
1877
|
-
1877 if (!records.length) return
|
|
1878
|
-
1878
|
|
1879
|
-
1879 const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
1880
|
-
1880 if (!schemaInfo) return
|
|
1881
|
-
1881
|
|
1882
|
-
1882 const relationships = schemaInfo.schemaRelationships || {}
|
|
1883
|
-
1883 const recordIds = records.map(r => r.id)
|
|
1884
|
-
1884
|
|
1885
|
-
1885 // Process each hasMany relationship
|
|
1886
|
-
1886 for (const [relName, relDef] of Object.entries(relationships)) {
|
|
1887
|
-
1887 const idsMap = {}
|
|
1888
|
-
1888
|
|
1889
|
-
1889 if (relDef.type === 'hasMany' && !relDef.via) {
|
|
1890
|
-
1890 // Regular one-to-many
|
|
1891
|
-
1891 // Example: publisher hasMany authors
|
|
1892
|
-
1892 const foreignKey = relDef.foreignKey
|
|
1893
|
-
1893
|
|
1894
|
-
1894 if (!foreignKey) {
|
|
1895
|
-
1895 throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
|
|
1896
|
-
1896 }
|
|
1897
|
-
1897 const targetScope = scopes[relDef.target]
|
|
1898
|
-
1898 const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target
|
|
1899
|
-
1899 const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id'
|
|
1900
|
-
1900
|
|
1901
|
-
1901 const results = await knex(targetTable)
|
|
1902
|
-
1902 .whereIn(foreignKey, recordIds)
|
|
1903
|
-
1903 .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', foreignKey)
|
|
1904
|
-
1904
|
|
1905
|
-
1905 results.forEach(row => {
|
|
1906
|
-
1906 const parentId = String(row[foreignKey])
|
|
1907
|
-
1907 if (!idsMap[parentId]) idsMap[parentId] = []
|
|
1908
|
-
1908 idsMap[parentId].push(String(row.id))
|
|
1909
|
-
1909 })
|
|
1910
|
-
1910 } else if (relDef.type === 'manyToMany') {
|
|
1911
|
-
1911 // Many-to-many relationship
|
|
1912
|
-
1912 // Example: { type: 'manyToMany', through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' }
|
|
1913
|
-
1913 const { through, foreignKey, otherKey } = relDef
|
|
1914
|
-
1914 const fk = foreignKey
|
|
1915
|
-
1915 const ok = otherKey
|
|
1916
|
-
1916
|
|
1917
|
-
1917 if (!fk || !ok) {
|
|
1918
|
-
1918 throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`)
|
|
1919
|
-
1919 }
|
|
1920
|
-
1920
|
|
1921
|
-
1921 // Get the actual table name from the pivot scope
|
|
1922
|
-
1922 const pivotScope = scopes[through]
|
|
1923
|
-
1923 const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through
|
|
1924
|
-
1924
|
|
1925
|
-
1925 const results = await knex(pivotTable)
|
|
1926
|
-
1926 .whereIn(fk, recordIds)
|
|
1927
|
-
1927 .select(fk, ok)
|
|
1928
|
-
1928
|
|
1929
|
-
1929 results.forEach(row => {
|
|
1930
|
-
1930 const parentId = String(row[fk])
|
|
1931
|
-
1931 const childId = String(row[ok])
|
|
1932
|
-
1932 if (!idsMap[parentId]) idsMap[parentId] = []
|
|
1933
|
-
1933 idsMap[parentId].push(childId)
|
|
1934
|
-
1934 })
|
|
1935
|
-
1935 } else if (relDef.type === 'hasMany' && relDef.via) {
|
|
1936
|
-
1936 // Polymorphic reverse (via)
|
|
1937
|
-
1937 // Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
|
|
1938
|
-
1938 const targetScope = relDef.target
|
|
1939
|
-
1939 const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships
|
|
1940
|
-
1940 const viaRel = targetRelationships?.[relDef.via]
|
|
1941
|
-
1941
|
|
1942
|
-
1942 if (viaRel?.belongsToPolymorphic) {
|
|
1943
|
-
1943 const { typeField, idField } = viaRel.belongsToPolymorphic
|
|
1944
|
-
1944
|
|
1945
|
-
1945 // Get the actual table name
|
|
1946
|
-
1946 const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope
|
|
1947
|
-
1947 const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
|
|
1948
|
-
1948
|
|
1949
|
-
1949 const results = await knex(targetTable)
|
|
1950
|
-
1950 .where(typeField, scopeName)
|
|
1951
|
-
1951 .whereIn(idField, recordIds)
|
|
1952
|
-
1952 .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', idField)
|
|
1953
|
-
1953
|
|
1954
|
-
1954 results.forEach(row => {
|
|
1955
|
-
1955 const parentId = String(row[idField])
|
|
1956
|
-
1956 if (!idsMap[parentId]) idsMap[parentId] = []
|
|
1957
|
-
1957 idsMap[parentId].push(String(row.id))
|
|
1958
|
-
1958 })
|
|
1959
|
-
1959 }
|
|
1960
|
-
1960 }
|
|
1961
|
-
1961
|
|
1962
|
-
1962 // Apply the collected IDs to all records
|
|
1963
|
-
1963 if (Object.keys(idsMap).length > 0 || relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
1964
|
-
1964 records.forEach(record => {
|
|
1965
|
-
1965 if (!record[RELATIONSHIPS_KEY]) {
|
|
1966
|
-
1966 record[RELATIONSHIPS_KEY] = {}
|
|
1967
|
-
1967 }
|
|
1968
|
-
1968
|
|
1969
|
-
1969 const ids = idsMap[String(record.id)] || []
|
|
1970
|
-
1970 // For manyToMany, the target type is the relationship name itself
|
|
1971
|
-
1971 // For hasMany/hasOne, the target type is specified in the target property
|
|
1972
|
-
1972 const targetType = (relDef.type === 'manyToMany') ? relName : relDef.target
|
|
1973
|
-
1973
|
|
1974
|
-
1974 record[RELATIONSHIPS_KEY][relName] = {
|
|
1975
|
-
1975 data: ids.map(id => ({
|
|
1976
|
-
1976 type: targetType,
|
|
1977
|
-
1977 id
|
|
1978
|
-
1978 }))
|
|
1979
|
-
1979 }
|
|
1980
|
-
1980 })
|
|
1981
|
-
1981 }
|
|
1982
|
-
1982 }
|
|
1983
|
-
1983 }
|