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.
- package/.claude/settings.local.json +9 -1
- package/AGENTS.md +34 -0
- package/CLAUDE.md +26 -1
- package/CRUSH.md +55 -0
- package/README.md +3 -3
- package/TODO.todo +8 -0
- package/docs/API.md +17 -18
- package/docs/COMPARISON.md +18 -13
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
- package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
- package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
- package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
- package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
- package/docs/GUIDE/index.md +3 -0
- package/docs/QUICKSTART.md +12 -12
- package/docs/index.md +2 -2
- package/docs/mini-guides/file-uploads-complete.md +0 -1
- package/examples/url-override-example.js +137 -0
- package/index.js +7 -1
- package/package.json +5 -9
- package/plugins/core/bulk-operations-plugin.js +2 -0
- package/plugins/core/connectors/express-plugin.js +43 -24
- package/plugins/core/connectors/lib/request-helpers.js +2 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
- package/plugins/core/lib/anyapi/schema-utils.js +186 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
- package/plugins/core/lib/dbTablesOperations.js +305 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
- package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
- package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
- package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
- package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
- package/plugins/core/lib/querying/url-helpers.js +119 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
- package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
- package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
- package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
- package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
- package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
- package/plugins/core/lib/storage/storage-adapter.js +160 -0
- package/plugins/core/lib/writing/relationship-processor.js +4 -11
- package/plugins/core/rest-api-access.js +456 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
- package/plugins/core/rest-api-knex-plugin.js +226 -87
- package/plugins/core/rest-api-multihome-plugin.js +89 -27
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
- package/plugins/core/rest-api-plugin-methods/common.js +46 -10
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
- package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
- package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
- package/plugins/core/rest-api-plugin-methods/get.js +2 -5
- package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
- package/plugins/core/rest-api-plugin-methods/post.js +23 -11
- package/plugins/core/rest-api-plugin-methods/put.js +18 -21
- package/plugins/core/rest-api-plugin-methods/query.js +3 -4
- package/plugins/core/rest-api-plugin-methods/release.js +13 -0
- package/plugins/core/rest-api-plugin.js +3 -4
- package/plugins/core/rest-api-positioning-plugin.js +253 -76
- package/plugins/core/socketio-plugin.js +436 -502
- package/quickTest.js +2 -2
- package/scripts/debug-anyapi-review-filter.mjs +63 -0
- package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
- package/scripts/inspect-anyapi-descriptor.mjs +19 -0
- package/test-debug.js +59 -0
- package/tests/README.md +7 -1
- package/tests/anyapi-basic.test.js +102 -0
- package/tests/anyapi-cursor-pagination.test.js +144 -0
- package/tests/computed-fields.test.js +27 -12
- package/tests/cors.test.js +5 -6
- package/tests/curl.test.js +1 -1
- package/tests/custom-idproperty-relationships.test.js +37 -29
- package/tests/field-getters.test.js +25 -7
- package/tests/fixtures/api-configs.js +576 -150
- package/tests/helpers/storage-mode.js +65 -0
- package/tests/helpers/test-utils.js +85 -3
- package/tests/include-depth-validation.test.js +35 -1
- package/tests/nested-includes.test.js +130 -103
- package/tests/pagination-cursor-multifield.test.js +17 -13
- package/tests/pagination-enhanced.test.js +19 -24
- package/tests/polymorphic-simple.test.js +9 -9
- package/tests/positioning-advanced.test.js +19 -19
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +62 -62
- package/tests/positioning.test.js +7 -7
- package/tests/queries.test.js +5 -5
- package/tests/query-limits.test.js +77 -92
- package/tests/rest-api-access.test.js +206 -0
- package/tests/searchschema-merge.test.js +182 -0
- package/tests/socketio.test.js +33 -14
- package/tests/virtual-fields.test.js +0 -1
- package/docs/DOCS_ALL.md +0 -21894
- package/plugins/core/jwt-auth-plugin.js +0 -897
- package/plugins/core/lib/jwt-auth-helpers.js +0 -130
- 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
|
-
*
|
|
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
|
-
* @
|
|
7
|
-
*
|
|
8
|
-
* @
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
18
|
+
* await validateCrossTableField(scopes, log, 'companies', 'name');
|
|
59
19
|
*
|
|
60
|
-
* // Output:
|
|
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
|
-
|
|
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
|
|
55
|
+
let targetSchemaInstance;
|
|
100
56
|
try {
|
|
101
|
-
|
|
102
|
-
log.trace('[VALIDATE] Got target schema:', { scopeName: targetScopeName, schemaKeys: Object.keys(
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
285
|
+
const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance;
|
|
330
286
|
const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
|
|
331
|
-
const
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
452
|
+
export const analyzeRequiredIndexes = (scopes, log, scopeName, schemaInfo) => {
|
|
490
453
|
const requiredIndexes = [];
|
|
491
454
|
|
|
492
|
-
const schemaToAnalyze =
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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 =
|
|
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: {
|
|
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: {
|
|
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(
|
|
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:
|
|
384
|
-
related:
|
|
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:
|
|
396
|
-
related:
|
|
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:
|
|
422
|
-
related:
|
|
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:
|
|
435
|
-
related:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
?
|
|
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/
|
|
103
|
-
* // first: '/api/
|
|
104
|
-
* // last: '/api/
|
|
105
|
-
* // prev: '/api/
|
|
106
|
-
* // next: '/api/
|
|
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
|
-
|
|
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
|
-
.
|
|
148
|
+
.flatMap(([key, value]) => {
|
|
148
149
|
if (Array.isArray(value)) {
|
|
149
|
-
return value.
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
439
|
+
.flatMap(([key, value]) => {
|
|
431
440
|
if (Array.isArray(value)) {
|
|
432
|
-
return value.
|
|
433
|
-
|
|
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
|
|
458
|
+
return parts;
|
|
447
459
|
}
|
|
448
|
-
|
|
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 = (
|
|
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
|
+
};
|