json-rest-api 1.0.10 → 1.0.13
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 +7 -11
- 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,3 +1,57 @@
|
|
|
1
|
+
import { analyzeRequiredIndexes, buildJoinChain } from './knex-cross-table-search.js';
|
|
2
|
+
|
|
3
|
+
const createAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
|
|
4
|
+
const context = hookParams.context || {};
|
|
5
|
+
const storageCache = new Map();
|
|
6
|
+
|
|
7
|
+
const fetchStorageAdapter = (scopeName) => {
|
|
8
|
+
if (!scopeName) return null;
|
|
9
|
+
if (storageCache.has(scopeName)) return storageCache.get(scopeName);
|
|
10
|
+
|
|
11
|
+
let adapter = null;
|
|
12
|
+
if (scopeName === context?.knexQuery?.scopeName) {
|
|
13
|
+
adapter = context.storageAdapter
|
|
14
|
+
|| context.knexQuery?.storageAdapter
|
|
15
|
+
|| (typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null);
|
|
16
|
+
if (adapter && !context.storageAdapter) {
|
|
17
|
+
context.storageAdapter = adapter;
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
adapter = typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
storageCache.set(scopeName, adapter);
|
|
24
|
+
return adapter;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const defaultAliasForScope = (scopeName) => {
|
|
28
|
+
if (scopeName === context?.knexQuery?.scopeName) {
|
|
29
|
+
return context.knexQuery?.tableName || scopeName;
|
|
30
|
+
}
|
|
31
|
+
return scopeName;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const translateColumn = (scopeName, field, alias = defaultAliasForScope(scopeName)) => {
|
|
35
|
+
const adapter = fetchStorageAdapter(scopeName);
|
|
36
|
+
const translated = adapter?.translateColumn?.(field) ?? field;
|
|
37
|
+
if (!alias) return translated;
|
|
38
|
+
return `${alias}.${translated}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const translateFilterValue = (scopeName, field, value) => {
|
|
42
|
+
const adapter = fetchStorageAdapter(scopeName);
|
|
43
|
+
if (!adapter?.translateFilterValue) return value;
|
|
44
|
+
return adapter.translateFilterValue(field, value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
fetchStorageAdapter,
|
|
49
|
+
defaultAliasForScope,
|
|
50
|
+
translateColumn,
|
|
51
|
+
translateFilterValue,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
1
55
|
/**
|
|
2
56
|
* Processes filters that target polymorphic relationships where a single relationship can point to different types of resources
|
|
3
57
|
*
|
|
@@ -73,16 +127,19 @@
|
|
|
73
127
|
*/
|
|
74
128
|
export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
75
129
|
const { log, scopes, knex } = dependencies;
|
|
130
|
+
const adapterUtils = createAdapterUtilities(hookParams, dependencies);
|
|
76
131
|
|
|
77
132
|
// Extract context
|
|
78
133
|
const scopeName = hookParams.context?.knexQuery?.scopeName;
|
|
79
134
|
const filters = hookParams.context?.knexQuery?.filters;
|
|
80
|
-
const searchSchema = hookParams.context?.knexQuery?.searchSchema;
|
|
81
135
|
const query = hookParams.context?.knexQuery?.query;
|
|
82
|
-
const tableName = hookParams.context?.knexQuery?.tableName;
|
|
83
136
|
const db = hookParams.context?.knexQuery?.db || knex;
|
|
84
137
|
|
|
85
|
-
|
|
138
|
+
const schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
139
|
+
const tableAlias = adapterUtils.defaultAliasForScope(scopeName);
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if (!filters) {
|
|
86
143
|
return;
|
|
87
144
|
}
|
|
88
145
|
|
|
@@ -91,7 +148,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
91
148
|
const polymorphicJoins = new Map();
|
|
92
149
|
|
|
93
150
|
for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
94
|
-
const fieldDef =
|
|
151
|
+
const fieldDef = schemaInfo.searchSchemaStructure[filterKey];
|
|
95
152
|
|
|
96
153
|
if (fieldDef?.polymorphicField && fieldDef?.targetFields && filterValue !== undefined) {
|
|
97
154
|
log.trace('[POLYMORPHIC-SEARCH] Found polymorphic search:', {
|
|
@@ -131,12 +188,13 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
131
188
|
|
|
132
189
|
// Build JOINs for each target type
|
|
133
190
|
for (const [targetType, targetFieldPath] of Object.entries(fieldDef.targetFields)) {
|
|
134
|
-
const baseAlias = `${
|
|
191
|
+
const baseAlias = `${tableAlias}_${polymorphicField}_${targetType}`;
|
|
135
192
|
|
|
136
193
|
// Skip if we already added this JOIN
|
|
137
194
|
if (!polymorphicJoins.has(baseAlias)) {
|
|
138
|
-
const targetSchema = scopes[targetType].vars.schemaInfo.
|
|
195
|
+
const targetSchema = scopes[targetType].vars.schemaInfo.schemaInstance;
|
|
139
196
|
const targetTable = targetSchema?.tableName || targetType;
|
|
197
|
+
const targetIdField = scopes[targetType].vars.schemaInfo.idProperty || 'id';
|
|
140
198
|
|
|
141
199
|
log.trace('[POLYMORPHIC-SEARCH] Adding conditional JOIN:', {
|
|
142
200
|
targetType,
|
|
@@ -145,8 +203,12 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
145
203
|
|
|
146
204
|
// Conditional JOIN - only matches when type is correct
|
|
147
205
|
query.leftJoin(`${targetTable} as ${baseAlias}`, function() {
|
|
148
|
-
|
|
149
|
-
|
|
206
|
+
const typeColumn = adapterUtils.translateColumn(scopeName, typeField, tableAlias);
|
|
207
|
+
const idColumn = adapterUtils.translateColumn(scopeName, idField, tableAlias);
|
|
208
|
+
const targetIdColumn = adapterUtils.translateColumn(targetType, targetIdField, baseAlias);
|
|
209
|
+
|
|
210
|
+
this.on(typeColumn, db.raw('?', [adapterUtils.translateFilterValue(scopeName, typeField, targetType)]))
|
|
211
|
+
.andOn(idColumn, targetIdColumn);
|
|
150
212
|
});
|
|
151
213
|
|
|
152
214
|
polymorphicJoins.set(baseAlias, {
|
|
@@ -154,7 +216,14 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
154
216
|
targetTable,
|
|
155
217
|
targetFieldPath
|
|
156
218
|
});
|
|
157
|
-
|
|
219
|
+
polymorphicJoins.get(baseAlias).baseAlias = baseAlias;
|
|
220
|
+
polymorphicJoins.get(baseAlias).targetIdField = targetIdField;
|
|
221
|
+
|
|
222
|
+
if (!polymorphicJoins.get(baseAlias).aliasScopeMap) {
|
|
223
|
+
polymorphicJoins.get(baseAlias).aliasScopeMap = new Map();
|
|
224
|
+
}
|
|
225
|
+
polymorphicJoins.get(baseAlias).aliasScopeMap.set(baseAlias, targetType);
|
|
226
|
+
|
|
158
227
|
// Handle cross-table paths
|
|
159
228
|
if (targetFieldPath.includes('.')) {
|
|
160
229
|
log.trace('[POLYMORPHIC-SEARCH] Building cross-table JOINs for path:', targetFieldPath);
|
|
@@ -168,7 +237,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
168
237
|
const relationshipName = pathParts[i];
|
|
169
238
|
|
|
170
239
|
// Find the foreign key for this relationship
|
|
171
|
-
const currentSchema = scopes[currentScope].vars.schemaInfo.
|
|
240
|
+
const currentSchema = scopes[currentScope].vars.schemaInfo.schemaInstance;
|
|
172
241
|
let foreignKeyField = null;
|
|
173
242
|
let nextScope = null;
|
|
174
243
|
|
|
@@ -199,7 +268,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
199
268
|
|
|
200
269
|
// Build next JOIN
|
|
201
270
|
const nextAlias = `${currentAlias}_${relationshipName}`;
|
|
202
|
-
const nextSchema = scopes[nextScope].vars.schemaInfo.
|
|
271
|
+
const nextSchema = scopes[nextScope].vars.schemaInfo.schemaInstance;
|
|
203
272
|
const nextTable = nextSchema?.tableName || nextScope;
|
|
204
273
|
|
|
205
274
|
log.trace('[POLYMORPHIC-SEARCH] Adding cross-table JOIN:', {
|
|
@@ -208,13 +277,16 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
208
277
|
table: nextTable
|
|
209
278
|
});
|
|
210
279
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
280
|
+
const nextIdField = scopes[nextScope].vars.schemaInfo.idProperty || 'id';
|
|
281
|
+
const sourceColumn = adapterUtils.translateColumn(currentScope, foreignKeyField, currentAlias);
|
|
282
|
+
const targetColumn = adapterUtils.translateColumn(nextScope, nextIdField, nextAlias);
|
|
283
|
+
|
|
284
|
+
query.leftJoin(`${nextTable} as ${nextAlias}`, sourceColumn, targetColumn);
|
|
285
|
+
|
|
216
286
|
currentAlias = nextAlias;
|
|
217
287
|
currentScope = nextScope;
|
|
288
|
+
|
|
289
|
+
polymorphicJoins.get(baseAlias).aliasScopeMap.set(currentAlias, currentScope);
|
|
218
290
|
}
|
|
219
291
|
}
|
|
220
292
|
}
|
|
@@ -236,8 +308,36 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
236
308
|
hookParams.context.knexQuery.hasJoins = true;
|
|
237
309
|
|
|
238
310
|
// Step 3: Apply WHERE conditions
|
|
239
|
-
query.where(function() {
|
|
240
|
-
|
|
311
|
+
query.where(function applyPolymorphicWhere() {
|
|
312
|
+
const applyComparison = (builder, scope, alias, field, operator, rawValue) => {
|
|
313
|
+
const columnRef = adapterUtils.translateColumn(scope, field, alias);
|
|
314
|
+
const normalizedValue = adapterUtils.translateFilterValue(scope, field, rawValue);
|
|
315
|
+
const op = operator || '=';
|
|
316
|
+
|
|
317
|
+
const lowerOp = typeof op === 'string' ? op.toLowerCase() : op;
|
|
318
|
+
if (normalizedValue === null && (lowerOp === '=' || lowerOp === '==')) {
|
|
319
|
+
builder.whereNull(columnRef);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (lowerOp === 'like') {
|
|
324
|
+
if (normalizedValue === null || normalizedValue === undefined) {
|
|
325
|
+
builder.whereNull(columnRef);
|
|
326
|
+
} else {
|
|
327
|
+
builder.where(columnRef, 'like', `%${normalizedValue}%`);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (Array.isArray(normalizedValue) && lowerOp === 'in') {
|
|
333
|
+
builder.whereIn(columnRef, normalizedValue);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
builder.where(columnRef, op, normalizedValue);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
for (const [filterKey] of Object.entries(filters)) {
|
|
241
341
|
if (!polymorphicSearches.has(filterKey)) continue;
|
|
242
342
|
|
|
243
343
|
const searchInfo = polymorphicSearches.get(filterKey);
|
|
@@ -246,18 +346,17 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
246
346
|
|
|
247
347
|
const { typeField } = polyRel.belongsToPolymorphic;
|
|
248
348
|
|
|
249
|
-
|
|
250
|
-
this.where(function() {
|
|
349
|
+
this.where(function applyTypeOrBranch() {
|
|
251
350
|
for (const [targetType, targetFieldPath] of Object.entries(searchInfo.fieldDef.targetFields)) {
|
|
252
|
-
this.orWhere(function() {
|
|
253
|
-
|
|
254
|
-
this
|
|
351
|
+
this.orWhere(function applyTargetBranch() {
|
|
352
|
+
const typeColumnAlias = tableAlias;
|
|
353
|
+
applyComparison(this, scopeName, typeColumnAlias, typeField, '=', targetType);
|
|
255
354
|
|
|
256
|
-
|
|
257
|
-
const
|
|
355
|
+
const baseAlias = `${tableAlias}_${searchInfo.polymorphicField}_${targetType}`;
|
|
356
|
+
const joinMeta = polymorphicJoins.get(baseAlias);
|
|
357
|
+
const aliasScopeMap = joinMeta?.aliasScopeMap || new Map([[baseAlias, targetType]]);
|
|
258
358
|
|
|
259
359
|
if (targetFieldPath.includes('.')) {
|
|
260
|
-
// Complex path
|
|
261
360
|
const pathParts = targetFieldPath.split('.');
|
|
262
361
|
const fieldName = pathParts[pathParts.length - 1];
|
|
263
362
|
|
|
@@ -266,20 +365,13 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
266
365
|
finalAlias = `${finalAlias}_${pathParts[i]}`;
|
|
267
366
|
}
|
|
268
367
|
|
|
368
|
+
const finalScope = aliasScopeMap.get(finalAlias) || targetType;
|
|
269
369
|
const operator = searchInfo.fieldDef.filterOperator || '=';
|
|
270
|
-
|
|
271
|
-
this.where(`${finalAlias}.${fieldName}`, 'like', `%${searchInfo.filterValue}%`);
|
|
272
|
-
} else {
|
|
273
|
-
this.where(`${finalAlias}.${fieldName}`, operator, searchInfo.filterValue);
|
|
274
|
-
}
|
|
370
|
+
applyComparison(this, finalScope, finalAlias, fieldName, operator, searchInfo.filterValue);
|
|
275
371
|
} else {
|
|
276
|
-
// Direct field
|
|
277
372
|
const operator = searchInfo.fieldDef.filterOperator || '=';
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
} else {
|
|
281
|
-
this.where(`${baseAlias}.${targetFieldPath}`, operator, searchInfo.filterValue);
|
|
282
|
-
}
|
|
373
|
+
const finalScope = aliasScopeMap.get(baseAlias) || targetType;
|
|
374
|
+
applyComparison(this, finalScope, baseAlias, targetFieldPath, operator, searchInfo.filterValue);
|
|
283
375
|
}
|
|
284
376
|
});
|
|
285
377
|
}
|
|
@@ -381,22 +473,27 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
381
473
|
* 6. Sets hasJoins flag for basicFiltersHook to use
|
|
382
474
|
*/
|
|
383
475
|
export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
384
|
-
const { log, scopes, knex
|
|
476
|
+
const { log, scopes, knex } = dependencies;
|
|
477
|
+
const adapterUtils = createAdapterUtilities(hookParams, dependencies);
|
|
385
478
|
|
|
386
479
|
// Extract context
|
|
387
480
|
const scopeName = hookParams.context?.knexQuery?.scopeName;
|
|
388
481
|
const filters = hookParams.context?.knexQuery?.filters;
|
|
389
|
-
const searchSchema = hookParams.context?.knexQuery?.searchSchema;
|
|
390
482
|
const query = hookParams.context?.knexQuery?.query;
|
|
391
|
-
const tableName = hookParams.context?.knexQuery?.tableName;
|
|
392
483
|
const db = hookParams.context?.knexQuery?.db || knex;
|
|
484
|
+
|
|
485
|
+
const schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
486
|
+
const tableName = schemaInfo.tableName;
|
|
487
|
+
const tableAlias = adapterUtils.defaultAliasForScope(scopeName);
|
|
488
|
+
const aliasScopeMap = new Map();
|
|
489
|
+
aliasScopeMap.set(tableAlias, scopeName);
|
|
393
490
|
|
|
394
|
-
if (!filters
|
|
491
|
+
if (!filters) {
|
|
395
492
|
return;
|
|
396
493
|
}
|
|
397
494
|
|
|
398
495
|
// Step 1: Analyze indexes
|
|
399
|
-
const requiredIndexes =
|
|
496
|
+
const requiredIndexes = analyzeRequiredIndexes(scopes, log, scopeName, schemaInfo);
|
|
400
497
|
if (requiredIndexes.length > 0) {
|
|
401
498
|
log.debug(`Cross-table search requires indexes:`, requiredIndexes);
|
|
402
499
|
}
|
|
@@ -406,7 +503,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
406
503
|
const fieldPathMap = new Map();
|
|
407
504
|
let hasCrossTableFilters = false;
|
|
408
505
|
|
|
409
|
-
for (const [filterKey, fieldDef] of Object.entries(
|
|
506
|
+
for (const [filterKey, fieldDef] of Object.entries(schemaInfo.searchSchemaStructure)) {
|
|
410
507
|
if (filters[filterKey] === undefined) continue;
|
|
411
508
|
|
|
412
509
|
// Skip polymorphic filters
|
|
@@ -416,7 +513,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
416
513
|
if (fieldDef.actualField?.includes('.')) {
|
|
417
514
|
hasCrossTableFilters = true;
|
|
418
515
|
log.trace('[JOIN-DETECTION] Cross-table actualField found', { filterKey, actualField: fieldDef.actualField, scopeName });
|
|
419
|
-
const joinInfo = await
|
|
516
|
+
const joinInfo = await buildJoinChain(scopes, log, scopeName, fieldDef.actualField);
|
|
420
517
|
if (!joinMap.has(joinInfo.joinAlias)) {
|
|
421
518
|
joinMap.set(joinInfo.joinAlias, joinInfo);
|
|
422
519
|
}
|
|
@@ -429,7 +526,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
429
526
|
if (field.includes('.')) {
|
|
430
527
|
hasCrossTableFilters = true;
|
|
431
528
|
log.trace('[JOIN-DETECTION] Cross-table oneOf field found', { filterKey, field, scopeName });
|
|
432
|
-
const joinInfo = await
|
|
529
|
+
const joinInfo = await buildJoinChain(scopes, log, scopeName, field);
|
|
433
530
|
if (!joinMap.has(joinInfo.joinAlias)) {
|
|
434
531
|
joinMap.set(joinInfo.joinAlias, joinInfo);
|
|
435
532
|
}
|
|
@@ -443,76 +540,113 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
443
540
|
return;
|
|
444
541
|
}
|
|
445
542
|
|
|
446
|
-
// Step 3: Apply JOINs
|
|
447
|
-
const appliedJoins = new Set();
|
|
448
|
-
|
|
449
543
|
joinMap.forEach((joinInfo) => {
|
|
450
|
-
if (joinInfo.
|
|
544
|
+
if (joinInfo.joinAlias) {
|
|
545
|
+
aliasScopeMap.set(joinInfo.joinAlias, joinInfo.targetScopeName || joinInfo.targetTableName);
|
|
546
|
+
}
|
|
547
|
+
if (joinInfo.isMultiLevel && Array.isArray(joinInfo.joinChain)) {
|
|
451
548
|
joinInfo.joinChain.forEach((join) => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// Check if this is a polymorphic join with AND condition
|
|
455
|
-
if (join.isPolymorphic && join.joinCondition.includes(' AND ')) {
|
|
456
|
-
query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function() {
|
|
457
|
-
// Parse polymorphic condition: type_field = 'value' AND id_field = parent.id
|
|
458
|
-
const parts = join.joinCondition.split(' AND ');
|
|
459
|
-
const [typeCondition, idCondition] = parts;
|
|
460
|
-
|
|
461
|
-
// Extract the type field and value from first part
|
|
462
|
-
const typeMatch = typeCondition.match(/(.+?)\s*=\s*'(.+?)'/);
|
|
463
|
-
if (typeMatch) {
|
|
464
|
-
const typeField = typeMatch[1].trim();
|
|
465
|
-
const typeValue = typeMatch[2];
|
|
466
|
-
this.on(typeField, db.raw('?', [typeValue]));
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Extract the id fields from second part
|
|
470
|
-
const idMatch = idCondition.match(/(.+?)\s*=\s*(.+)/);
|
|
471
|
-
if (idMatch) {
|
|
472
|
-
this.andOn(idMatch[1].trim(), idMatch[2].trim());
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
} else {
|
|
476
|
-
const [leftSide, rightSide] = join.joinCondition.split(' = ');
|
|
477
|
-
query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function() {
|
|
478
|
-
this.on(leftSide, rightSide);
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
appliedJoins.add(joinKey);
|
|
549
|
+
if (join.joinAlias) {
|
|
550
|
+
aliasScopeMap.set(join.joinAlias, join.targetScopeName || join.targetTableName);
|
|
482
551
|
}
|
|
483
552
|
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const translateQualifiedColumn = (qualified) => {
|
|
557
|
+
const trimmed = qualified.trim();
|
|
558
|
+
if (!trimmed.includes('.')) {
|
|
559
|
+
return adapterUtils.translateColumn(scopeName, trimmed, tableAlias);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const [alias, ...fieldParts] = trimmed.split('.');
|
|
563
|
+
const field = fieldParts.join('.');
|
|
564
|
+
const scopeForAlias = aliasScopeMap.get(alias) || scopeName;
|
|
565
|
+
return adapterUtils.translateColumn(scopeForAlias, field, alias);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const resolveFieldColumn = (field) => {
|
|
569
|
+
if (field.includes('.')) {
|
|
570
|
+
const qualified = fieldPathMap.get(field) || field;
|
|
571
|
+
return translateQualifiedColumn(qualified);
|
|
572
|
+
}
|
|
573
|
+
return adapterUtils.translateColumn(scopeName, field, tableAlias);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const normalizeFieldValue = (field, value) => {
|
|
577
|
+
if (field.includes('.')) {
|
|
578
|
+
const qualified = fieldPathMap.get(field) || field;
|
|
579
|
+
const [alias, ...rest] = qualified.split('.');
|
|
580
|
+
const fieldName = rest.join('.');
|
|
581
|
+
const valueScope = aliasScopeMap.get(alias) || scopeName;
|
|
582
|
+
if (Array.isArray(value)) {
|
|
583
|
+
return value.map((entry) => adapterUtils.translateFilterValue(valueScope, fieldName, entry));
|
|
515
584
|
}
|
|
585
|
+
return adapterUtils.translateFilterValue(valueScope, fieldName, value);
|
|
586
|
+
}
|
|
587
|
+
if (Array.isArray(value)) {
|
|
588
|
+
return value.map((entry) => adapterUtils.translateFilterValue(scopeName, field, entry));
|
|
589
|
+
}
|
|
590
|
+
return adapterUtils.translateFilterValue(scopeName, field, value);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// Step 3: Apply JOINs
|
|
594
|
+
const appliedJoins = new Set();
|
|
595
|
+
|
|
596
|
+
const applyPolymorphicJoin = (join) => {
|
|
597
|
+
query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function() {
|
|
598
|
+
const parts = join.joinCondition.split(' AND ');
|
|
599
|
+
const [typeCondition, idCondition] = parts;
|
|
600
|
+
|
|
601
|
+
const typeMatch = typeCondition.match(/(.+?)\s*=\s*'(.+?)'/);
|
|
602
|
+
if (typeMatch) {
|
|
603
|
+
const typeColumnToken = typeMatch[1].trim();
|
|
604
|
+
const typeAlias = typeColumnToken.split('.')[0];
|
|
605
|
+
const typeField = typeColumnToken.split('.').slice(1).join('.');
|
|
606
|
+
const typeScope = aliasScopeMap.get(typeAlias) || scopeName;
|
|
607
|
+
const translatedColumn = translateQualifiedColumn(typeColumnToken);
|
|
608
|
+
const translatedValue = adapterUtils.translateFilterValue(typeScope, typeField, typeMatch[2]);
|
|
609
|
+
this.on(translatedColumn, db.raw('?', [translatedValue]));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const idMatch = idCondition.match(/(.+?)\s*=\s*(.+)/);
|
|
613
|
+
if (idMatch) {
|
|
614
|
+
const leftToken = idMatch[1].trim();
|
|
615
|
+
const rightToken = idMatch[2].trim();
|
|
616
|
+
const leftColumn = translateQualifiedColumn(leftToken);
|
|
617
|
+
const rightColumn = translateQualifiedColumn(rightToken);
|
|
618
|
+
this.andOn(leftColumn, rightColumn);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const applyStandardJoin = (join) => {
|
|
624
|
+
const [leftSide, rightSide] = join.joinCondition.split(' = ');
|
|
625
|
+
const translatedLeft = translateQualifiedColumn(leftSide.trim());
|
|
626
|
+
const translatedRight = translateQualifiedColumn(rightSide.trim());
|
|
627
|
+
query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function() {
|
|
628
|
+
this.on(translatedLeft, translatedRight);
|
|
629
|
+
});
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const processJoin = (join) => {
|
|
633
|
+
const joinKey = `${join.joinAlias}:${join.joinCondition}`;
|
|
634
|
+
if (appliedJoins.has(joinKey)) return;
|
|
635
|
+
|
|
636
|
+
if (join.isPolymorphic && join.joinCondition.includes(' AND ')) {
|
|
637
|
+
applyPolymorphicJoin(join);
|
|
638
|
+
} else {
|
|
639
|
+
applyStandardJoin(join);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
appliedJoins.add(joinKey);
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
joinMap.forEach((joinInfo) => {
|
|
646
|
+
if (joinInfo.isMultiLevel && Array.isArray(joinInfo.joinChain)) {
|
|
647
|
+
joinInfo.joinChain.forEach(processJoin);
|
|
648
|
+
} else {
|
|
649
|
+
processJoin(joinInfo);
|
|
516
650
|
}
|
|
517
651
|
});
|
|
518
652
|
|
|
@@ -539,7 +673,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
539
673
|
// Step 5: Apply WHERE conditions for cross-table filters
|
|
540
674
|
query.where(function() {
|
|
541
675
|
for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
542
|
-
const fieldDef =
|
|
676
|
+
const fieldDef = searchSchemaStructure[filterKey];
|
|
543
677
|
if (!fieldDef) continue;
|
|
544
678
|
|
|
545
679
|
// Skip non-cross-table and polymorphic filters
|
|
@@ -553,59 +687,56 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
553
687
|
switch (true) {
|
|
554
688
|
case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
|
|
555
689
|
const operator = fieldDef.filterOperator || '=';
|
|
556
|
-
|
|
557
|
-
// Handle split search terms
|
|
690
|
+
|
|
558
691
|
let searchTerms = [filterValue];
|
|
559
692
|
if (fieldDef.splitBy && typeof filterValue === 'string') {
|
|
560
693
|
searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim());
|
|
694
|
+
} else if (Array.isArray(filterValue)) {
|
|
695
|
+
searchTerms = filterValue;
|
|
561
696
|
}
|
|
562
|
-
|
|
697
|
+
|
|
698
|
+
const applyTermComparison = (builder, method, field, raw) => {
|
|
699
|
+
const columnRef = resolveFieldColumn(field);
|
|
700
|
+
let normalized = normalizeFieldValue(field, raw);
|
|
701
|
+
|
|
702
|
+
if (operator === 'like') {
|
|
703
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
704
|
+
if (value === null || value === undefined) {
|
|
705
|
+
builder[method === 'or' ? 'orWhereNull' : 'whereNull'](columnRef);
|
|
706
|
+
} else {
|
|
707
|
+
builder[method === 'or' ? 'orWhere' : 'where'](columnRef, 'like', `%${String(value)}%`);
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (operator === 'in') {
|
|
713
|
+
const values = Array.isArray(normalized) ? normalized : [normalized];
|
|
714
|
+
builder[method === 'or' ? 'orWhereIn' : 'whereIn'](columnRef, values);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
719
|
+
if (value === null && (operator === '=' || operator === '==')) {
|
|
720
|
+
builder[method === 'or' ? 'orWhereNull' : 'whereNull'](columnRef);
|
|
721
|
+
} else {
|
|
722
|
+
builder[method === 'or' ? 'orWhere' : 'where'](columnRef, operator, value);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
563
726
|
this.where(function() {
|
|
564
727
|
if (fieldDef.matchAll && searchTerms.length > 1) {
|
|
565
|
-
// AND logic - all terms must match
|
|
566
728
|
searchTerms.forEach(term => {
|
|
567
729
|
this.andWhere(function() {
|
|
568
730
|
fieldDef.oneOf.forEach((field, index) => {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if (operator === 'like') {
|
|
573
|
-
const condition = `%${term}%`;
|
|
574
|
-
if (index === 0) {
|
|
575
|
-
this.where(dbField, 'like', condition);
|
|
576
|
-
} else {
|
|
577
|
-
this.orWhere(dbField, 'like', condition);
|
|
578
|
-
}
|
|
579
|
-
} else {
|
|
580
|
-
if (index === 0) {
|
|
581
|
-
this.where(dbField, operator, term);
|
|
582
|
-
} else {
|
|
583
|
-
this.orWhere(dbField, operator, term);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
731
|
+
const method = index === 0 ? 'and' : 'or';
|
|
732
|
+
applyTermComparison(this, method, field, term);
|
|
586
733
|
});
|
|
587
734
|
});
|
|
588
735
|
});
|
|
589
736
|
} else {
|
|
590
|
-
// OR logic for single term or matchAll=false
|
|
591
737
|
fieldDef.oneOf.forEach((field, index) => {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (operator === 'like') {
|
|
596
|
-
const condition = `%${filterValue}%`;
|
|
597
|
-
if (index === 0) {
|
|
598
|
-
this.where(dbField, 'like', condition);
|
|
599
|
-
} else {
|
|
600
|
-
this.orWhere(dbField, 'like', condition);
|
|
601
|
-
}
|
|
602
|
-
} else {
|
|
603
|
-
if (index === 0) {
|
|
604
|
-
this.where(dbField, operator, filterValue);
|
|
605
|
-
} else {
|
|
606
|
-
this.orWhere(dbField, operator, filterValue);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
738
|
+
const method = index === 0 ? 'and' : 'or';
|
|
739
|
+
applyTermComparison(this, method, field, filterValue);
|
|
609
740
|
});
|
|
610
741
|
}
|
|
611
742
|
});
|
|
@@ -617,34 +748,44 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
617
748
|
break;
|
|
618
749
|
|
|
619
750
|
default:
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
dbField = fieldPathMap.get(dbField) || dbField;
|
|
623
|
-
}
|
|
624
|
-
|
|
751
|
+
const targetField = fieldDef.actualField || filterKey;
|
|
752
|
+
const columnRef = resolveFieldColumn(targetField);
|
|
625
753
|
const operator = fieldDef.filterOperator || '=';
|
|
754
|
+
const normalized = normalizeFieldValue(targetField, filterValue);
|
|
626
755
|
|
|
627
756
|
switch (operator) {
|
|
628
|
-
case 'like':
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (Array.isArray(filterValue)) {
|
|
633
|
-
this.whereIn(dbField, filterValue);
|
|
757
|
+
case 'like': {
|
|
758
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
759
|
+
if (value === null || value === undefined) {
|
|
760
|
+
this.whereNull(columnRef);
|
|
634
761
|
} else {
|
|
635
|
-
this.where(
|
|
762
|
+
this.where(columnRef, 'like', `%${String(value)}%`);
|
|
636
763
|
}
|
|
637
764
|
break;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
765
|
+
}
|
|
766
|
+
case 'in': {
|
|
767
|
+
const values = Array.isArray(normalized) ? normalized : [normalized];
|
|
768
|
+
this.whereIn(columnRef, values);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'between': {
|
|
772
|
+
const values = Array.isArray(normalized) ? normalized : [normalized];
|
|
773
|
+
if (values.length === 2) {
|
|
774
|
+
this.whereBetween(columnRef, values);
|
|
641
775
|
} else {
|
|
642
|
-
this.where(
|
|
776
|
+
this.where(columnRef, operator, values[0]);
|
|
643
777
|
}
|
|
644
778
|
break;
|
|
645
|
-
|
|
646
|
-
|
|
779
|
+
}
|
|
780
|
+
default: {
|
|
781
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
782
|
+
if (value === null && (operator === '=' || operator === '==')) {
|
|
783
|
+
this.whereNull(columnRef);
|
|
784
|
+
} else {
|
|
785
|
+
this.where(columnRef, operator, value);
|
|
786
|
+
}
|
|
647
787
|
break;
|
|
788
|
+
}
|
|
648
789
|
}
|
|
649
790
|
break;
|
|
650
791
|
}
|
|
@@ -795,39 +936,45 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
795
936
|
*/
|
|
796
937
|
export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
797
938
|
const { log, scopes, knex } = dependencies;
|
|
939
|
+
const adapterUtils = createAdapterUtilities(hookParams, dependencies);
|
|
798
940
|
|
|
799
941
|
// Extract context
|
|
800
942
|
const scopeName = hookParams.context?.knexQuery?.scopeName;
|
|
801
943
|
const filters = hookParams.context?.knexQuery?.filters;
|
|
802
|
-
const searchSchema = hookParams.context?.knexQuery?.searchSchema;
|
|
803
944
|
const query = hookParams.context?.knexQuery?.query;
|
|
804
|
-
const tableName = hookParams.context?.knexQuery?.tableName;
|
|
805
945
|
const db = hookParams.context?.knexQuery?.db || knex;
|
|
946
|
+
|
|
947
|
+
const schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
948
|
+
const tableName = schemaInfo.tableName;
|
|
949
|
+
const tableAlias = adapterUtils.defaultAliasForScope(scopeName);
|
|
806
950
|
|
|
807
951
|
log.trace('[DEBUG basicFiltersHook] Called with:', {
|
|
808
952
|
scopeName,
|
|
809
953
|
hasFilters: !!filters,
|
|
810
954
|
filters,
|
|
811
|
-
|
|
812
|
-
searchSchemaKeys: searchSchema ? Object.keys(searchSchema.structure || {}) : [],
|
|
955
|
+
searchSchemaKeys: Object.keys(schemaInfo.searchSchemaStructure || {}),
|
|
813
956
|
tableName
|
|
814
957
|
});
|
|
815
958
|
|
|
816
|
-
if (!filters
|
|
817
|
-
log.trace('[DEBUG basicFiltersHook] Returning early - no filters
|
|
959
|
+
if (!filters) {
|
|
960
|
+
log.trace('[DEBUG basicFiltersHook] Returning early - no filters');
|
|
818
961
|
return;
|
|
819
962
|
}
|
|
820
963
|
|
|
821
964
|
// Check if we have any JOINs applied (to know if we need to qualify fields)
|
|
822
965
|
// Instead of relying on hasJoins flag, always qualify fields for safety
|
|
823
|
-
const qualifyField = (field) =>
|
|
824
|
-
|
|
966
|
+
const qualifyField = (field) => adapterUtils.translateColumn(scopeName, field, tableAlias);
|
|
967
|
+
const normalizeValue = (field, value) => {
|
|
968
|
+
if (Array.isArray(value)) {
|
|
969
|
+
return value.map((entry) => adapterUtils.translateFilterValue(scopeName, field, entry));
|
|
970
|
+
}
|
|
971
|
+
return adapterUtils.translateFilterValue(scopeName, field, value);
|
|
825
972
|
};
|
|
826
973
|
|
|
827
974
|
// Main WHERE group
|
|
828
975
|
query.where(function() {
|
|
829
976
|
for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
830
|
-
const fieldDef =
|
|
977
|
+
const fieldDef = schemaInfo.searchSchemaStructure[filterKey];
|
|
831
978
|
if (!fieldDef) {
|
|
832
979
|
log.trace(`[DEBUG basicFiltersHook] No field definition for filter key: ${filterKey}`);
|
|
833
980
|
continue;
|
|
@@ -856,48 +1003,55 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
|
856
1003
|
|
|
857
1004
|
this.where(function() {
|
|
858
1005
|
if (fieldDef.matchAll && searchTerms.length > 1) {
|
|
859
|
-
// AND logic - all terms must match
|
|
860
1006
|
searchTerms.forEach(term => {
|
|
1007
|
+
const normalizedTerm = normalizeValue(fieldDef.oneOf[0], term);
|
|
861
1008
|
this.andWhere(function() {
|
|
862
1009
|
fieldDef.oneOf.forEach((field, index) => {
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
1010
|
+
const columnRef = qualifyField(field);
|
|
1011
|
+
const perFieldValue = Array.isArray(normalizedTerm)
|
|
1012
|
+
? normalizeValue(field, term)
|
|
1013
|
+
: normalizeValue(field, term);
|
|
1014
|
+
const normalized = Array.isArray(perFieldValue) ? perFieldValue[0] : perFieldValue;
|
|
1015
|
+
|
|
866
1016
|
if (operator === 'like') {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
this.where(dbField, 'like', condition);
|
|
1017
|
+
if (normalized === null || normalized === undefined) {
|
|
1018
|
+
this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
|
|
870
1019
|
} else {
|
|
871
|
-
this
|
|
1020
|
+
this[index === 0 ? 'where' : 'orWhere'](columnRef, 'like', `%${String(normalized)}%`);
|
|
872
1021
|
}
|
|
1022
|
+
} else if (operator === 'in') {
|
|
1023
|
+
const values = Array.isArray(perFieldValue) ? perFieldValue : [perFieldValue];
|
|
1024
|
+
this[index === 0 ? 'whereIn' : 'orWhereIn'](columnRef, values);
|
|
873
1025
|
} else {
|
|
874
|
-
if (
|
|
875
|
-
this
|
|
1026
|
+
if (normalized === null && (operator === '=' || operator === '==')) {
|
|
1027
|
+
this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
|
|
876
1028
|
} else {
|
|
877
|
-
this
|
|
1029
|
+
this[index === 0 ? 'where' : 'orWhere'](columnRef, operator, normalized);
|
|
878
1030
|
}
|
|
879
1031
|
}
|
|
880
1032
|
});
|
|
881
1033
|
});
|
|
882
1034
|
});
|
|
883
1035
|
} else {
|
|
884
|
-
// OR logic for single term or matchAll=false
|
|
885
1036
|
fieldDef.oneOf.forEach((field, index) => {
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
1037
|
+
const columnRef = qualifyField(field);
|
|
1038
|
+
const normalizedValue = normalizeValue(field, filterValue);
|
|
1039
|
+
const primary = Array.isArray(normalizedValue) ? normalizedValue[0] : normalizedValue;
|
|
1040
|
+
|
|
889
1041
|
if (operator === 'like') {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
this.where(dbField, 'like', condition);
|
|
1042
|
+
if (primary === null || primary === undefined) {
|
|
1043
|
+
this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
|
|
893
1044
|
} else {
|
|
894
|
-
this
|
|
1045
|
+
this[index === 0 ? 'where' : 'orWhere'](columnRef, 'like', `%${String(primary)}%`);
|
|
895
1046
|
}
|
|
1047
|
+
} else if (operator === 'in') {
|
|
1048
|
+
const values = Array.isArray(normalizedValue) ? normalizedValue : [normalizedValue];
|
|
1049
|
+
this[index === 0 ? 'whereIn' : 'orWhereIn'](columnRef, values);
|
|
896
1050
|
} else {
|
|
897
|
-
if (
|
|
898
|
-
this
|
|
1051
|
+
if (primary === null && (operator === '=' || operator === '==')) {
|
|
1052
|
+
this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
|
|
899
1053
|
} else {
|
|
900
|
-
this
|
|
1054
|
+
this[index === 0 ? 'where' : 'orWhere'](columnRef, operator, primary);
|
|
901
1055
|
}
|
|
902
1056
|
}
|
|
903
1057
|
});
|
|
@@ -918,47 +1072,49 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
|
918
1072
|
const dbField = qualifyField(actualField);
|
|
919
1073
|
|
|
920
1074
|
const operator = fieldDef.filterOperator || '=';
|
|
1075
|
+
const normalized = normalizeValue(actualField, filterValue);
|
|
921
1076
|
log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator} ${filterValue}`);
|
|
922
1077
|
|
|
923
1078
|
switch (operator) {
|
|
924
|
-
case 'like':
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
if (Array.isArray(filterValue)) {
|
|
929
|
-
this.whereIn(dbField, filterValue);
|
|
1079
|
+
case 'like': {
|
|
1080
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
1081
|
+
if (value === null || value === undefined) {
|
|
1082
|
+
this.whereNull(dbField);
|
|
930
1083
|
} else {
|
|
931
|
-
|
|
932
|
-
if (filterValue === null) {
|
|
933
|
-
this.whereNull(dbField);
|
|
934
|
-
} else {
|
|
935
|
-
this.where(dbField, operator, filterValue);
|
|
936
|
-
}
|
|
1084
|
+
this.where(dbField, 'like', `%${String(value)}%`);
|
|
937
1085
|
}
|
|
938
1086
|
break;
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1087
|
+
}
|
|
1088
|
+
case 'in': {
|
|
1089
|
+
const values = Array.isArray(normalized) ? normalized : [normalized];
|
|
1090
|
+
this.whereIn(dbField, values);
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
case 'between': {
|
|
1094
|
+
const values = Array.isArray(normalized) ? normalized : [normalized];
|
|
1095
|
+
if (values.length === 2) {
|
|
1096
|
+
this.whereBetween(dbField, values);
|
|
942
1097
|
} else {
|
|
943
|
-
|
|
944
|
-
if (filterValue === null) {
|
|
1098
|
+
if (values[0] === null || values[0] === undefined) {
|
|
945
1099
|
this.whereNull(dbField);
|
|
946
1100
|
} else {
|
|
947
|
-
this.where(dbField, operator,
|
|
1101
|
+
this.where(dbField, operator, values[0]);
|
|
948
1102
|
}
|
|
949
1103
|
}
|
|
950
1104
|
break;
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1105
|
+
}
|
|
1106
|
+
default: {
|
|
1107
|
+
const value = Array.isArray(normalized) ? normalized[0] : normalized;
|
|
1108
|
+
if (value === null || value === undefined) {
|
|
954
1109
|
this.whereNull(dbField);
|
|
955
1110
|
} else {
|
|
956
|
-
this.where(dbField, operator,
|
|
1111
|
+
this.where(dbField, operator, value);
|
|
957
1112
|
}
|
|
958
1113
|
break;
|
|
1114
|
+
}
|
|
959
1115
|
}
|
|
960
1116
|
break;
|
|
961
1117
|
}
|
|
962
1118
|
}
|
|
963
1119
|
});
|
|
964
|
-
};
|
|
1120
|
+
};
|