json-rest-api 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/connectors_ideas/fastify.js +66 -68
  2. package/connectors_ideas/koa-plugin.js +50 -51
  3. package/eslint.config.js +13 -0
  4. package/examples/url-override-example.js +63 -63
  5. package/index.js +13 -13
  6. package/lib/enhanced-logger.js +51 -51
  7. package/lib/error-formatter.js +61 -61
  8. package/lib/rest-api-errors.js +28 -28
  9. package/package.json +9 -5
  10. package/plugins/core/bulk-operations-plugin.js +142 -145
  11. package/plugins/core/connectors/express-plugin.js +210 -217
  12. package/plugins/core/connectors/lib/busboy-detector.js +84 -84
  13. package/plugins/core/connectors/lib/formidable-detector.js +50 -50
  14. package/plugins/core/connectors/lib/request-helpers.js +74 -74
  15. package/plugins/core/file-handling-plugin.js +125 -125
  16. package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
  17. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
  18. package/plugins/core/lib/anyapi/schema-utils.js +101 -101
  19. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
  20. package/plugins/core/lib/dbTablesOperations.js +134 -135
  21. package/plugins/core/lib/fractional-positioning.js +74 -76
  22. package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
  23. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
  24. package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
  25. package/plugins/core/lib/querying/knex-process-includes.js +50 -50
  26. package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
  27. package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
  29. package/plugins/core/lib/querying/knex-window-queries.js +89 -89
  30. package/plugins/core/lib/querying/url-helpers.js +35 -35
  31. package/plugins/core/lib/querying-writing/compile-schemas.js +97 -98
  32. package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
  33. package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
  34. package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
  35. package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
  36. package/plugins/core/lib/querying-writing/field-utils.js +44 -42
  37. package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
  38. package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
  39. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
  40. package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
  41. package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
  42. package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
  43. package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
  44. package/plugins/core/lib/storage/storage-adapter.js +68 -68
  45. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
  46. package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
  47. package/plugins/core/lib/writing/relationship-processor.js +58 -58
  48. package/plugins/core/rest-api-access.js +8 -8
  49. package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
  50. package/plugins/core/rest-api-cors-plugin.js +170 -171
  51. package/plugins/core/rest-api-knex-plugin.js +482 -493
  52. package/plugins/core/rest-api-multihome-plugin.js +247 -251
  53. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
  54. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
  55. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
  57. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
  58. package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
  59. package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
  60. package/plugins/core/rest-api-plugin-methods/common.js +295 -297
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
  62. package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
  63. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
  64. package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
  65. package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
  66. package/plugins/core/rest-api-plugin-methods/get.js +86 -86
  67. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
  68. package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
  69. package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
  70. package/plugins/core/rest-api-plugin-methods/post.js +61 -62
  71. package/plugins/core/rest-api-plugin-methods/put.js +86 -87
  72. package/plugins/core/rest-api-plugin-methods/query.js +88 -88
  73. package/plugins/core/rest-api-plugin-methods/release.js +5 -6
  74. package/plugins/core/rest-api-plugin.js +76 -78
  75. package/plugins/core/rest-api-positioning-plugin.js +251 -252
  76. package/plugins/core/socketio-plugin.js +240 -236
  77. package/plugins/storage/local-storage.js +103 -103
  78. package/plugins/storage/s3-storage.js +41 -42
  79. package/quickTest.js +37 -37
  80. package/scripts/debug-anyapi-review-filter.mjs +18 -18
  81. package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
  82. package/scripts/inspect-anyapi-descriptor.mjs +10 -10
  83. package/scripts/run-docs.js +112 -113
  84. package/test-debug.js +28 -28
  85. package/tests/TEST_TEMPLATE.test.js +51 -51
  86. package/tests/anyapi-basic.test.js +42 -42
  87. package/tests/anyapi-cursor-pagination.test.js +48 -48
  88. package/tests/bulk-operations.test.js +208 -208
  89. package/tests/computed-fields.test.js +204 -204
  90. package/tests/cors.test.js +412 -413
  91. package/tests/curl.test.js +275 -276
  92. package/tests/custom-idproperty-relationships.test.js +266 -266
  93. package/tests/field-getters.test.js +132 -132
  94. package/tests/field-setters.test.js +110 -110
  95. package/tests/fixtures/api-configs.js +699 -699
  96. package/tests/helpers/storage-mode.js +32 -32
  97. package/tests/helpers/test-utils.js +76 -76
  98. package/tests/include-depth-validation.test.js +76 -76
  99. package/tests/includes.test.js +145 -145
  100. package/tests/multihome.test.js +175 -175
  101. package/tests/nested-includes.test.js +255 -255
  102. package/tests/pagination-cursor-multifield.test.js +164 -164
  103. package/tests/pagination-enhanced.test.js +225 -225
  104. package/tests/polymorphic-relationships.test.js +96 -96
  105. package/tests/polymorphic-simple.test.js +44 -44
  106. package/tests/positioning-advanced.test.js +226 -226
  107. package/tests/positioning-extended.test.js +399 -399
  108. package/tests/positioning-multifilter.test.js +204 -204
  109. package/tests/positioning.test.js +266 -266
  110. package/tests/queries.test.js +184 -184
  111. package/tests/query-limits.test.js +154 -155
  112. package/tests/relationship-endpoints.test.js +127 -127
  113. package/tests/relationships.test.js +178 -179
  114. package/tests/rest-api-access.test.js +86 -86
  115. package/tests/rest-api.test.js +103 -103
  116. package/tests/searchschema-merge.test.js +93 -93
  117. package/tests/socketio.test.js +326 -326
  118. package/tests/virtual-fields.test.js +164 -164
@@ -1,8 +1,8 @@
1
1
  // Cross-table search helper functions that enable filtering across related database tables
2
-
2
+
3
3
  /**
4
4
  * Validates that a field exists and is indexed for cross-table search
5
- *
5
+ *
6
6
  * @async
7
7
  * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
8
8
  * @param {Object} log - Logger instance for debug and trace output
@@ -11,14 +11,14 @@
11
11
  * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
12
12
  * @returns {Promise<void>}
13
13
  * @throws {Error} If scope not found, field not found, field not indexed, or circular reference detected
14
- *
14
+ *
15
15
  * @example
16
16
  * // Input: Validate indexed field
17
17
  * // Schema has: companies.name with indexed: true
18
18
  * await validateCrossTableField(scopes, log, 'companies', 'name');
19
- *
19
+ *
20
20
  * // Output: Validation succeeds (no error thrown)
21
- *
21
+ *
22
22
  * @example
23
23
  * // Input: Non-indexed field
24
24
  * // Schema has: companies.internal_code without indexed property
@@ -28,61 +28,61 @@
28
28
  * console.log(error.message);
29
29
  * // "Field 'companies.internal_code' is not indexed. Add 'indexed: true' to allow cross-table search"
30
30
  * }
31
- *
31
+ *
32
32
  * @description
33
33
  * Used by:
34
34
  * - buildJoinChain calls this to ensure target fields are properly indexed
35
35
  * - analyzeRequiredIndexes uses this to identify missing indexes
36
- *
36
+ *
37
37
  * Purpose:
38
38
  * - Cross-table searches can be slow without proper indexes
39
39
  * - Forces developers to explicitly mark searchable fields with indexed: true
40
40
  * - Provides clear error messages when configuration is missing
41
- *
41
+ *
42
42
  * Data flow:
43
43
  * - Called during query building to validate field configuration
44
44
  * - Ensures performance by requiring indexes on searchable fields
45
45
  * - Part of the validation phase before expensive JOIN operations
46
46
  */
47
47
  export const validateCrossTableField = async (scopes, log, targetScopeName, fieldName, searchedScopes = new Set()) => {
48
- log.trace('[VALIDATE] Starting validateCrossTableField:', { targetScopeName, fieldName, searchedScopes: Array.from(searchedScopes) });
49
-
50
- if (searchedScopes.has(targetScopeName)) {
51
- throw new Error(`Circular reference detected: ${targetScopeName} -> ${Array.from(searchedScopes).join(' -> ')}`);
52
- }
53
-
54
- log.trace('[VALIDATE] Getting target schema for:', targetScopeName);
55
- let targetSchemaInstance;
56
- try {
57
- targetSchemaInstance = scopes[targetScopeName].vars.schemaInfo.schemaInstance;;
58
- log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(targetSchemaInstance || {}) });
59
- } catch (error) {
60
- log.trace('[VALIDATE] Error getting target schema:', error.message);
61
- throw new Error(`Target scope '${targetScopeName}' not found`);
62
- }
63
-
64
- if (!targetSchemaInstance) {
65
- log.trace('[VALIDATE] Target schema is null/undefined');
66
- throw new Error(`Target scope '${targetScopeName}' has no schema`);
67
- }
68
-
69
- const fieldDef = targetSchemaInstance.structure[fieldName];
70
- log.trace('[VALIDATE] Field lookup result:', { fieldName, fieldExists: !!fieldDef });
71
- if (!fieldDef) {
72
- throw new Error(`Field '${fieldName}' not found in scope '${targetScopeName}'`);
73
- }
74
-
75
- log.trace('[VALIDATE] Checking indexed status:', { fieldName, indexed: fieldDef.indexed });
76
- if (!fieldDef.indexed) {
77
- throw new Error(`Field '${targetScopeName}.${fieldName}' is not indexed. Add 'indexed: true' to allow cross-table search`);
78
- }
79
-
80
- log.trace('[VALIDATE] Validation successful for field:', `${targetScopeName}.${fieldName}`);
81
- };
48
+ log.trace('[VALIDATE] Starting validateCrossTableField:', { targetScopeName, fieldName, searchedScopes: Array.from(searchedScopes) })
49
+
50
+ if (searchedScopes.has(targetScopeName)) {
51
+ throw new Error(`Circular reference detected: ${targetScopeName} -> ${Array.from(searchedScopes).join(' -> ')}`)
52
+ }
53
+
54
+ log.trace('[VALIDATE] Getting target schema for:', targetScopeName)
55
+ let targetSchemaInstance
56
+ try {
57
+ targetSchemaInstance = scopes[targetScopeName].vars.schemaInfo.schemaInstance
58
+ log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(targetSchemaInstance || {}) })
59
+ } catch (error) {
60
+ log.trace('[VALIDATE] Error getting target schema:', error.message)
61
+ throw new Error(`Target scope '${targetScopeName}' not found`)
62
+ }
63
+
64
+ if (!targetSchemaInstance) {
65
+ log.trace('[VALIDATE] Target schema is null/undefined')
66
+ throw new Error(`Target scope '${targetScopeName}' has no schema`)
67
+ }
68
+
69
+ const fieldDef = targetSchemaInstance.structure[fieldName]
70
+ log.trace('[VALIDATE] Field lookup result:', { fieldName, fieldExists: !!fieldDef })
71
+ if (!fieldDef) {
72
+ throw new Error(`Field '${fieldName}' not found in scope '${targetScopeName}'`)
73
+ }
74
+
75
+ log.trace('[VALIDATE] Checking indexed status:', { fieldName, indexed: fieldDef.indexed })
76
+ if (!fieldDef.indexed) {
77
+ throw new Error(`Field '${targetScopeName}.${fieldName}' is not indexed. Add 'indexed: true' to allow cross-table search`)
78
+ }
79
+
80
+ log.trace('[VALIDATE] Validation successful for field:', `${targetScopeName}.${fieldName}`)
81
+ }
82
82
 
83
83
  /**
84
84
  * Builds a chain of SQL JOINs to reach a field in a related table
85
- *
85
+ *
86
86
  * @async
87
87
  * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
88
88
  * @param {Object} log - Logger instance for debug and trace output
@@ -91,12 +91,12 @@ export const validateCrossTableField = async (scopes, log, targetScopeName, fiel
91
91
  * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
92
92
  * @returns {Promise<Object>} JOIN information for query builder
93
93
  * @throws {Error} If path is invalid or relationships not properly configured
94
- *
94
+ *
95
95
  * @example
96
96
  * // Input: Simple belongsTo relationship
97
97
  * // articles table has author_id field, authors schema defines belongsTo relationship
98
98
  * const joinInfo = await buildJoinChain('articles', 'author.name');
99
- *
99
+ *
100
100
  * // Output: Single JOIN configuration
101
101
  * // {
102
102
  * // joinAlias: 'articles_to_authors_authors',
@@ -107,12 +107,12 @@ export const validateCrossTableField = async (scopes, log, targetScopeName, fiel
107
107
  * // isOneToMany: false,
108
108
  * // isPolymorphic: false
109
109
  * // }
110
- *
110
+ *
111
111
  * @example
112
112
  * // Input: Multi-level path through relationships
113
113
  * // articles → authors → companies
114
114
  * const joinInfo = await buildJoinChain('articles', 'author.company.name');
115
- *
115
+ *
116
116
  * // Output: Multi-level JOIN chain
117
117
  * // {
118
118
  * // targetTableName: 'companies',
@@ -140,12 +140,12 @@ export const validateCrossTableField = async (scopes, log, targetScopeName, fiel
140
140
  * // }
141
141
  * // ]
142
142
  * // }
143
- *
143
+ *
144
144
  * @example
145
145
  * // Input: One-to-many relationship (hasMany)
146
146
  * // authors have many articles
147
147
  * const joinInfo = await buildJoinChain('authors', 'articles.title');
148
- *
148
+ *
149
149
  * // Output: JOIN with isOneToMany flag
150
150
  * // {
151
151
  * // joinAlias: 'authors_to_articles_articles',
@@ -156,17 +156,17 @@ export const validateCrossTableField = async (scopes, log, targetScopeName, fiel
156
156
  * // isOneToMany: true,
157
157
  * // isPolymorphic: false
158
158
  * // }
159
- *
159
+ *
160
160
  * @description
161
161
  * Used by:
162
162
  * - rest-api-knex-plugin's dataQuery method when processing cross-table filters
163
163
  * - Called for each filter that references a related table field
164
- *
164
+ *
165
165
  * Purpose:
166
166
  * - Automatically constructs complex SQL JOINs from simple dot notation
167
167
  * - Supports many-to-one (belongsTo), one-to-many (hasMany), and many-to-many relationships
168
168
  * - Handles polymorphic relationships and multi-level paths
169
- *
169
+ *
170
170
  * Data flow:
171
171
  * 1. Filter parser identifies cross-table reference (contains dots)
172
172
  * 2. buildJoinChain analyzes relationship path segment by segment
@@ -176,243 +176,243 @@ export const validateCrossTableField = async (scopes, log, targetScopeName, fiel
176
176
  * 6. Query builder adds these JOINs to enable filtering on related data
177
177
  */
178
178
  export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, searchedScopes = new Set()) => {
179
- log.trace('[BUILD-JOIN] Starting buildJoinChain:', { fromScopeName, targetPath });
180
-
181
- const pathSegments = targetPath.split('.');
182
- if (pathSegments.length < 2) {
183
- throw new Error(`Invalid cross-table path '${targetPath}'. Must contain at least 'scope.field'`);
179
+ log.trace('[BUILD-JOIN] Starting buildJoinChain:', { fromScopeName, targetPath })
180
+
181
+ const pathSegments = targetPath.split('.')
182
+ if (pathSegments.length < 2) {
183
+ throw new Error(`Invalid cross-table path '${targetPath}'. Must contain at least 'scope.field'`)
184
+ }
185
+
186
+ const targetFieldName = pathSegments[pathSegments.length - 1]
187
+ const relationshipPath = pathSegments.slice(0, -1)
188
+
189
+ log.trace('[BUILD-JOIN] Parsed path:', { relationshipPath, targetFieldName })
190
+
191
+ const joinChain = []
192
+ let currentScope = fromScopeName
193
+
194
+ for (let i = 0; i < relationshipPath.length; i++) {
195
+ const targetScope = relationshipPath[i]
196
+
197
+ log.trace('[BUILD-JOIN] Looking for direct relationship:', { from: currentScope, to: targetScope })
198
+
199
+ if (searchedScopes.has(currentScope)) {
200
+ throw new Error(`Circular reference detected in path: ${Array.from(searchedScopes).join(' -> ')} -> ${currentScope}`)
184
201
  }
185
-
186
- const targetFieldName = pathSegments[pathSegments.length - 1];
187
- const relationshipPath = pathSegments.slice(0, -1);
188
-
189
- log.trace('[BUILD-JOIN] Parsed path:', { relationshipPath, targetFieldName });
190
-
191
- const joinChain = [];
192
- let currentScope = fromScopeName;
193
-
194
- for (let i = 0; i < relationshipPath.length; i++) {
195
- const targetScope = relationshipPath[i];
196
-
197
- log.trace('[BUILD-JOIN] Looking for direct relationship:', { from: currentScope, to: targetScope });
198
-
199
- if (searchedScopes.has(currentScope)) {
200
- throw new Error(`Circular reference detected in path: ${Array.from(searchedScopes).join(' -> ')} -> ${currentScope}`);
201
- }
202
- searchedScopes.add(currentScope);
203
-
204
- const schemaInfo = scopes[currentScope].vars.schemaInfo
205
- const currentSchema = schemaInfo.schemaInstance;
206
- const currentRelationships = schemaInfo.schemaRelationships;
207
-
208
- let foundRelationship = null;
209
- let relationshipType = null;
210
- let relationshipField = null;
211
- let relationshipDef = null;
212
-
213
- if (currentRelationships) {
214
- for (const [relName, relDef] of Object.entries(currentRelationships)) {
215
- if (relDef.type === 'hasMany' && relDef.target === targetScope) {
216
- if (relDef.via) {
217
- const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships;
218
- const viaRel = targetRelationships?.[relDef.via];
219
-
220
- if (viaRel?.belongsToPolymorphic) {
221
- const { typeField, idField } = viaRel.belongsToPolymorphic;
222
- foundRelationship = targetScope;
223
- relationshipType = 'hasManyPolymorphic';
224
- relationshipField = { typeField, idField, via: relDef.via };
225
- relationshipDef = relDef;
226
- log.trace('[BUILD-JOIN] Found polymorphic hasMany relationship:', {
227
- relName, targetScope, via: relDef.via, typeField, idField
228
- });
229
- break;
230
- }
231
- }
232
-
233
- if (relDef.through) {
234
- foundRelationship = targetScope;
235
- relationshipType = 'manyToMany';
236
- relationshipField = {
237
- through: relDef.through,
238
- foreignKey: relDef.foreignKey,
239
- otherKey: relDef.otherKey
240
- };
241
- relationshipDef = relDef;
242
- log.trace('[BUILD-JOIN] Found many-to-many relationship:', {
243
- relName,
244
- targetScope,
245
- through: relDef.through,
246
- foreignKey: relDef.foreignKey,
247
- otherKey: relDef.otherKey
248
- });
249
- break;
202
+ searchedScopes.add(currentScope)
203
+
204
+ const schemaInfo = scopes[currentScope].vars.schemaInfo
205
+ const currentSchema = schemaInfo.schemaInstance
206
+ const currentRelationships = schemaInfo.schemaRelationships
207
+
208
+ let foundRelationship = null
209
+ let relationshipType = null
210
+ let relationshipField = null
211
+ let relationshipDef = null
212
+
213
+ if (currentRelationships) {
214
+ for (const [relName, relDef] of Object.entries(currentRelationships)) {
215
+ if (relDef.type === 'hasMany' && relDef.target === targetScope) {
216
+ if (relDef.via) {
217
+ const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships
218
+ const viaRel = targetRelationships?.[relDef.via]
219
+
220
+ if (viaRel?.belongsToPolymorphic) {
221
+ const { typeField, idField } = viaRel.belongsToPolymorphic
222
+ foundRelationship = targetScope
223
+ relationshipType = 'hasManyPolymorphic'
224
+ relationshipField = { typeField, idField, via: relDef.via }
225
+ relationshipDef = relDef
226
+ log.trace('[BUILD-JOIN] Found polymorphic hasMany relationship:', {
227
+ relName, targetScope, via: relDef.via, typeField, idField
228
+ })
229
+ break
250
230
  }
251
-
252
- foundRelationship = targetScope;
253
- relationshipType = 'hasMany';
254
- relationshipField = relDef.foreignKey;
255
- if (!relationshipField) {
256
- log.error('[BUILD-JOIN] Missing foreignKey in hasMany relationship:', { relName, currentScope });
257
- throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${currentScope}'`);
231
+ }
232
+
233
+ if (relDef.through) {
234
+ foundRelationship = targetScope
235
+ relationshipType = 'manyToMany'
236
+ relationshipField = {
237
+ through: relDef.through,
238
+ foreignKey: relDef.foreignKey,
239
+ otherKey: relDef.otherKey
258
240
  }
259
- relationshipDef = relDef;
260
- log.trace('[BUILD-JOIN] Found hasMany relationship:', { relName, targetScope, foreignKey: relationshipField });
261
- break;
241
+ relationshipDef = relDef
242
+ log.trace('[BUILD-JOIN] Found many-to-many relationship:', {
243
+ relName,
244
+ targetScope,
245
+ through: relDef.through,
246
+ foreignKey: relDef.foreignKey,
247
+ otherKey: relDef.otherKey
248
+ })
249
+ break
262
250
  }
251
+
252
+ foundRelationship = targetScope
253
+ relationshipType = 'hasMany'
254
+ relationshipField = relDef.foreignKey
255
+ if (!relationshipField) {
256
+ log.error('[BUILD-JOIN] Missing foreignKey in hasMany relationship:', { relName, currentScope })
257
+ throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${currentScope}'`)
258
+ }
259
+ relationshipDef = relDef
260
+ log.trace('[BUILD-JOIN] Found hasMany relationship:', { relName, targetScope, foreignKey: relationshipField })
261
+ break
263
262
  }
264
263
  }
265
-
266
- if (!foundRelationship) {
267
- for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
268
- if (fieldDef.belongsTo === targetScope) {
269
- foundRelationship = targetScope;
270
- relationshipType = 'belongsTo';
271
- relationshipField = fieldName;
272
- relationshipDef = fieldDef;
273
- log.trace('[BUILD-JOIN] Found belongsTo relationship:', { fieldName, targetScope });
274
- break;
275
- }
264
+ }
265
+
266
+ if (!foundRelationship) {
267
+ for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
268
+ if (fieldDef.belongsTo === targetScope) {
269
+ foundRelationship = targetScope
270
+ relationshipType = 'belongsTo'
271
+ relationshipField = fieldName
272
+ relationshipDef = fieldDef
273
+ log.trace('[BUILD-JOIN] Found belongsTo relationship:', { fieldName, targetScope })
274
+ break
276
275
  }
277
276
  }
278
-
279
- if (!foundRelationship) {
280
- throw new Error(
277
+ }
278
+
279
+ if (!foundRelationship) {
280
+ throw new Error(
281
281
  `No searchable relationship from '${currentScope}' to '${targetScope}'. `
282
- );
283
- }
284
-
285
- const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance;
286
- const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
287
- const targetSchemaInstance = scopes[targetScope].vars.schemaInfo.schemaInstance;
288
- const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
289
-
290
- if (relationshipType === 'manyToMany') {
291
- const { through, foreignKey, otherKey } = relationshipField;
292
- const pivotTableName = scopes[through].vars.schemaInfo.tableName || through;
293
- const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
294
-
295
- const pivotAlias = `${currentScope}_to_${through}_${through}`;
296
- const pivotJoinCondition = `${previousAlias}.id = ${pivotAlias}.${foreignKey}`;
297
-
298
- joinChain.push({
299
- targetTableName: pivotTableName,
300
- joinAlias: pivotAlias,
301
- joinCondition: pivotJoinCondition,
302
- isOneToMany: true,
303
- isPolymorphic: false,
304
- relationshipField: foreignKey,
305
- relationshipType: 'manyToMany_pivot',
306
- targetScopeName: through
307
- });
308
-
309
- const targetAlias = `${through}_to_${targetScope}_${targetScope}`;
310
- const targetJoinCondition = `${pivotAlias}.${otherKey} = ${targetAlias}.id`;
311
-
312
- joinChain.push({
313
- targetTableName,
314
- joinAlias: targetAlias,
315
- joinCondition: targetJoinCondition,
316
- isOneToMany: false,
317
- isPolymorphic: false,
318
- relationshipField: otherKey,
319
- relationshipType: 'manyToMany_target',
320
- targetScopeName: targetScope
321
- });
282
+ )
283
+ }
284
+
285
+ const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
286
+ const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
287
+ const targetSchemaInstance = scopes[targetScope].vars.schemaInfo.schemaInstance
288
+ const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
289
+
290
+ if (relationshipType === 'manyToMany') {
291
+ const { through, foreignKey, otherKey } = relationshipField
292
+ const pivotTableName = scopes[through].vars.schemaInfo.tableName || through
293
+ const previousAlias = i === 0 ? sourceTableName : joinChain[i - 1].joinAlias
294
+
295
+ const pivotAlias = `${currentScope}_to_${through}_${through}`
296
+ const pivotJoinCondition = `${previousAlias}.id = ${pivotAlias}.${foreignKey}`
297
+
298
+ joinChain.push({
299
+ targetTableName: pivotTableName,
300
+ joinAlias: pivotAlias,
301
+ joinCondition: pivotJoinCondition,
302
+ isOneToMany: true,
303
+ isPolymorphic: false,
304
+ relationshipField: foreignKey,
305
+ relationshipType: 'manyToMany_pivot',
306
+ targetScopeName: through
307
+ })
308
+
309
+ const targetAlias = `${through}_to_${targetScope}_${targetScope}`
310
+ const targetJoinCondition = `${pivotAlias}.${otherKey} = ${targetAlias}.id`
311
+
312
+ joinChain.push({
313
+ targetTableName,
314
+ joinAlias: targetAlias,
315
+ joinCondition: targetJoinCondition,
316
+ isOneToMany: false,
317
+ isPolymorphic: false,
318
+ relationshipField: otherKey,
319
+ relationshipType: 'manyToMany_target',
320
+ targetScopeName: targetScope
321
+ })
322
+ } else {
323
+ const joinAlias = `${currentScope}_to_${targetScope}_${targetScope}`
324
+
325
+ let joinCondition
326
+ if (relationshipType === 'hasManyPolymorphic') {
327
+ const { typeField, idField } = relationshipField
328
+ const previousAlias = i === 0 ? sourceTableName : joinChain[i - 1].joinAlias
329
+ joinCondition = `${joinAlias}.${typeField} = '${currentScope}' AND ${joinAlias}.${idField} = ${previousAlias}.id`
330
+ } else if (relationshipType === 'hasMany') {
331
+ const previousAlias = i === 0 ? sourceTableName : joinChain[i - 1].joinAlias
332
+ joinCondition = `${previousAlias}.id = ${joinAlias}.${relationshipField}`
322
333
  } else {
323
- const joinAlias = `${currentScope}_to_${targetScope}_${targetScope}`;
324
-
325
- let joinCondition;
326
- if (relationshipType === 'hasManyPolymorphic') {
327
- const { typeField, idField } = relationshipField;
328
- const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
329
- joinCondition = `${joinAlias}.${typeField} = '${currentScope}' AND ${joinAlias}.${idField} = ${previousAlias}.id`;
330
- } else if (relationshipType === 'hasMany') {
331
- const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
332
- joinCondition = `${previousAlias}.id = ${joinAlias}.${relationshipField}`;
333
- } else {
334
- const previousAlias = i === 0 ? sourceTableName : joinChain[i-1].joinAlias;
335
- joinCondition = `${previousAlias}.${relationshipField} = ${joinAlias}.id`;
336
- }
337
-
338
- joinChain.push({
339
- targetTableName,
340
- joinAlias,
341
- joinCondition,
342
- isOneToMany: relationshipType === 'hasMany' || relationshipType === 'hasManyPolymorphic',
343
- isPolymorphic: relationshipType === 'hasManyPolymorphic',
344
- relationshipField,
345
- relationshipType,
346
- targetScopeName: targetScope
347
- });
334
+ const previousAlias = i === 0 ? sourceTableName : joinChain[i - 1].joinAlias
335
+ joinCondition = `${previousAlias}.${relationshipField} = ${joinAlias}.id`
348
336
  }
349
-
350
- currentScope = targetScope;
337
+
338
+ joinChain.push({
339
+ targetTableName,
340
+ joinAlias,
341
+ joinCondition,
342
+ isOneToMany: relationshipType === 'hasMany' || relationshipType === 'hasManyPolymorphic',
343
+ isPolymorphic: relationshipType === 'hasManyPolymorphic',
344
+ relationshipField,
345
+ relationshipType,
346
+ targetScopeName: targetScope
347
+ })
351
348
  }
352
-
353
- const finalScope = relationshipPath[relationshipPath.length - 1];
354
- await validateCrossTableField(scopes, log, finalScope, targetFieldName, searchedScopes);
355
-
356
- if (joinChain.length === 0) {
357
- throw new Error(`Invalid path '${targetPath}': no relationships to traverse`);
349
+
350
+ currentScope = targetScope
351
+ }
352
+
353
+ const finalScope = relationshipPath[relationshipPath.length - 1]
354
+ await validateCrossTableField(scopes, log, finalScope, targetFieldName, searchedScopes)
355
+
356
+ if (joinChain.length === 0) {
357
+ throw new Error(`Invalid path '${targetPath}': no relationships to traverse`)
358
+ }
359
+
360
+ const lastJoin = joinChain[joinChain.length - 1]
361
+
362
+ if (joinChain.length > 1) {
363
+ return {
364
+ targetTableName: lastJoin.targetTableName,
365
+ joinAlias: lastJoin.joinAlias,
366
+ joinCondition: joinChain.map(j => j.joinCondition).join(' AND '),
367
+ targetField: targetFieldName,
368
+ isOneToMany: joinChain.some(j => j.isOneToMany),
369
+ isMultiLevel: true,
370
+ joinChain,
371
+ targetScopeName: finalScope
358
372
  }
359
-
360
- const lastJoin = joinChain[joinChain.length - 1];
361
-
362
- if (joinChain.length > 1) {
363
- return {
364
- targetTableName: lastJoin.targetTableName,
365
- joinAlias: lastJoin.joinAlias,
366
- joinCondition: joinChain.map(j => j.joinCondition).join(' AND '),
367
- targetField: targetFieldName,
368
- isOneToMany: joinChain.some(j => j.isOneToMany),
369
- isMultiLevel: true,
370
- joinChain,
371
- targetScopeName: finalScope
372
- };
373
- } else {
374
- return {
375
- joinAlias: lastJoin.joinAlias,
376
- targetTableName: lastJoin.targetTableName,
377
- sourceField: lastJoin.relationshipField,
378
- targetField: targetFieldName,
379
- joinCondition: lastJoin.joinCondition,
380
- isOneToMany: lastJoin.isOneToMany,
381
- isPolymorphic: lastJoin.isPolymorphic,
382
- targetScopeName: lastJoin.targetScopeName || finalScope
383
- };
373
+ } else {
374
+ return {
375
+ joinAlias: lastJoin.joinAlias,
376
+ targetTableName: lastJoin.targetTableName,
377
+ sourceField: lastJoin.relationshipField,
378
+ targetField: targetFieldName,
379
+ joinCondition: lastJoin.joinCondition,
380
+ isOneToMany: lastJoin.isOneToMany,
381
+ isPolymorphic: lastJoin.isPolymorphic,
382
+ targetScopeName: lastJoin.targetScopeName || finalScope
384
383
  }
385
- };
384
+ }
385
+ }
386
386
 
387
387
  /**
388
388
  * Analyzes a search schema to identify which fields need database indexes
389
- *
389
+ *
390
390
  * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
391
391
  * @param {Object} log - Logger instance for debug and trace output
392
392
  * @param {string} scopeName - The scope being analyzed
393
393
  * @param {Object} searchSchema - Search schema definition with filter fields
394
394
  * @returns {Array<Object>} Array of required indexes with scope, field, and reason
395
- *
395
+ *
396
396
  * @example
397
397
  * // Input: Search schema with cross-table references
398
398
  * const searchSchema = {
399
- * authorName: {
399
+ * authorName: {
400
400
  * type: 'string',
401
401
  * actualField: 'authors.name',
402
402
  * filterOperator: 'like'
403
403
  * },
404
404
  * companyName: {
405
- * type: 'string',
405
+ * type: 'string',
406
406
  * actualField: 'authors.company.name'
407
407
  * },
408
- * search: {
408
+ * search: {
409
409
  * type: 'string',
410
410
  * oneOf: ['title', 'authors.name', 'authors.company.name']
411
411
  * }
412
412
  * };
413
- *
413
+ *
414
414
  * const requiredIndexes = analyzeRequiredIndexes('articles', searchSchema);
415
- *
415
+ *
416
416
  * // Output: List of fields that need indexes
417
417
  * // [
418
418
  * // { scope: 'authors', field: 'name', reason: 'Cross-table search from articles.authorName' },
@@ -420,29 +420,29 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
420
420
  * // { scope: 'authors', field: 'name', reason: 'Cross-table oneOf search from articles.search' },
421
421
  * // { scope: 'authors', field: 'company', reason: 'Cross-table oneOf search from articles.search' }
422
422
  * // ]
423
- *
423
+ *
424
424
  * @example
425
425
  * // Input: No cross-table references
426
426
  * const searchSchema = {
427
427
  * title: { type: 'string' },
428
428
  * status: { type: 'string' }
429
429
  * };
430
- *
430
+ *
431
431
  * const requiredIndexes = analyzeRequiredIndexes('articles', searchSchema);
432
- *
432
+ *
433
433
  * // Output: Empty array - no cross-table indexes needed
434
434
  * // []
435
- *
435
+ *
436
436
  * @description
437
437
  * Used by:
438
438
  * - Called during API initialization to identify missing indexes
439
439
  * - Used by developers to understand performance requirements
440
- *
440
+ *
441
441
  * Purpose:
442
442
  * - Cross-table searches require indexes for acceptable performance
443
443
  * - Helps identify configuration issues before they cause slow queries
444
444
  * - Provides clear documentation of index requirements
445
- *
445
+ *
446
446
  * Data flow:
447
447
  * - Runs during schema compilation phase
448
448
  * - Analyzes search schemas to find cross-table references
@@ -450,89 +450,89 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
450
450
  * - Prevents performance issues before queries are executed
451
451
  */
452
452
  export const analyzeRequiredIndexes = (scopes, log, scopeName, schemaInfo) => {
453
- const requiredIndexes = [];
454
-
455
- const schemaToAnalyze = schemaInfo.searchSchemaStructure
456
-
457
- Object.entries(schemaToAnalyze).forEach(([filterKey, fieldDef]) => {
458
- if (fieldDef.actualField && fieldDef.actualField.includes('.')) {
459
- const [targetScopeName, targetFieldName] = fieldDef.actualField.split('.');
460
- requiredIndexes.push({
461
- scope: targetScopeName,
462
- field: targetFieldName,
463
- reason: `Cross-table search from ${scopeName}.${filterKey}`
464
- });
465
- }
466
-
467
- if (fieldDef.oneOf && Array.isArray(fieldDef.oneOf)) {
468
- fieldDef.oneOf.forEach(field => {
469
- if (field.includes('.')) {
470
- const [targetScopeName, targetFieldName] = field.split('.');
471
- requiredIndexes.push({
472
- scope: targetScopeName,
473
- field: targetFieldName,
474
- reason: `Cross-table oneOf search from ${scopeName}.${filterKey}`
475
- });
476
- }
477
- });
478
- }
479
- });
480
-
481
- return requiredIndexes;
482
- };
453
+ const requiredIndexes = []
454
+
455
+ const schemaToAnalyze = schemaInfo.searchSchemaStructure
456
+
457
+ Object.entries(schemaToAnalyze).forEach(([filterKey, fieldDef]) => {
458
+ if (fieldDef.actualField && fieldDef.actualField.includes('.')) {
459
+ const [targetScopeName, targetFieldName] = fieldDef.actualField.split('.')
460
+ requiredIndexes.push({
461
+ scope: targetScopeName,
462
+ field: targetFieldName,
463
+ reason: `Cross-table search from ${scopeName}.${filterKey}`
464
+ })
465
+ }
466
+
467
+ if (fieldDef.oneOf && Array.isArray(fieldDef.oneOf)) {
468
+ fieldDef.oneOf.forEach(field => {
469
+ if (field.includes('.')) {
470
+ const [targetScopeName, targetFieldName] = field.split('.')
471
+ requiredIndexes.push({
472
+ scope: targetScopeName,
473
+ field: targetFieldName,
474
+ reason: `Cross-table oneOf search from ${scopeName}.${filterKey}`
475
+ })
476
+ }
477
+ })
478
+ }
479
+ })
480
+
481
+ return requiredIndexes
482
+ }
483
483
 
484
484
  /**
485
485
  * Creates database indexes for fields identified by analyzeRequiredIndexes
486
- *
486
+ *
487
487
  * @async
488
488
  * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
489
489
  * @param {Object} log - Logger instance for debug and trace output
490
490
  * @param {Array<Object>} requiredIndexes - Index requirements from analyzeRequiredIndexes
491
491
  * @param {Object} knex - Knex database connection instance
492
492
  * @returns {Promise<Array<Object>>} Array of successfully created indexes
493
- *
493
+ *
494
494
  * @example
495
495
  * // Input: Required indexes from analysis
496
496
  * const requiredIndexes = [
497
497
  * { scope: 'authors', field: 'name', reason: 'Cross-table search' },
498
498
  * { scope: 'companies', field: 'name', reason: 'Cross-table search' }
499
499
  * ];
500
- *
500
+ *
501
501
  * const createdIndexes = await createRequiredIndexes(requiredIndexes, knex);
502
- *
502
+ *
503
503
  * // Output: Successfully created indexes
504
504
  * // [
505
505
  * // { tableName: 'authors', field: 'name', indexName: 'idx_authors_name_search' },
506
506
  * // { tableName: 'companies', field: 'name', indexName: 'idx_companies_name_search' }
507
507
  * // ]
508
- *
508
+ *
509
509
  * // Database effect: CREATE INDEX idx_authors_name_search ON authors(name);
510
510
  * // Database effect: CREATE INDEX idx_companies_name_search ON companies(name);
511
- *
511
+ *
512
512
  * @example
513
513
  * // Input: Some indexes already exist
514
514
  * const requiredIndexes = [
515
515
  * { scope: 'authors', field: 'name', reason: 'Cross-table search' }, // Already exists
516
516
  * { scope: 'authors', field: 'email', reason: 'Cross-table search' } // New
517
517
  * ];
518
- *
518
+ *
519
519
  * const createdIndexes = await createRequiredIndexes(requiredIndexes, knex);
520
- *
520
+ *
521
521
  * // Output: Only newly created indexes
522
522
  * // [
523
523
  * // { tableName: 'authors', field: 'email', indexName: 'idx_authors_email_search' }
524
524
  * // ]
525
- *
525
+ *
526
526
  * @description
527
527
  * Used by:
528
528
  * - Can be called during database migration or setup
529
529
  * - Used by developers to automatically create performance indexes
530
- *
530
+ *
531
531
  * Purpose:
532
532
  * - Automates index creation based on search schema requirements
533
533
  * - Ensures consistent index naming across the application
534
534
  * - Safely handles cases where indexes already exist
535
- *
535
+ *
536
536
  * Data flow:
537
537
  * - Typically runs during database setup or migration
538
538
  * - Creates indexes identified by analyzeRequiredIndexes
@@ -540,26 +540,26 @@ export const analyzeRequiredIndexes = (scopes, log, scopeName, schemaInfo) => {
540
540
  * - Part of the database optimization phase
541
541
  */
542
542
  export const createRequiredIndexes = async (scopes, log, requiredIndexes, knex) => {
543
- const createdIndexes = [];
544
-
545
- for (const indexInfo of requiredIndexes) {
546
- const { scope, field } = indexInfo;
547
- const tableName = scopes[scope].vars.schemaInfo.tableName
548
- const indexName = `idx_${tableName}_${field}_search`;
549
-
550
- try {
551
- const hasIndex = await knex.schema.hasIndex(tableName, [field]);
552
- if (!hasIndex) {
553
- await knex.schema.table(tableName, table => {
554
- table.index([field], indexName);
555
- });
556
- createdIndexes.push({ tableName, field, indexName });
557
- log.info(`Created index: ${indexName} on ${tableName}.${field}`);
558
- }
559
- } catch (error) {
560
- log.warn(`Failed to create index on ${tableName}.${field}:`, error.message);
543
+ const createdIndexes = []
544
+
545
+ for (const indexInfo of requiredIndexes) {
546
+ const { scope, field } = indexInfo
547
+ const tableName = scopes[scope].vars.schemaInfo.tableName
548
+ const indexName = `idx_${tableName}_${field}_search`
549
+
550
+ try {
551
+ const hasIndex = await knex.schema.hasIndex(tableName, [field])
552
+ if (!hasIndex) {
553
+ await knex.schema.table(tableName, table => {
554
+ table.index([field], indexName)
555
+ })
556
+ createdIndexes.push({ tableName, field, indexName })
557
+ log.info(`Created index: ${indexName} on ${tableName}.${field}`)
561
558
  }
559
+ } catch (error) {
560
+ log.warn(`Failed to create index on ${tableName}.${field}:`, error.message)
562
561
  }
563
-
564
- return createdIndexes;
565
- };
562
+ }
563
+
564
+ return createdIndexes
565
+ }