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