json-rest-api 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +99 -33
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js}
RENAMED
|
@@ -1,85 +1,144 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* @description JSON:API relationship inclusion helpers for REST API Knex Plugin
|
|
2
|
+
* Provides utilities for loading related resources according to JSON:API specification
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Many-to-one relationships (belongsTo)
|
|
10
|
-
* - One-to-many relationships (hasMany)
|
|
11
|
-
* - Polymorphic relationships (belongsToPolymorphic)
|
|
12
|
-
* - Reverse polymorphic relationships (hasMany with via)
|
|
13
|
-
* - Automatic deduplication of included resources
|
|
14
|
-
* - Efficient batch loading to prevent N+1 queries
|
|
4
|
+
* @description
|
|
5
|
+
* This module implements the JSON:API include functionality, enabling compound documents
|
|
6
|
+
* that contain both primary data and related resources. It handles all relationship types
|
|
7
|
+
* and supports arbitrary nesting depths while preventing N+1 query problems.
|
|
15
8
|
*
|
|
16
|
-
* @example
|
|
9
|
+
* @example
|
|
10
|
+
* // Input: Simple include request
|
|
17
11
|
* // GET /articles?include=author
|
|
18
|
-
* //
|
|
12
|
+
* // Records before:
|
|
13
|
+
* [
|
|
14
|
+
* { id: 1, title: 'Article 1', author_id: 10 },
|
|
15
|
+
* { id: 2, title: 'Article 2', author_id: 11 }
|
|
16
|
+
* ]
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
* //
|
|
22
|
-
*
|
|
18
|
+
* // Result: 2 queries total (articles + authors)
|
|
19
|
+
* // Records after (with RELATIONSHIPS_KEY):
|
|
20
|
+
* [
|
|
21
|
+
* {
|
|
22
|
+
* id: 1,
|
|
23
|
+
* title: 'Article 1',
|
|
24
|
+
* author_id: 10,
|
|
25
|
+
* __$jsonrestapi_rel$__: {
|
|
26
|
+
* author: { data: { type: 'users', id: '10' } }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* ]
|
|
30
|
+
* // Included resources:
|
|
31
|
+
* [
|
|
32
|
+
* { type: 'users', id: '10', attributes: { name: 'John' } },
|
|
33
|
+
* { type: 'users', id: '11', attributes: { name: 'Jane' } }
|
|
34
|
+
* ]
|
|
23
35
|
*
|
|
24
|
-
* @example
|
|
36
|
+
* @example
|
|
37
|
+
* // Input: Nested includes
|
|
25
38
|
* // GET /articles?include=comments.author
|
|
26
|
-
* //
|
|
39
|
+
* // Articles → Comments → Authors (3 queries total)
|
|
40
|
+
*
|
|
41
|
+
* // Result: Comments loaded with their authors
|
|
42
|
+
* // Included array contains both comments AND their authors
|
|
43
|
+
* // No duplicate authors even if multiple comments by same person
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Input: Polymorphic includes
|
|
47
|
+
* // GET /activities?include=trackable
|
|
48
|
+
* // Where trackable can be 'posts', 'photos', or 'videos'
|
|
27
49
|
*
|
|
28
|
-
*
|
|
29
|
-
* //
|
|
30
|
-
* //
|
|
50
|
+
* // Groups by type and loads each type separately:
|
|
51
|
+
* // Query 1: SELECT * FROM posts WHERE id IN (1, 3, 5)
|
|
52
|
+
* // Query 2: SELECT * FROM photos WHERE id IN (2, 4)
|
|
53
|
+
* // Query 3: SELECT * FROM videos WHERE id IN (6)
|
|
31
54
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
55
|
+
* Used by:
|
|
56
|
+
* - rest-api-knex-plugin calls buildIncludedResources in dataGet and dataQuery
|
|
57
|
+
* - Called after primary records are fetched but before response assembly
|
|
58
|
+
* - Results go into the 'included' section of JSON:API response
|
|
59
|
+
*
|
|
60
|
+
* Purpose:
|
|
61
|
+
* - Implements JSON:API compound documents with primary data and included resources
|
|
62
|
+
* - Prevents N+1 queries through batch loading (1 query per resource type)
|
|
63
|
+
* - Supports deep nesting like article.author.company.country.continent
|
|
64
|
+
* - Deduplicates resources automatically (each resource appears once)
|
|
65
|
+
* - Handles circular references through processedPaths tracking
|
|
66
|
+
* - Applies sparse fieldsets to reduce payload size
|
|
67
|
+
*
|
|
68
|
+
* Data flow:
|
|
69
|
+
* 1. Parse include parameter into tree structure (author,comments.author)
|
|
70
|
+
* 2. For each relationship, determine type (belongsTo, hasMany, polymorphic)
|
|
71
|
+
* 3. Batch load all related records of same type in single query
|
|
72
|
+
* 4. Recursively process nested includes
|
|
73
|
+
* 5. Store all included resources in deduplication Map
|
|
74
|
+
* 6. Return array of unique included resources
|
|
43
75
|
*/
|
|
44
76
|
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
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';
|
|
47
80
|
import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js';
|
|
48
|
-
import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, DEFAULT_QUERY_LIMIT } from '../
|
|
49
|
-
import { RestApiResourceError } from '
|
|
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';
|
|
50
83
|
|
|
51
84
|
/**
|
|
52
85
|
* Groups records by their polymorphic type for efficient batch loading
|
|
53
86
|
*
|
|
54
|
-
* Used when loading includes to minimize database queries. Instead of making
|
|
55
|
-
* N queries (one per record), this groups records by type so we can make
|
|
56
|
-
* one query per type.
|
|
57
|
-
*
|
|
58
87
|
* @param {Array<Object>} records - Records containing polymorphic fields
|
|
59
88
|
* @param {string} typeField - Name of the type field (e.g., 'commentable_type')
|
|
60
89
|
* @param {string} idField - Name of the ID field (e.g., 'commentable_id')
|
|
61
90
|
* @returns {Object<string, Array<number|string>>} Map of type to array of unique IDs
|
|
62
91
|
*
|
|
63
|
-
* @example
|
|
92
|
+
* @example
|
|
93
|
+
* // Input: Comments with polymorphic commentable relationship
|
|
64
94
|
* const records = [
|
|
65
95
|
* { id: 1, commentable_type: 'articles', commentable_id: 10 },
|
|
66
96
|
* { id: 2, commentable_type: 'videos', commentable_id: 20 },
|
|
67
|
-
* { id: 3, commentable_type: 'articles', commentable_id: 11 }
|
|
97
|
+
* { id: 3, commentable_type: 'articles', commentable_id: 11 },
|
|
98
|
+
* { id: 4, commentable_type: 'articles', commentable_id: 10 } // Duplicate
|
|
68
99
|
* ];
|
|
69
100
|
*
|
|
70
101
|
* const grouped = groupByPolymorphicType(records, 'commentable_type', 'commentable_id');
|
|
71
|
-
* // Returns: { articles: [10, 11], videos: [20] }
|
|
72
102
|
*
|
|
73
|
-
*
|
|
103
|
+
* // Output: IDs grouped by type, duplicates removed
|
|
104
|
+
* // {
|
|
105
|
+
* // articles: [10, 11], // Only unique IDs
|
|
106
|
+
* // videos: [20]
|
|
107
|
+
* // }
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* // Input: Some records have null relationships
|
|
74
111
|
* const records = [
|
|
75
112
|
* { id: 1, item_type: 'products', item_id: 1 },
|
|
76
|
-
* { id: 2, item_type: null, item_id: null },
|
|
77
|
-
* { id: 3, item_type: 'products', item_id:
|
|
113
|
+
* { id: 2, item_type: null, item_id: null }, // Ignored
|
|
114
|
+
* { id: 3, item_type: 'products', item_id: null }, // Ignored
|
|
115
|
+
* { id: 4, item_type: 'categories', item_id: 5 }
|
|
78
116
|
* ];
|
|
79
117
|
*
|
|
80
118
|
* const grouped = groupByPolymorphicType(records, 'item_type', 'item_id');
|
|
81
|
-
*
|
|
82
|
-
* //
|
|
119
|
+
*
|
|
120
|
+
* // Output: Only records with both type AND id
|
|
121
|
+
* // {
|
|
122
|
+
* // products: [1],
|
|
123
|
+
* // categories: [5]
|
|
124
|
+
* // }
|
|
125
|
+
*
|
|
126
|
+
* @description
|
|
127
|
+
* Used by:
|
|
128
|
+
* - loadPolymorphicBelongsTo uses this to group records before batch loading
|
|
129
|
+
* - Enables efficient loading with one query per type instead of per record
|
|
130
|
+
*
|
|
131
|
+
* Purpose:
|
|
132
|
+
* - Minimizes database queries when loading polymorphic relationships
|
|
133
|
+
* - Instead of N queries (one per record), makes T queries (one per type)
|
|
134
|
+
* - Automatically deduplicates IDs within each type
|
|
135
|
+
* - Safely handles null/undefined values by filtering them out
|
|
136
|
+
*
|
|
137
|
+
* Data flow:
|
|
138
|
+
* 1. Receives array of records with polymorphic fields
|
|
139
|
+
* 2. Groups records by their type field value
|
|
140
|
+
* 3. Collects unique IDs for each type
|
|
141
|
+
* 4. Returns map used for batch WHERE IN queries
|
|
83
142
|
*/
|
|
84
143
|
export const groupByPolymorphicType = (records, typeField, idField) => {
|
|
85
144
|
const grouped = {};
|
|
@@ -107,27 +166,63 @@ export const groupByPolymorphicType = (records, typeField, idField) => {
|
|
|
107
166
|
/**
|
|
108
167
|
* Parses the include parameter string into a tree structure
|
|
109
168
|
*
|
|
110
|
-
*
|
|
111
|
-
* object structure that makes it easier to process relationships hierarchically.
|
|
112
|
-
*
|
|
113
|
-
* @param {string} includeParam - The include parameter value (e.g., "author,comments.author,comments.article")
|
|
169
|
+
* @param {string|Array<string>} includeParam - The include parameter value
|
|
114
170
|
* @returns {Object} Nested object representing the include tree
|
|
115
171
|
*
|
|
116
|
-
* @example
|
|
117
|
-
*
|
|
118
|
-
*
|
|
172
|
+
* @example
|
|
173
|
+
* // Input: Simple comma-separated includes
|
|
174
|
+
* const tree = parseIncludeTree('author,comments');
|
|
119
175
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* //
|
|
176
|
+
* // Output: Flat structure
|
|
177
|
+
* // {
|
|
178
|
+
* // author: {},
|
|
179
|
+
* // comments: {}
|
|
180
|
+
* // }
|
|
123
181
|
*
|
|
124
|
-
* @example
|
|
125
|
-
*
|
|
126
|
-
*
|
|
182
|
+
* @example
|
|
183
|
+
* // Input: Nested includes with dot notation
|
|
184
|
+
* const tree = parseIncludeTree('comments.author,comments.replies');
|
|
185
|
+
*
|
|
186
|
+
* // Output: Nested structure
|
|
187
|
+
* // {
|
|
188
|
+
* // comments: {
|
|
189
|
+
* // author: {},
|
|
190
|
+
* // replies: {}
|
|
191
|
+
* // }
|
|
192
|
+
* // }
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* // Input: Complex mixed-depth includes
|
|
196
|
+
* const tree = parseIncludeTree('author,comments.author,comments.replies.author,tags');
|
|
197
|
+
*
|
|
198
|
+
* // Output: Multi-level nesting
|
|
199
|
+
* // {
|
|
127
200
|
* // author: {},
|
|
128
|
-
* // comments: {
|
|
129
|
-
* //
|
|
201
|
+
* // comments: {
|
|
202
|
+
* // author: {},
|
|
203
|
+
* // replies: {
|
|
204
|
+
* // author: {}
|
|
205
|
+
* // }
|
|
206
|
+
* // },
|
|
207
|
+
* // tags: {}
|
|
130
208
|
* // }
|
|
209
|
+
*
|
|
210
|
+
* @description
|
|
211
|
+
* Used by:
|
|
212
|
+
* - buildIncludedResources parses the include parameter before processing
|
|
213
|
+
* - processIncludes traverses this tree structure recursively
|
|
214
|
+
*
|
|
215
|
+
* Purpose:
|
|
216
|
+
* - Converts flat string format into hierarchical structure for processing
|
|
217
|
+
* - Enables recursive traversal of relationship includes
|
|
218
|
+
* - Handles both string and pre-split array inputs
|
|
219
|
+
* - Empty values are filtered out automatically
|
|
220
|
+
*
|
|
221
|
+
* Data flow:
|
|
222
|
+
* 1. Split comma-separated string into individual paths
|
|
223
|
+
* 2. For each path, split by dots to get nesting levels
|
|
224
|
+
* 3. Build nested object structure following the path
|
|
225
|
+
* 4. Return tree for recursive processing
|
|
131
226
|
*/
|
|
132
227
|
export const parseIncludeTree = (includeParam) => {
|
|
133
228
|
if (!includeParam) return {};
|
|
@@ -197,22 +292,18 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
|
|
|
197
292
|
if (relDef.hasMany) {
|
|
198
293
|
// Add empty relationship data - will be populated if explicitly included
|
|
199
294
|
record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// Process belongsToPolymorphic relationships from schema
|
|
204
|
-
for (const [fieldName, fieldDef] of Object.entries(schema)) {
|
|
205
|
-
if (fieldDef.belongsToPolymorphic && fieldDef.as) {
|
|
206
|
-
const { typeField, idField } = fieldDef.belongsToPolymorphic;
|
|
295
|
+
} else if (relDef.belongsToPolymorphic) {
|
|
296
|
+
// Process polymorphic relationships defined in relationships section
|
|
297
|
+
const { typeField, idField } = relDef.belongsToPolymorphic;
|
|
207
298
|
const type = record[typeField];
|
|
208
299
|
const id = record[idField];
|
|
209
300
|
|
|
210
301
|
if (type && id) {
|
|
211
|
-
record[RELATIONSHIP_METADATA_KEY][
|
|
302
|
+
record[RELATIONSHIP_METADATA_KEY][relName] = {
|
|
212
303
|
data: { type, id: String(id) }
|
|
213
304
|
};
|
|
214
305
|
} else {
|
|
215
|
-
record[RELATIONSHIP_METADATA_KEY][
|
|
306
|
+
record[RELATIONSHIP_METADATA_KEY][relName] = { data: null };
|
|
216
307
|
}
|
|
217
308
|
}
|
|
218
309
|
}
|
|
@@ -288,8 +379,8 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
288
379
|
const relationshipObject = { data: null };
|
|
289
380
|
|
|
290
381
|
// Add links if urlPrefix is configured
|
|
291
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
292
|
-
if (
|
|
382
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
383
|
+
if (scopeName) {
|
|
293
384
|
relationshipObject.links = {
|
|
294
385
|
self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
295
386
|
related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
@@ -322,6 +413,9 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
322
413
|
let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds);
|
|
323
414
|
if (fieldSelectionInfo) {
|
|
324
415
|
query = query.select(fieldSelectionInfo.fieldsToSelect);
|
|
416
|
+
} else if (targetIdProperty !== 'id') {
|
|
417
|
+
// If no field selection but custom idProperty, we need to alias it
|
|
418
|
+
query = query.select('*', `${targetIdProperty} as id`);
|
|
325
419
|
}
|
|
326
420
|
const targetRecords = await query;
|
|
327
421
|
|
|
@@ -331,7 +425,7 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
331
425
|
// Create lookup map
|
|
332
426
|
const targetById = {};
|
|
333
427
|
targetRecords.forEach(record => {
|
|
334
|
-
targetById[record[targetIdProperty]] = record;
|
|
428
|
+
targetById[record.id || record[targetIdProperty]] = record;
|
|
335
429
|
});
|
|
336
430
|
|
|
337
431
|
// Set relationships on original records
|
|
@@ -345,10 +439,10 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
345
439
|
|
|
346
440
|
if (targetRecord) {
|
|
347
441
|
// Convert to JSON:API format
|
|
348
|
-
const jsonApiRecord =
|
|
442
|
+
const jsonApiRecord = toJsonApiRecord(
|
|
349
443
|
scopes[targetScope],
|
|
350
444
|
targetRecord,
|
|
351
|
-
|
|
445
|
+
targetScope
|
|
352
446
|
);
|
|
353
447
|
|
|
354
448
|
// Add relationships from metadata
|
|
@@ -357,12 +451,17 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
357
451
|
// Clean up the temporary property
|
|
358
452
|
delete targetRecord[RELATIONSHIP_METADATA_KEY];
|
|
359
453
|
}
|
|
454
|
+
|
|
455
|
+
// Attach computed dependencies info if sparse fieldsets were used
|
|
456
|
+
if (fieldSelectionInfo?.computedDependencies) {
|
|
457
|
+
jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
|
|
458
|
+
}
|
|
360
459
|
|
|
361
460
|
const relationshipObject = { data: { type: targetScope, id: String(targetId) } };
|
|
362
461
|
|
|
363
462
|
// Add links if urlPrefix is configured
|
|
364
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
365
|
-
if (
|
|
463
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
464
|
+
if (scopeName) {
|
|
366
465
|
relationshipObject.links = {
|
|
367
466
|
self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
368
467
|
related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
@@ -385,8 +484,8 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
385
484
|
const relationshipObject = { data: null };
|
|
386
485
|
|
|
387
486
|
// Add links if urlPrefix is configured
|
|
388
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
389
|
-
if (
|
|
487
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
488
|
+
if (scopeName) {
|
|
390
489
|
relationshipObject.links = {
|
|
391
490
|
self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
|
|
392
491
|
related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
|
|
@@ -476,8 +575,12 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
476
575
|
|
|
477
576
|
// Handle many-to-many relationship
|
|
478
577
|
const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName
|
|
479
|
-
const foreignKey = relDef.foreignKey
|
|
480
|
-
const otherKey = relDef.otherKey
|
|
578
|
+
const foreignKey = relDef.foreignKey;
|
|
579
|
+
const otherKey = relDef.otherKey;
|
|
580
|
+
|
|
581
|
+
if (!foreignKey || !otherKey) {
|
|
582
|
+
throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`);
|
|
583
|
+
}
|
|
481
584
|
const targetScope = relDef.hasMany;
|
|
482
585
|
const targetTable = scopes[targetScope].vars.schemaInfo.tableName
|
|
483
586
|
|
|
@@ -499,8 +602,8 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
499
602
|
const relationshipObject = { data: [] };
|
|
500
603
|
|
|
501
604
|
// Add links if urlPrefix is configured
|
|
502
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
503
|
-
if (
|
|
605
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
606
|
+
if (scopeName) {
|
|
504
607
|
relationshipObject.links = {
|
|
505
608
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
506
609
|
related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
@@ -556,18 +659,25 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
556
659
|
});
|
|
557
660
|
}
|
|
558
661
|
|
|
662
|
+
const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
|
|
559
663
|
const windowQuery = knex
|
|
560
|
-
.select(`${targetTable}.*`)
|
|
561
|
-
|
|
664
|
+
.select(`${targetTable}.*`);
|
|
665
|
+
|
|
666
|
+
// Add id alias if needed
|
|
667
|
+
if (targetIdProperty !== 'id') {
|
|
668
|
+
windowQuery.select(`${targetTable}.${targetIdProperty} as id`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
windowQuery.select(
|
|
562
672
|
knex.raw(
|
|
563
673
|
'ROW_NUMBER() OVER (PARTITION BY pivot.?? ORDER BY ' +
|
|
564
|
-
buildOrderByClause(relDef.include?.orderBy || [
|
|
674
|
+
buildOrderByClause(relDef.include?.orderBy || [targetIdProperty], targetTable) +
|
|
565
675
|
') as ' + ROW_NUMBER_KEY,
|
|
566
676
|
[foreignKey]
|
|
567
677
|
)
|
|
568
678
|
)
|
|
569
679
|
.from(targetTable)
|
|
570
|
-
.join(`${pivotTable} as pivot`, `${targetTable}
|
|
680
|
+
.join(`${pivotTable} as pivot`, `${targetTable}.${targetIdProperty}`, 'pivot.' + otherKey)
|
|
571
681
|
.whereIn(`pivot.${foreignKey}`, mainIds);
|
|
572
682
|
|
|
573
683
|
|
|
@@ -584,10 +694,14 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
584
694
|
|
|
585
695
|
} else {
|
|
586
696
|
// Standard query without per-parent limits
|
|
587
|
-
|
|
697
|
+
const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
|
|
698
|
+
let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
|
|
588
699
|
|
|
589
700
|
if (fieldSelectionInfo) {
|
|
590
701
|
query = query.select(fieldSelectionInfo.fieldsToSelect);
|
|
702
|
+
} else if (targetIdProperty !== 'id') {
|
|
703
|
+
// If no field selection but custom idProperty, we need to alias it
|
|
704
|
+
query = query.select('*', `${targetIdProperty} as id`);
|
|
591
705
|
}
|
|
592
706
|
|
|
593
707
|
// Apply standard include config (global limits)
|
|
@@ -634,10 +748,10 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
634
748
|
// Add to included
|
|
635
749
|
const resourceKey = `${targetScope}:${childRecord.id}`;
|
|
636
750
|
if (!included.has(resourceKey)) {
|
|
637
|
-
const jsonApiRecord =
|
|
751
|
+
const jsonApiRecord = toJsonApiRecord(
|
|
638
752
|
scopes[targetScope],
|
|
639
753
|
childRecord,
|
|
640
|
-
|
|
754
|
+
targetScope
|
|
641
755
|
);
|
|
642
756
|
|
|
643
757
|
|
|
@@ -648,6 +762,11 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
648
762
|
delete childRecord[RELATIONSHIP_METADATA_KEY];
|
|
649
763
|
}
|
|
650
764
|
|
|
765
|
+
// Attach computed dependencies info if sparse fieldsets were used
|
|
766
|
+
if (fieldSelectionInfo?.computedDependencies) {
|
|
767
|
+
jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
|
|
768
|
+
}
|
|
769
|
+
|
|
651
770
|
included.set(resourceKey, jsonApiRecord);
|
|
652
771
|
}
|
|
653
772
|
return { type: targetScope, id: String(childRecord.id) };
|
|
@@ -656,8 +775,8 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
656
775
|
const relationshipObject = { data: relData };
|
|
657
776
|
|
|
658
777
|
// Add links if urlPrefix is configured
|
|
659
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
660
|
-
if (
|
|
778
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
779
|
+
if (scopeName) {
|
|
661
780
|
relationshipObject.links = {
|
|
662
781
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
663
782
|
related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
@@ -692,7 +811,11 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
692
811
|
// Handle regular one-to-many relationship
|
|
693
812
|
const targetScope = relDef.hasMany;
|
|
694
813
|
const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
|
|
695
|
-
const foreignKey = relDef.foreignKey
|
|
814
|
+
const foreignKey = relDef.foreignKey;
|
|
815
|
+
|
|
816
|
+
if (!foreignKey) {
|
|
817
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
|
|
818
|
+
}
|
|
696
819
|
|
|
697
820
|
log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
|
|
698
821
|
whereIn: mainIds,
|
|
@@ -799,10 +922,10 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
799
922
|
// Add to included
|
|
800
923
|
const resourceKey = `${targetScope}:${childRecord.id}`;
|
|
801
924
|
if (!included.has(resourceKey)) {
|
|
802
|
-
const jsonApiRecord =
|
|
925
|
+
const jsonApiRecord = toJsonApiRecord(
|
|
803
926
|
scopes[targetScope],
|
|
804
927
|
childRecord,
|
|
805
|
-
|
|
928
|
+
targetScope
|
|
806
929
|
);
|
|
807
930
|
|
|
808
931
|
// Add relationships from metadata
|
|
@@ -812,6 +935,11 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
812
935
|
delete childRecord[RELATIONSHIP_METADATA_KEY];
|
|
813
936
|
}
|
|
814
937
|
|
|
938
|
+
// Attach computed dependencies info if sparse fieldsets were used
|
|
939
|
+
if (fieldSelectionInfo?.computedDependencies) {
|
|
940
|
+
jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
|
|
941
|
+
}
|
|
942
|
+
|
|
815
943
|
included.set(resourceKey, jsonApiRecord);
|
|
816
944
|
}
|
|
817
945
|
return { type: targetScope, id: String(childRecord.id) };
|
|
@@ -820,8 +948,8 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
820
948
|
const relationshipObject = { data: relData };
|
|
821
949
|
|
|
822
950
|
// Add links if urlPrefix is configured
|
|
823
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
824
|
-
if (
|
|
951
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
952
|
+
if (scopeName) {
|
|
825
953
|
relationshipObject.links = {
|
|
826
954
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
827
955
|
related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
@@ -933,7 +1061,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
933
1061
|
|
|
934
1062
|
// Build field selection for sparse fieldsets
|
|
935
1063
|
const targetScopeObject = scopes[targetType];
|
|
936
|
-
const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
|
|
1064
|
+
// const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
|
|
937
1065
|
const fieldSelectionInfo = fields?.[targetType] ?
|
|
938
1066
|
await buildFieldSelection(targetScopeObject, {
|
|
939
1067
|
context: {
|
|
@@ -950,9 +1078,13 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
950
1078
|
});
|
|
951
1079
|
|
|
952
1080
|
// Query for this type
|
|
953
|
-
|
|
1081
|
+
const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id';
|
|
1082
|
+
let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
|
|
954
1083
|
if (fieldSelectionInfo) {
|
|
955
1084
|
query = query.select(fieldSelectionInfo.fieldsToSelect);
|
|
1085
|
+
} else if (targetIdProperty !== 'id') {
|
|
1086
|
+
// If no field selection but custom idProperty, we need to alias it
|
|
1087
|
+
query = query.select('*', `${targetIdProperty} as id`);
|
|
956
1088
|
}
|
|
957
1089
|
const targetRecords = await query;
|
|
958
1090
|
|
|
@@ -977,10 +1109,10 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
977
1109
|
// Add to included
|
|
978
1110
|
const resourceKey = `${targetType}:${targetId}`;
|
|
979
1111
|
if (!included.has(resourceKey)) {
|
|
980
|
-
const jsonApiRecord =
|
|
1112
|
+
const jsonApiRecord = toJsonApiRecord(
|
|
981
1113
|
scopes[targetType],
|
|
982
1114
|
targetRecord,
|
|
983
|
-
|
|
1115
|
+
targetType
|
|
984
1116
|
);
|
|
985
1117
|
|
|
986
1118
|
// Add relationships from metadata
|
|
@@ -990,6 +1122,11 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
990
1122
|
delete targetRecord[RELATIONSHIP_METADATA_KEY];
|
|
991
1123
|
}
|
|
992
1124
|
|
|
1125
|
+
// Attach computed dependencies info if sparse fieldsets were used
|
|
1126
|
+
if (fieldSelectionInfo?.computedDependencies) {
|
|
1127
|
+
jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
993
1130
|
included.set(resourceKey, jsonApiRecord);
|
|
994
1131
|
}
|
|
995
1132
|
|
|
@@ -998,8 +1135,8 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
998
1135
|
};
|
|
999
1136
|
|
|
1000
1137
|
// Add links if urlPrefix is configured
|
|
1001
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
1002
|
-
if (
|
|
1138
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
1139
|
+
if (scopeName) {
|
|
1003
1140
|
relationshipObject.links = {
|
|
1004
1141
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1005
1142
|
related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
@@ -1011,8 +1148,8 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
1011
1148
|
const relationshipObject = { data: null };
|
|
1012
1149
|
|
|
1013
1150
|
// Add links if urlPrefix is configured
|
|
1014
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
1015
|
-
if (
|
|
1151
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
1152
|
+
if (scopeName) {
|
|
1016
1153
|
relationshipObject.links = {
|
|
1017
1154
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1018
1155
|
related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
@@ -1043,8 +1180,8 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
1043
1180
|
const relationshipObject = { data: null };
|
|
1044
1181
|
|
|
1045
1182
|
// Add links if urlPrefix is configured
|
|
1046
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
1047
|
-
if (
|
|
1183
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
1184
|
+
if (scopeName) {
|
|
1048
1185
|
relationshipObject.links = {
|
|
1049
1186
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
|
|
1050
1187
|
related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
|
|
@@ -1154,6 +1291,9 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1154
1291
|
|
|
1155
1292
|
if (fieldSelectionInfo) {
|
|
1156
1293
|
query = query.select(fieldSelectionInfo.fieldsToSelect);
|
|
1294
|
+
} else if (targetIdProperty !== 'id') {
|
|
1295
|
+
// If no field selection but custom idProperty, we need to alias it
|
|
1296
|
+
query = query.select('*', `${targetIdProperty} as id`);
|
|
1157
1297
|
}
|
|
1158
1298
|
|
|
1159
1299
|
// Apply include configuration (limits, ordering, etc.)
|
|
@@ -1193,10 +1333,10 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1193
1333
|
// Add to included
|
|
1194
1334
|
const resourceKey = `${targetScope}:${childRecord.id}`;
|
|
1195
1335
|
if (!included.has(resourceKey)) {
|
|
1196
|
-
const jsonApiRecord =
|
|
1336
|
+
const jsonApiRecord = toJsonApiRecord(
|
|
1197
1337
|
scopes[targetScope],
|
|
1198
1338
|
childRecord,
|
|
1199
|
-
|
|
1339
|
+
targetScope
|
|
1200
1340
|
);
|
|
1201
1341
|
|
|
1202
1342
|
// Add relationships from metadata
|
|
@@ -1206,6 +1346,11 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1206
1346
|
delete childRecord[RELATIONSHIP_METADATA_KEY];
|
|
1207
1347
|
}
|
|
1208
1348
|
|
|
1349
|
+
// Attach computed dependencies info if sparse fieldsets were used
|
|
1350
|
+
if (fieldSelectionInfo?.computedDependencies) {
|
|
1351
|
+
jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1209
1354
|
included.set(resourceKey, jsonApiRecord);
|
|
1210
1355
|
}
|
|
1211
1356
|
return { type: targetScope, id: String(childRecord.id) };
|
|
@@ -1214,8 +1359,8 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1214
1359
|
const relationshipObject = { data: relData };
|
|
1215
1360
|
|
|
1216
1361
|
// Add links if urlPrefix is configured
|
|
1217
|
-
const urlPrefix = scopes[scopeName]?.vars?.
|
|
1218
|
-
if (
|
|
1362
|
+
const urlPrefix = scopes[scopeName]?.vars?.publicBaseUrl || scopes[scopeName]?.vars?.mountPath || '';
|
|
1363
|
+
if (scopeName) {
|
|
1219
1364
|
relationshipObject.links = {
|
|
1220
1365
|
self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
|
|
1221
1366
|
related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
|
|
@@ -1499,4 +1644,175 @@ export const buildIncludedResources = async (scope, deps) => {
|
|
|
1499
1644
|
};
|
|
1500
1645
|
throw enhancedError;
|
|
1501
1646
|
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Loads relationship identifiers for all hasMany relationships without fetching full related records.
|
|
1651
|
+
*
|
|
1652
|
+
* ## Purpose
|
|
1653
|
+
* This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
|
|
1654
|
+
* always return resource identifiers in the JSON:API response, even when the related resources
|
|
1655
|
+
* are not included via the ?include parameter.
|
|
1656
|
+
*
|
|
1657
|
+
* ## Why this is needed
|
|
1658
|
+
* 1. **JSON:API Consistency**: The JSON:API spec allows servers to include relationship identifiers
|
|
1659
|
+
* without including the full related resources. This provides a consistent API surface where
|
|
1660
|
+
* clients always know what relationships exist and their IDs.
|
|
1661
|
+
*
|
|
1662
|
+
* 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into `_ids`
|
|
1663
|
+
* arrays (e.g., `reviews_ids: ['1', '2', '3']`). Without this function, these arrays only
|
|
1664
|
+
* appear when using ?include, creating an inconsistent API where fields appear/disappear.
|
|
1665
|
+
*
|
|
1666
|
+
* 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
|
|
1667
|
+
* clients the ability to know what relationships exist without the cost of fetching all data.
|
|
1668
|
+
*
|
|
1669
|
+
* ## What it does
|
|
1670
|
+
* - Runs ONE query per relationship type (not per record) to fetch all related IDs
|
|
1671
|
+
* - Populates the relationship data with resource identifiers: `{ type: 'resource', id: '123' }`
|
|
1672
|
+
* - Handles all relationship types: one-to-many, many-to-many, and polymorphic
|
|
1673
|
+
* - Works for both JSON:API and simplified modes (transformation happens elsewhere)
|
|
1674
|
+
*
|
|
1675
|
+
* ## When it runs
|
|
1676
|
+
* This runs after the main records are fetched but before includes are processed.
|
|
1677
|
+
* If includes ARE specified, they will overwrite these IDs with full data.
|
|
1678
|
+
*
|
|
1679
|
+
* @param {Array<Object>} records - The parent records to load relationships for
|
|
1680
|
+
* @param {string} scopeName - The parent scope name (e.g., 'authors')
|
|
1681
|
+
* @param {Object} scopes - All available scopes with their schemas
|
|
1682
|
+
* @param {Object} knex - Knex instance for database queries
|
|
1683
|
+
* @returns {Promise<void>} Modifies records in place by adding relationship data
|
|
1684
|
+
*/
|
|
1685
|
+
export const loadRelationshipIdentifiers = async (records, scopeName, scopes, knex) => {
|
|
1686
|
+
if (!records.length) return;
|
|
1687
|
+
|
|
1688
|
+
const schemaInfo = scopes[scopeName]?.vars?.schemaInfo;
|
|
1689
|
+
if (!schemaInfo) return;
|
|
1690
|
+
|
|
1691
|
+
const relationships = schemaInfo.schemaRelationships || {};
|
|
1692
|
+
const recordIds = records.map(r => r.id);
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
// Process each hasMany relationship
|
|
1696
|
+
for (const [relName, relDef] of Object.entries(relationships)) {
|
|
1697
|
+
let idsMap = {};
|
|
1698
|
+
|
|
1699
|
+
if (relDef.hasMany && !relDef.through && !relDef.via) {
|
|
1700
|
+
// Regular one-to-many
|
|
1701
|
+
// Example: publisher hasMany authors
|
|
1702
|
+
const foreignKey = relDef.foreignKey;
|
|
1703
|
+
|
|
1704
|
+
if (!foreignKey) {
|
|
1705
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
|
|
1706
|
+
}
|
|
1707
|
+
const targetScope = scopes[relDef.hasMany];
|
|
1708
|
+
const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.hasMany;
|
|
1709
|
+
const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id';
|
|
1710
|
+
|
|
1711
|
+
const results = await knex(targetTable)
|
|
1712
|
+
.whereIn(foreignKey, recordIds)
|
|
1713
|
+
.select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', foreignKey);
|
|
1714
|
+
|
|
1715
|
+
results.forEach(row => {
|
|
1716
|
+
const parentId = String(row[foreignKey]);
|
|
1717
|
+
if (!idsMap[parentId]) idsMap[parentId] = [];
|
|
1718
|
+
idsMap[parentId].push(String(row.id));
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
} else if (relDef.hasMany && relDef.through) {
|
|
1722
|
+
// Many-to-many with through
|
|
1723
|
+
// Example: authors hasMany books through book_authors
|
|
1724
|
+
const foreignKey = relDef.foreignKey;
|
|
1725
|
+
|
|
1726
|
+
if (!foreignKey) {
|
|
1727
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
|
|
1728
|
+
}
|
|
1729
|
+
const otherKey = relDef.otherKey || `${relDef.hasMany.slice(0, -1)}_id`;
|
|
1730
|
+
|
|
1731
|
+
// Get the actual table name from the pivot scope
|
|
1732
|
+
const pivotScope = scopes[relDef.through];
|
|
1733
|
+
const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || relDef.through;
|
|
1734
|
+
|
|
1735
|
+
const results = await knex(pivotTable)
|
|
1736
|
+
.whereIn(foreignKey, recordIds)
|
|
1737
|
+
.select(foreignKey, otherKey);
|
|
1738
|
+
|
|
1739
|
+
results.forEach(row => {
|
|
1740
|
+
const parentId = String(row[foreignKey]);
|
|
1741
|
+
const childId = String(row[otherKey]);
|
|
1742
|
+
if (!idsMap[parentId]) idsMap[parentId] = [];
|
|
1743
|
+
idsMap[parentId].push(childId);
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
} else if (relDef.manyToMany) {
|
|
1747
|
+
// Many-to-many (alternate syntax)
|
|
1748
|
+
// Example: { manyToMany: { through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' } }
|
|
1749
|
+
const { through, foreignKey, otherKey } = relDef.manyToMany;
|
|
1750
|
+
const fk = foreignKey;
|
|
1751
|
+
const ok = otherKey;
|
|
1752
|
+
|
|
1753
|
+
if (!fk || !ok) {
|
|
1754
|
+
throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// Get the actual table name from the pivot scope
|
|
1758
|
+
const pivotScope = scopes[through];
|
|
1759
|
+
const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through;
|
|
1760
|
+
|
|
1761
|
+
const results = await knex(pivotTable)
|
|
1762
|
+
.whereIn(fk, recordIds)
|
|
1763
|
+
.select(fk, ok);
|
|
1764
|
+
|
|
1765
|
+
results.forEach(row => {
|
|
1766
|
+
const parentId = String(row[fk]);
|
|
1767
|
+
const childId = String(row[ok]);
|
|
1768
|
+
if (!idsMap[parentId]) idsMap[parentId] = [];
|
|
1769
|
+
idsMap[parentId].push(childId);
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
} else if (relDef.hasMany && relDef.via) {
|
|
1773
|
+
// Polymorphic reverse (via)
|
|
1774
|
+
// Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
|
|
1775
|
+
const targetScope = relDef.hasMany;
|
|
1776
|
+
const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships;
|
|
1777
|
+
const viaRel = targetRelationships?.[relDef.via];
|
|
1778
|
+
|
|
1779
|
+
if (viaRel?.belongsToPolymorphic) {
|
|
1780
|
+
const { typeField, idField } = viaRel.belongsToPolymorphic;
|
|
1781
|
+
|
|
1782
|
+
// Get the actual table name
|
|
1783
|
+
const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope;
|
|
1784
|
+
const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
|
|
1785
|
+
|
|
1786
|
+
const results = await knex(targetTable)
|
|
1787
|
+
.where(typeField, scopeName)
|
|
1788
|
+
.whereIn(idField, recordIds)
|
|
1789
|
+
.select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', idField);
|
|
1790
|
+
|
|
1791
|
+
results.forEach(row => {
|
|
1792
|
+
const parentId = String(row[idField]);
|
|
1793
|
+
if (!idsMap[parentId]) idsMap[parentId] = [];
|
|
1794
|
+
idsMap[parentId].push(String(row.id));
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Apply the collected IDs to all records
|
|
1800
|
+
if (Object.keys(idsMap).length > 0 || relDef.hasMany || relDef.manyToMany) {
|
|
1801
|
+
records.forEach(record => {
|
|
1802
|
+
if (!record[RELATIONSHIPS_KEY]) {
|
|
1803
|
+
record[RELATIONSHIPS_KEY] = {};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const ids = idsMap[String(record.id)] || [];
|
|
1807
|
+
const targetType = relDef.hasMany || (relDef.manyToMany ? relName : null);
|
|
1808
|
+
|
|
1809
|
+
record[RELATIONSHIPS_KEY][relName] = {
|
|
1810
|
+
data: ids.map(id => ({
|
|
1811
|
+
type: targetType,
|
|
1812
|
+
id
|
|
1813
|
+
}))
|
|
1814
|
+
};
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1502
1818
|
};
|