json-rest-api 1.0.7 → 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.
Files changed (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +90 -32
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -1,85 +1,144 @@
1
1
  /**
2
- * @module relationship-includes
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
- * This module provides utilities for loading related resources according to the JSON:API
6
- * specification. It supports:
7
- * - Single-level includes (e.g., "author")
8
- * - Nested includes (e.g., "comments.author")
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 <caption>Basic single-level include</caption>
9
+ * @example
10
+ * // Input: Simple include request
17
11
  * // GET /articles?include=author
18
- * // Loads all articles and their authors in 2 queries total
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
- * @example <caption>Multiple includes</caption>
21
- * // GET /articles?include=author,comments
22
- * // Loads articles, all authors, and all comments in 3 queries total
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 <caption>Nested includes</caption>
36
+ * @example
37
+ * // Input: Nested includes
25
38
  * // GET /articles?include=comments.author
26
- * // Loads articles, their comments, and comment authors in 3 queries total
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
- * @example <caption>Complex nested includes</caption>
29
- * // GET /articles?include=author,comments.author,comments.article
30
- * // Handles circular references automatically through deduplication
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
- * @example <caption>Why this is useful upstream</caption>
33
- * // The REST API Knex plugin uses this module to:
34
- * // 1. Implement JSON:API compound documents (main data + included resources)
35
- * // 2. Prevent N+1 query problems through batch loading strategies
36
- * // 3. Support arbitrarily deep relationship nesting (e.g., article.author.company.country)
37
- * // 4. Handle polymorphic relationships that can point to multiple types
38
- * // 5. Deduplicate included resources automatically (no duplicate authors if multiple articles have same author)
39
- * // 6. Apply sparse fieldsets to included resources for bandwidth optimization
40
- * // 7. Maintain referential integrity by only loading existing relationships
41
- * // 8. Use mutual recursion for elegant handling of complex include trees
42
- * // 9. Support both forward (belongsTo/hasMany) and reverse (via) relationships
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 { getForeignKeyFields, buildFieldSelection } from './knex-field-helpers.js';
46
- import { toJsonApi } from './knex-json-api-helpers.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';
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 '../utils/knex-constants.js';
49
- import { RestApiResourceError } from '../../../lib/rest-api-errors.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';
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 <caption>Basic grouping</caption>
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
- * @example <caption>Handles null values</caption>
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: 2 }
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
- * // Returns: { products: [1, 2] }
82
- * // Null values are filtered out
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
- * Converts comma-separated include strings with dot notation into a nested
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 <caption>Simple includes</caption>
117
- * parseIncludeTree('author,comments')
118
- * // Returns: { author: {}, comments: {} }
172
+ * @example
173
+ * // Input: Simple comma-separated includes
174
+ * const tree = parseIncludeTree('author,comments');
119
175
  *
120
- * @example <caption>Nested includes</caption>
121
- * parseIncludeTree('comments.author,comments.article')
122
- * // Returns: { comments: { author: {}, article: {} } }
176
+ * // Output: Flat structure
177
+ * // {
178
+ * // author: {},
179
+ * // comments: {}
180
+ * // }
123
181
  *
124
- * @example <caption>Mixed levels</caption>
125
- * parseIncludeTree('author,comments.author,publisher.country')
126
- * // Returns: {
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: { author: {} },
129
- * // publisher: { country: {} }
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][fieldDef.as] = {
302
+ record[RELATIONSHIP_METADATA_KEY][relName] = {
212
303
  data: { type, id: String(id) }
213
304
  };
214
305
  } else {
215
- record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = { data: null };
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?.resourceUrlPrefix;
292
- if (urlPrefix && scopeName) {
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 = toJsonApi(
442
+ const jsonApiRecord = toJsonApiRecord(
349
443
  scopes[targetScope],
350
444
  targetRecord,
351
- { context: { scopeName: targetScope, schemaInfo: scopes[targetScope].vars.schemaInfo, polymorphicFields: new Set() } }
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?.resourceUrlPrefix;
365
- if (urlPrefix && scopeName) {
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?.resourceUrlPrefix;
389
- if (urlPrefix && scopeName) {
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 || `${scopeName.slice(0, -1)}_id`;
480
- const otherKey = relDef.otherKey || `${relDef.hasMany.slice(0, -1)}_id`;
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?.resourceUrlPrefix;
503
- if (urlPrefix && scopeName) {
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
- .select(
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 || ['id'], targetTable) +
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}.id`, 'pivot.' + otherKey)
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
- let query = knex(targetTable).whereIn('id', targetIds);
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 = toJsonApi(
751
+ const jsonApiRecord = toJsonApiRecord(
638
752
  scopes[targetScope],
639
753
  childRecord,
640
- { context: { scopeName: targetScope, schemaInfo: scopes[targetScope].vars.schemaInfo, polymorphicFields: new Set() } }
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?.resourceUrlPrefix;
660
- if (urlPrefix && scopeName) {
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 || `${scopeName.slice(0, -1)}_id`;
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 = toJsonApi(
925
+ const jsonApiRecord = toJsonApiRecord(
803
926
  scopes[targetScope],
804
927
  childRecord,
805
- { context: { scopeName: targetScope, schemaInfo: scopes[targetScope].vars.schemaInfo, polymorphicFields: new Set() } }
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?.resourceUrlPrefix;
824
- if (urlPrefix && scopeName) {
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
- let query = knex(targetTable).whereIn('id', targetIds);
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 = toJsonApi(
1112
+ const jsonApiRecord = toJsonApiRecord(
981
1113
  scopes[targetType],
982
1114
  targetRecord,
983
- { context: { scopeName: targetType, schemaInfo: scopes[targetType].vars.schemaInfo, polymorphicFields: new Set() } }
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?.resourceUrlPrefix;
1002
- if (urlPrefix && scopeName) {
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?.resourceUrlPrefix;
1015
- if (urlPrefix && scopeName) {
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?.resourceUrlPrefix;
1047
- if (urlPrefix && scopeName) {
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 = toJsonApi(
1336
+ const jsonApiRecord = toJsonApiRecord(
1197
1337
  scopes[targetScope],
1198
1338
  childRecord,
1199
- { context: { scopeName: targetScope, schemaInfo: scopes[targetScope].vars.schemaInfo, polymorphicFields: new Set() } }
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?.resourceUrlPrefix;
1218
- if (urlPrefix && scopeName) {
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
  };