json-rest-api 1.0.21 → 1.0.22

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