json-rest-api 1.0.10 → 1.0.11

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +5 -9
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -1,67 +1,23 @@
1
+ // Cross-table search helper functions that enable filtering across related database tables
2
+
1
3
  /**
2
- * Creates cross-table search helper functions that enable filtering across related database tables
4
+ * Validates that a field exists and is indexed for cross-table search
3
5
  *
6
+ * @async
4
7
  * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
5
8
  * @param {Object} log - Logger instance for debug and trace output
6
- * @returns {Object} Object containing validateCrossTableField, buildJoinChain, analyzeRequiredIndexes, and createRequiredIndexes functions
7
- *
8
- * @example
9
- * // Input: API scopes with relationships defined
10
- * const scopes = {
11
- * articles: { vars: { schemaInfo: { schema: articlesSchema, tableName: 'articles' } } },
12
- * authors: { vars: { schemaInfo: { schema: authorsSchema, tableName: 'authors' } } },
13
- * companies: { vars: { schemaInfo: { schema: companiesSchema, tableName: 'companies' } } }
14
- * };
15
- *
16
- * const helpers = createCrossTableSearchHelpers(scopes, logger);
17
- *
18
- * // Output: Object with helper functions
19
- * // {
20
- * // validateCrossTableField: [Function],
21
- * // buildJoinChain: [Function],
22
- * // analyzeRequiredIndexes: [Function],
23
- * // createRequiredIndexes: [Function]
24
- * // }
25
- *
26
- * @description
27
- * Used by:
28
- * - rest-api-knex-plugin uses this during dataQuery to enable cross-table filtering
29
- * - Called when filter parameters reference fields from related tables (e.g., filter[author.name]=John)
30
- *
31
- * Purpose:
32
- * - Enables powerful filtering across relationships without exposing SQL complexity to API consumers
33
- * - Automatically builds optimal JOIN chains based on schema relationships
34
- * - Prevents N+1 queries by using efficient SQL JOINs instead of multiple queries
35
- *
36
- * Data flow:
37
- * 1. API receives filter like filter[author.company.name]=Acme
38
- * 2. rest-api-knex-plugin detects cross-table reference (contains dots)
39
- * 3. Calls buildJoinChain to construct SQL JOIN path from articles → authors → companies
40
- * 4. Adds JOINs to main query, enabling filtering on company name while querying articles
41
- * 5. Returns articles where author's company name matches 'Acme'
42
- */
43
- export const createCrossTableSearchHelpers = (scopes, log) => {
44
-
45
- /**
46
- * Validates that a field exists and is indexed for cross-table search
47
- *
48
- * @async
49
- * @param {string} targetScopeName - The scope containing the field to validate
50
- * @param {string} fieldName - The field name to check
51
- * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
52
- * @returns {Promise<{targetSchema: Object, fieldDef: Object}>} The validated schema and field definition
53
- * @throws {Error} If scope not found, field not found, field not indexed, or circular reference detected
9
+ * @param {string} targetScopeName - The scope containing the field to validate
10
+ * @param {string} fieldName - The field name to check
11
+ * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
12
+ * @returns {Promise<void>}
13
+ * @throws {Error} If scope not found, field not found, field not indexed, or circular reference detected
54
14
  *
55
15
  * @example
56
16
  * // Input: Validate indexed field
57
17
  * // Schema has: companies.name with indexed: true
58
- * const result = await validateCrossTableField('companies', 'name');
18
+ * await validateCrossTableField(scopes, log, 'companies', 'name');
59
19
  *
60
- * // Output: Successfully returns schema and field definition
61
- * // {
62
- * // targetSchema: { structure: { name: { type: 'string', indexed: true }, ... } },
63
- * // fieldDef: { type: 'string', indexed: true }
64
- * // }
20
+ * // Output: Validation succeeds (no error thrown)
65
21
  *
66
22
  * @example
67
23
  * // Input: Non-indexed field
@@ -88,7 +44,7 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
88
44
  * - Ensures performance by requiring indexes on searchable fields
89
45
  * - Part of the validation phase before expensive JOIN operations
90
46
  */
91
- const validateCrossTableField = async (targetScopeName, fieldName, searchedScopes = new Set()) => {
47
+ export const validateCrossTableField = async (scopes, log, targetScopeName, fieldName, searchedScopes = new Set()) => {
92
48
  log.trace('[VALIDATE] Starting validateCrossTableField:', { targetScopeName, fieldName, searchedScopes: Array.from(searchedScopes) });
93
49
 
94
50
  if (searchedScopes.has(targetScopeName)) {
@@ -96,21 +52,21 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
96
52
  }
97
53
 
98
54
  log.trace('[VALIDATE] Getting target schema for:', targetScopeName);
99
- let targetSchema;
55
+ let targetSchemaInstance;
100
56
  try {
101
- targetSchema = scopes[targetScopeName].vars.schemaInfo.schema;;
102
- log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(targetSchema || {}) });
57
+ targetSchemaInstance = scopes[targetScopeName].vars.schemaInfo.schemaInstance;;
58
+ log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(targetSchemaInstance || {}) });
103
59
  } catch (error) {
104
60
  log.trace('[VALIDATE] Error getting target schema:', error.message);
105
61
  throw new Error(`Target scope '${targetScopeName}' not found`);
106
62
  }
107
63
 
108
- if (!targetSchema) {
64
+ if (!targetSchemaInstance) {
109
65
  log.trace('[VALIDATE] Target schema is null/undefined');
110
66
  throw new Error(`Target scope '${targetScopeName}' has no schema`);
111
67
  }
112
68
 
113
- const fieldDef = targetSchema.structure[fieldName];
69
+ const fieldDef = targetSchemaInstance.structure[fieldName];
114
70
  log.trace('[VALIDATE] Field lookup result:', { fieldName, fieldExists: !!fieldDef });
115
71
  if (!fieldDef) {
116
72
  throw new Error(`Field '${fieldName}' not found in scope '${targetScopeName}'`);
@@ -121,20 +77,20 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
121
77
  throw new Error(`Field '${targetScopeName}.${fieldName}' is not indexed. Add 'indexed: true' to allow cross-table search`);
122
78
  }
123
79
 
124
- const result = { targetSchema, fieldDef };
125
80
  log.trace('[VALIDATE] Validation successful for field:', `${targetScopeName}.${fieldName}`);
126
- return result;
127
81
  };
128
82
 
129
- /**
130
- * Builds a chain of SQL JOINs to reach a field in a related table
131
- *
132
- * @async
133
- * @param {string} fromScopeName - Starting scope (e.g., 'articles')
134
- * @param {string} targetPath - Dot-separated path to target field (e.g., 'author.company.name')
135
- * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
136
- * @returns {Promise<Object>} JOIN information for query builder
137
- * @throws {Error} If path is invalid or relationships not properly configured
83
+ /**
84
+ * Builds a chain of SQL JOINs to reach a field in a related table
85
+ *
86
+ * @async
87
+ * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
88
+ * @param {Object} log - Logger instance for debug and trace output
89
+ * @param {string} fromScopeName - Starting scope (e.g., 'articles')
90
+ * @param {string} targetPath - Dot-separated path to target field (e.g., 'author.company.name')
91
+ * @param {Set<string>} [searchedScopes=new Set()] - Internal parameter to prevent circular references
92
+ * @returns {Promise<Object>} JOIN information for query builder
93
+ * @throws {Error} If path is invalid or relationships not properly configured
138
94
  *
139
95
  * @example
140
96
  * // Input: Simple belongsTo relationship
@@ -219,7 +175,7 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
219
175
  * 5. Returns complete JOIN information for SQL query builder
220
176
  * 6. Query builder adds these JOINs to enable filtering on related data
221
177
  */
222
- const buildJoinChain = async (fromScopeName, targetPath, searchedScopes = new Set()) => {
178
+ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, searchedScopes = new Set()) => {
223
179
  log.trace('[BUILD-JOIN] Starting buildJoinChain:', { fromScopeName, targetPath });
224
180
 
225
181
  const pathSegments = targetPath.split('.');
@@ -246,7 +202,7 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
246
202
  searchedScopes.add(currentScope);
247
203
 
248
204
  const schemaInfo = scopes[currentScope].vars.schemaInfo
249
- const currentSchema = schemaInfo.schema;
205
+ const currentSchema = schemaInfo.schemaInstance;
250
206
  const currentRelationships = schemaInfo.schemaRelationships;
251
207
 
252
208
  let foundRelationship = null;
@@ -256,7 +212,7 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
256
212
 
257
213
  if (currentRelationships) {
258
214
  for (const [relName, relDef] of Object.entries(currentRelationships)) {
259
- if (relDef.hasMany === targetScope) {
215
+ if (relDef.type === 'hasMany' && relDef.target === targetScope) {
260
216
  if (relDef.via) {
261
217
  const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships;
262
218
  const viaRel = targetRelationships?.[relDef.via];
@@ -326,9 +282,9 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
326
282
  );
327
283
  }
328
284
 
329
- const sourceSchema = scopes[currentScope].vars.schemaInfo.schema;
285
+ const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance;
330
286
  const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
331
- const targetSchema = scopes[targetScope].vars.schemaInfo.schema;
287
+ const targetSchemaInstance = scopes[targetScope].vars.schemaInfo.schemaInstance;
332
288
  const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
333
289
 
334
290
  if (relationshipType === 'manyToMany') {
@@ -346,7 +302,8 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
346
302
  isOneToMany: true,
347
303
  isPolymorphic: false,
348
304
  relationshipField: foreignKey,
349
- relationshipType: 'manyToMany_pivot'
305
+ relationshipType: 'manyToMany_pivot',
306
+ targetScopeName: through
350
307
  });
351
308
 
352
309
  const targetAlias = `${through}_to_${targetScope}_${targetScope}`;
@@ -359,7 +316,8 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
359
316
  isOneToMany: false,
360
317
  isPolymorphic: false,
361
318
  relationshipField: otherKey,
362
- relationshipType: 'manyToMany_target'
319
+ relationshipType: 'manyToMany_target',
320
+ targetScopeName: targetScope
363
321
  });
364
322
  } else {
365
323
  const joinAlias = `${currentScope}_to_${targetScope}_${targetScope}`;
@@ -384,7 +342,8 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
384
342
  isOneToMany: relationshipType === 'hasMany' || relationshipType === 'hasManyPolymorphic',
385
343
  isPolymorphic: relationshipType === 'hasManyPolymorphic',
386
344
  relationshipField,
387
- relationshipType
345
+ relationshipType,
346
+ targetScopeName: targetScope
388
347
  });
389
348
  }
390
349
 
@@ -392,7 +351,7 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
392
351
  }
393
352
 
394
353
  const finalScope = relationshipPath[relationshipPath.length - 1];
395
- await validateCrossTableField(finalScope, targetFieldName, searchedScopes);
354
+ await validateCrossTableField(scopes, log, finalScope, targetFieldName, searchedScopes);
396
355
 
397
356
  if (joinChain.length === 0) {
398
357
  throw new Error(`Invalid path '${targetPath}': no relationships to traverse`);
@@ -408,7 +367,8 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
408
367
  targetField: targetFieldName,
409
368
  isOneToMany: joinChain.some(j => j.isOneToMany),
410
369
  isMultiLevel: true,
411
- joinChain
370
+ joinChain,
371
+ targetScopeName: finalScope
412
372
  };
413
373
  } else {
414
374
  return {
@@ -418,17 +378,20 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
418
378
  targetField: targetFieldName,
419
379
  joinCondition: lastJoin.joinCondition,
420
380
  isOneToMany: lastJoin.isOneToMany,
421
- isPolymorphic: lastJoin.isPolymorphic
381
+ isPolymorphic: lastJoin.isPolymorphic,
382
+ targetScopeName: lastJoin.targetScopeName || finalScope
422
383
  };
423
384
  }
424
385
  };
425
386
 
426
- /**
427
- * Analyzes a search schema to identify which fields need database indexes
428
- *
429
- * @param {string} scopeName - The scope being analyzed
430
- * @param {Object} searchSchema - Search schema definition with filter fields
431
- * @returns {Array<Object>} Array of required indexes with scope, field, and reason
387
+ /**
388
+ * Analyzes a search schema to identify which fields need database indexes
389
+ *
390
+ * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
391
+ * @param {Object} log - Logger instance for debug and trace output
392
+ * @param {string} scopeName - The scope being analyzed
393
+ * @param {Object} searchSchema - Search schema definition with filter fields
394
+ * @returns {Array<Object>} Array of required indexes with scope, field, and reason
432
395
  *
433
396
  * @example
434
397
  * // Input: Search schema with cross-table references
@@ -486,10 +449,10 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
486
449
  * - Output can be used to create indexes manually or automatically
487
450
  * - Prevents performance issues before queries are executed
488
451
  */
489
- const analyzeRequiredIndexes = (scopeName, searchSchema) => {
452
+ export const analyzeRequiredIndexes = (scopes, log, scopeName, schemaInfo) => {
490
453
  const requiredIndexes = [];
491
454
 
492
- const schemaToAnalyze = searchSchema.structure || searchSchema;
455
+ const schemaToAnalyze = schemaInfo.searchSchemaStructure
493
456
 
494
457
  Object.entries(schemaToAnalyze).forEach(([filterKey, fieldDef]) => {
495
458
  if (fieldDef.actualField && fieldDef.actualField.includes('.')) {
@@ -518,13 +481,15 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
518
481
  return requiredIndexes;
519
482
  };
520
483
 
521
- /**
522
- * Creates database indexes for fields identified by analyzeRequiredIndexes
523
- *
524
- * @async
525
- * @param {Array<Object>} requiredIndexes - Index requirements from analyzeRequiredIndexes
526
- * @param {Object} knex - Knex database connection instance
527
- * @returns {Promise<Array<Object>>} Array of successfully created indexes
484
+ /**
485
+ * Creates database indexes for fields identified by analyzeRequiredIndexes
486
+ *
487
+ * @async
488
+ * @param {Object} scopes - All registered hooked-api scopes containing schema and relationship definitions
489
+ * @param {Object} log - Logger instance for debug and trace output
490
+ * @param {Array<Object>} requiredIndexes - Index requirements from analyzeRequiredIndexes
491
+ * @param {Object} knex - Knex database connection instance
492
+ * @returns {Promise<Array<Object>>} Array of successfully created indexes
528
493
  *
529
494
  * @example
530
495
  * // Input: Required indexes from analysis
@@ -574,12 +539,11 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
574
539
  * - Improves query performance for cross-table searches
575
540
  * - Part of the database optimization phase
576
541
  */
577
- const createRequiredIndexes = async (requiredIndexes, knex) => {
542
+ export const createRequiredIndexes = async (scopes, log, requiredIndexes, knex) => {
578
543
  const createdIndexes = [];
579
544
 
580
545
  for (const indexInfo of requiredIndexes) {
581
546
  const { scope, field } = indexInfo;
582
- const schema = scopes[scope].vars.schemaInfo.schema;
583
547
  const tableName = scopes[scope].vars.schemaInfo.tableName
584
548
  const indexName = `idx_${tableName}_${field}_search`;
585
549
 
@@ -599,11 +563,3 @@ export const createCrossTableSearchHelpers = (scopes, log) => {
599
563
 
600
564
  return createdIndexes;
601
565
  };
602
-
603
- return {
604
- validateCrossTableField,
605
- buildJoinChain,
606
- analyzeRequiredIndexes,
607
- createRequiredIndexes
608
- };
609
- };
@@ -1,5 +1,6 @@
1
1
  import { getForeignKeyFields as getForeignKeyFieldsFromUtils } from '../querying-writing/field-utils.js';
2
2
  import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, getSchemaStructure } from '../querying-writing/knex-constants.js';
3
+ import { getUrlPrefix, buildResourceUrl, buildRelationshipUrl } from './url-helpers.js';
3
4
 
4
5
  /**
5
6
  * Transforms a flat database record into JSON:API resource format
@@ -74,11 +75,7 @@ import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_
74
75
  const toJsonApi = (scope, record, deps) => {
75
76
  if (!record) return null;
76
77
 
77
- const {
78
- vars: {
79
- schemaInfo: { schema }
80
- }
81
- } = scope;
78
+ const schemaInstance = scope.vars.schemaInfo.schemaInstance
82
79
 
83
80
  const { context } = deps;
84
81
  const scopeName = context.scopeName;
@@ -87,7 +84,7 @@ const toJsonApi = (scope, record, deps) => {
87
84
 
88
85
  const { id, ...allAttributes } = record;
89
86
 
90
- const foreignKeys = schema ? getForeignKeyFieldsFromUtils(schema) : new Set();
87
+ const foreignKeys = schemaInstance ? getForeignKeyFieldsFromUtils(schemaInstance) : new Set();
91
88
 
92
89
  const internalFields = new Set([
93
90
  RELATIONSHIPS_KEY,
@@ -187,10 +184,10 @@ const toJsonApi = (scope, record, deps) => {
187
184
  export const toJsonApiRecord = (scope, record, scopeName) => {
188
185
  const {
189
186
  vars: {
190
- schemaInfo: { schema, schemaRelationships: relationships, idProperty }
187
+ schemaInfo: { schemaRelationships: relationships, idProperty }
191
188
  }
192
189
  } = scope;
193
-
190
+
194
191
  const polymorphicFields = new Set();
195
192
  try {
196
193
  Object.entries(relationships || {}).forEach(([relName, relDef]) => {
@@ -350,13 +347,13 @@ export const toJsonApiRecord = (scope, record, scopeName) => {
350
347
  export const buildJsonApiResponse = async (scope, records, included = [], isSingle = false, scopeName, context) => {
351
348
  const {
352
349
  vars: {
353
- schemaInfo: { schema, schemaRelationships: relationships, idProperty }
350
+ schemaInfo: { schemaInstance: schemaInstance, schemaRelationships: relationships, idProperty }
354
351
  }
355
352
  } = scope;
356
353
 
357
354
  const idField = idProperty || 'id';
358
355
 
359
- const schemaStructure = getSchemaStructure(schema);
356
+ const schemaStructure = getSchemaStructure(schemaInstance);
360
357
 
361
358
  const processedRecords = records.map(record => {
362
359
  const { [RELATIONSHIPS_KEY]: _relationships, ...cleanRecord } = record;
@@ -378,10 +375,9 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
378
375
  }
379
376
  };
380
377
 
381
- const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
382
378
  relationshipObject.links = {
383
- self: `${urlPrefix}/${scopeName}/${record[idField]}/relationships/${fieldDef.as}`,
384
- related: `${urlPrefix}/${scopeName}/${record[idField]}/${fieldDef.as}`
379
+ self: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, true),
380
+ related: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, false)
385
381
  };
386
382
 
387
383
  jsonApiRecord.relationships[fieldDef.as] = relationshipObject;
@@ -390,10 +386,9 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
390
386
  data: null
391
387
  };
392
388
 
393
- const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
394
389
  relationshipObject.links = {
395
- self: `${urlPrefix}/${scopeName}/${record[idField]}/relationships/${fieldDef.as}`,
396
- related: `${urlPrefix}/${scopeName}/${record[idField]}/${fieldDef.as}`
390
+ self: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, true),
391
+ related: buildRelationshipUrl(context, scope, scopeName, record[idField], fieldDef.as, false)
397
392
  };
398
393
 
399
394
  jsonApiRecord.relationships[fieldDef.as] = relationshipObject;
@@ -416,10 +411,9 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
416
411
  }
417
412
  };
418
413
 
419
- const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
420
414
  relationshipObject.links = {
421
- self: `${urlPrefix}/${scopeName}/${record[idField]}/relationships/${relName}`,
422
- related: `${urlPrefix}/${scopeName}/${record[idField]}/${relName}`
415
+ self: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, true),
416
+ related: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, false)
423
417
  };
424
418
 
425
419
  jsonApiRecord.relationships[relName] = relationshipObject;
@@ -429,10 +423,9 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
429
423
  data: null
430
424
  };
431
425
 
432
- const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
433
426
  relationshipObject.links = {
434
- self: `${urlPrefix}/${scopeName}/${record[idField]}/relationships/${relName}`,
435
- related: `${urlPrefix}/${scopeName}/${record[idField]}/${relName}`
427
+ self: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, true),
428
+ related: buildRelationshipUrl(context, scope, scopeName, record[idField], relName, false)
436
429
  };
437
430
 
438
431
  jsonApiRecord.relationships[relName] = relationshipObject;
@@ -443,18 +436,18 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
443
436
  return jsonApiRecord;
444
437
  });
445
438
 
446
- const urlPrefix = scope.vars.publicBaseUrl || scope.vars.transport?.mountPath || '';
439
+ const urlPrefix = context.urlPrefix || scope.vars.transport?.mountPath || '';
447
440
  const normalizedData = isSingle ? processedRecords[0] : processedRecords;
448
441
 
449
442
  if (normalizedData) {
450
443
  if (Array.isArray(normalizedData)) {
451
444
  normalizedData.forEach(item => {
452
445
  if (!item.links) item.links = {};
453
- item.links.self = `${urlPrefix}/${scopeName}/${item.id}`;
446
+ item.links.self = buildResourceUrl(context, scope, scopeName, item.id);
454
447
  });
455
448
  } else {
456
449
  if (!normalizedData.links) normalizedData.links = {};
457
- normalizedData.links.self = `${urlPrefix}/${scopeName}/${normalizedData.id}`;
450
+ normalizedData.links.self = buildResourceUrl(context, scope, scopeName, normalizedData.id);
458
451
  }
459
452
  }
460
453
 
@@ -465,7 +458,7 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
465
458
  if (included.length > 0) {
466
459
  included.forEach(item => {
467
460
  if (!item.links) item.links = {};
468
- item.links.self = `${urlPrefix}/${item.type}/${item.id}`;
461
+ item.links.self = buildResourceUrl(context, scope, item.type, item.id);
469
462
  });
470
463
 
471
464
  response.included = included;
@@ -480,9 +473,10 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
480
473
  if (context?.returnMeta?.paginationLinks) {
481
474
  response.links = context.returnMeta.paginationLinks;
482
475
  } else {
476
+ const urlPrefix = getUrlPrefix(context, scope);
483
477
  response.links = {
484
478
  self: isSingle
485
- ? `${urlPrefix}/${scopeName}/${normalizedData.id}`
479
+ ? buildResourceUrl(context, scope, scopeName, normalizedData.id)
486
480
  : `${urlPrefix}/${scopeName}${context?.returnMeta?.queryString || ''}`
487
481
  };
488
482
  }
@@ -99,11 +99,11 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
99
99
  *
100
100
  * // Output - complete URLs with all parameters preserved:
101
101
  * // {
102
- * // self: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=2&page[size]=20',
103
- * // first: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
104
- * // last: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=5&page[size]=20',
105
- * // prev: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
106
- * // next: '/api/v1/articles?filter[status]=published&sort=-created_at&page[number]=3&page[size]=20'
102
+ * // self: '/api/articles?filter[status]=published&sort=-created_at&page[number]=2&page[size]=20',
103
+ * // first: '/api/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
104
+ * // last: '/api/articles?filter[status]=published&sort=-created_at&page[number]=5&page[size]=20',
105
+ * // prev: '/api/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
106
+ * // next: '/api/articles?filter[status]=published&sort=-created_at&page[number]=3&page[size]=20'
107
107
  * // }
108
108
  *
109
109
  * @example
@@ -137,33 +137,41 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
137
137
  * 4. Added to response.links for client navigation
138
138
  */
139
139
  export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, paginationMeta) => {
140
- if (!urlPrefix) return null;
140
+ // Allow empty urlPrefix to generate relative links
141
+ // if (!urlPrefix) return null;
141
142
 
142
143
  const { page, pageCount, pageSize } = paginationMeta;
143
144
  const links = {};
144
145
 
145
146
  const otherParams = Object.entries(queryParams)
146
147
  .filter(([key]) => key !== 'page')
147
- .map(([key, value]) => {
148
+ .flatMap(([key, value]) => {
148
149
  if (Array.isArray(value)) {
149
- return value.map(v => `${key}=${encodeURIComponent(v)}`).join('&');
150
- } else if (typeof value === 'object' && value !== null) {
150
+ return value.length === 0
151
+ ? []
152
+ : value.map((v) => `${key}=${encodeURIComponent(v)}`);
153
+ }
154
+ if (typeof value === 'object' && value !== null) {
151
155
  const parts = [];
152
156
  const processObject = (obj, prefix) => {
153
157
  Object.entries(obj).forEach(([k, v]) => {
154
158
  const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`;
155
159
  if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
156
160
  processObject(v, newKey);
157
- } else {
161
+ } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
158
162
  parts.push(`${newKey}=${encodeURIComponent(v)}`);
159
163
  }
160
164
  });
161
165
  };
162
166
  processObject(value, key);
163
- return parts.join('&');
167
+ return parts;
168
+ }
169
+ if (value === undefined || value === null) {
170
+ return [];
164
171
  }
165
- return `${key}=${encodeURIComponent(value)}`;
172
+ return [`${key}=${encodeURIComponent(value)}`];
166
173
  })
174
+ .filter(Boolean)
167
175
  .join('&');
168
176
 
169
177
  const baseUrl = `${urlPrefix}/${scopeName}`;
@@ -420,33 +428,41 @@ export const parseCursor = (cursor) => {
420
428
  * 5. Client uses next link to fetch subsequent pages
421
429
  */
422
430
  export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
423
- if (!urlPrefix || !records.length) return null;
431
+ // Allow empty urlPrefix to generate relative links
432
+ if (!records.length) return null;
424
433
 
425
434
  const links = {};
426
435
  const baseUrl = `${urlPrefix}/${scopeName}`;
427
436
 
428
437
  const otherParams = Object.entries(queryParams)
429
438
  .filter(([key]) => key !== 'page')
430
- .map(([key, value]) => {
439
+ .flatMap(([key, value]) => {
431
440
  if (Array.isArray(value)) {
432
- return value.map(v => `${key}=${encodeURIComponent(v)}`).join('&');
433
- } else if (typeof value === 'object' && value !== null) {
441
+ return value.length === 0
442
+ ? []
443
+ : value.map((v) => `${key}=${encodeURIComponent(v)}`);
444
+ }
445
+ if (typeof value === 'object' && value !== null) {
434
446
  const parts = [];
435
447
  const processObject = (obj, prefix) => {
436
448
  Object.entries(obj).forEach(([k, v]) => {
437
449
  const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`;
438
450
  if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
439
451
  processObject(v, newKey);
440
- } else {
452
+ } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
441
453
  parts.push(`${newKey}=${encodeURIComponent(v)}`);
442
454
  }
443
455
  });
444
456
  };
445
457
  processObject(value, key);
446
- return parts.join('&');
458
+ return parts;
447
459
  }
448
- return `${key}=${encodeURIComponent(value)}`;
460
+ if (value === undefined || value === null) {
461
+ return [];
462
+ }
463
+ return [`${key}=${encodeURIComponent(value)}`];
449
464
  })
465
+ .filter(Boolean)
450
466
  .join('&');
451
467
 
452
468
  const queryPrefix = otherParams ? `?${otherParams}&` : '?';
@@ -541,4 +557,4 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
541
557
  }
542
558
 
543
559
  return meta;
544
- };
560
+ };
@@ -55,20 +55,55 @@
55
55
  * 4. Smaller result sets improve query performance
56
56
  * 5. JSON:API response includes only requested attributes
57
57
  */
58
- export const buildQuerySelection = (query, tableName, fieldsToSelect, useTablePrefix = false) => {
58
+ export const buildQuerySelection = (
59
+ query,
60
+ tableName,
61
+ fieldsToSelect,
62
+ useTablePrefix = false,
63
+ options = {},
64
+ ) => {
65
+ const translateColumn = options.translateColumn;
66
+
67
+ const applyTranslation = (field, aliasForField) => {
68
+ if (translateColumn) {
69
+ const translated = translateColumn(field, aliasForField);
70
+ if (translated) {
71
+ return translated;
72
+ }
73
+ }
74
+
75
+ if (aliasForField) {
76
+ return `${aliasForField}.${field}`;
77
+ }
78
+ return field;
79
+ };
80
+
81
+ const targetAlias = useTablePrefix ? tableName : null;
82
+
59
83
  if (fieldsToSelect === '*') {
84
+ const translated = translateColumn ? translateColumn('*', targetAlias) : null;
85
+ if (translated) {
86
+ return query.select(translated);
87
+ }
60
88
  return useTablePrefix ? query.select(`${tableName}.*`) : query;
61
- } else {
62
- const fields = useTablePrefix
63
- ? fieldsToSelect.map(field => {
64
- // Handle aliased fields (e.g., "user_id as id")
65
- if (field.includes(' as ')) {
66
- const [realField, alias] = field.split(' as ');
67
- return `${tableName}.${realField.trim()} as ${alias.trim()}`;
68
- }
69
- return `${tableName}.${field}`;
70
- })
71
- : fieldsToSelect;
72
- return query.select(fields);
73
89
  }
74
- };
90
+
91
+ const fieldsArray = Array.isArray(fieldsToSelect)
92
+ ? fieldsToSelect
93
+ : Array.from(fieldsToSelect || []);
94
+
95
+ const translatedFields = fieldsArray.map((field) => {
96
+ if (typeof field !== 'string') return field;
97
+
98
+ const aliasMatch = field.match(/\s+as\s+/i);
99
+ if (aliasMatch) {
100
+ const [source, alias] = field.split(/\s+as\s+/i);
101
+ const translatedSource = applyTranslation(source.trim(), targetAlias);
102
+ return `${translatedSource} as ${alias.trim()}`;
103
+ }
104
+
105
+ return applyTranslation(field.trim(), targetAlias);
106
+ });
107
+
108
+ return query.select(translatedFields);
109
+ };