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