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.
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 +7 -11
  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,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
- if (!filters || !searchSchema) {
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 = searchSchema.structure[filterKey];
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 = `${tableName}_${polymorphicField}_${targetType}`;
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.schema;;
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
- this.on(`${tableName}.${typeField}`, db.raw('?', [targetType]))
149
- .andOn(`${tableName}.${idField}`, `${baseAlias}.id`);
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.schema;
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.schema;
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
- query.leftJoin(`${nextTable} as ${nextAlias}`,
212
- `${currentAlias}.${foreignKeyField}`,
213
- `${nextAlias}.id`
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
- for (const [filterKey, filterValue] of Object.entries(filters)) {
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
- // Build OR conditions for each possible type
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
- // First check the type matches
254
- this.where(`${tableName}.${typeField}`, targetType);
351
+ this.orWhere(function applyTargetBranch() {
352
+ const typeColumnAlias = tableAlias;
353
+ applyComparison(this, scopeName, typeColumnAlias, typeField, '=', targetType);
255
354
 
256
- // Then apply the field filter
257
- const baseAlias = `${tableName}_${searchInfo.polymorphicField}_${targetType}`;
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
- if (operator === 'like') {
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
- if (operator === 'like') {
279
- this.where(`${baseAlias}.${targetFieldPath}`, 'like', `%${searchInfo.filterValue}%`);
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, crossTableSearchHelpers } = dependencies;
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 || !searchSchema) {
491
+ if (!filters) {
395
492
  return;
396
493
  }
397
494
 
398
495
  // Step 1: Analyze indexes
399
- const requiredIndexes = crossTableSearchHelpers.analyzeRequiredIndexes(scopeName, searchSchema);
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(searchSchema.structure)) {
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 crossTableSearchHelpers.buildJoinChain(scopeName, fieldDef.actualField);
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 crossTableSearchHelpers.buildJoinChain(scopeName, field);
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.isMultiLevel && joinInfo.joinChain) {
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
- const joinKey = `${join.joinAlias}:${join.joinCondition}`;
453
- if (!appliedJoins.has(joinKey)) {
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
- } else {
485
- const joinKey = `${joinInfo.joinAlias}:${joinInfo.joinCondition}`;
486
- if (!appliedJoins.has(joinKey)) {
487
- // Check if this is a polymorphic join with AND condition
488
- if (joinInfo.isPolymorphic && joinInfo.joinCondition.includes(' AND ')) {
489
- query.leftJoin(`${joinInfo.targetTableName} as ${joinInfo.joinAlias}`, function() {
490
- // Parse polymorphic condition: type_field = 'value' AND id_field = parent.id
491
- const parts = joinInfo.joinCondition.split(' AND ');
492
- const [typeCondition, idCondition] = parts;
493
-
494
- // Extract the type field and value from first part
495
- const typeMatch = typeCondition.match(/(.+?)\s*=\s*'(.+?)'/);
496
- if (typeMatch) {
497
- const typeField = typeMatch[1].trim();
498
- const typeValue = typeMatch[2];
499
- this.on(typeField, db.raw('?', [typeValue]));
500
- }
501
-
502
- // Extract the id fields from second part
503
- const idMatch = idCondition.match(/(.+?)\s*=\s*(.+)/);
504
- if (idMatch) {
505
- this.andOn(idMatch[1].trim(), idMatch[2].trim());
506
- }
507
- });
508
- } else {
509
- const [leftSide, rightSide] = joinInfo.joinCondition.split(' = ');
510
- query.leftJoin(`${joinInfo.targetTableName} as ${joinInfo.joinAlias}`, function() {
511
- this.on(leftSide, rightSide);
512
- });
513
- }
514
- appliedJoins.add(joinKey);
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 = searchSchema.structure[filterKey];
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 dbField = fieldPathMap.get(field) ||
570
- (!field.includes('.') && joinMap.size > 0 ? `${tableName}.${field}` : field);
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 dbField = fieldPathMap.get(field) ||
593
- (!field.includes('.') && joinMap.size > 0 ? `${tableName}.${field}` : field);
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
- let dbField = fieldDef.actualField || filterKey;
621
- if (dbField.includes('.')) {
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
- this.where(dbField, 'like', `%${filterValue}%`);
630
- break;
631
- case 'in':
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(dbField, operator, filterValue);
762
+ this.where(columnRef, 'like', `%${String(value)}%`);
636
763
  }
637
764
  break;
638
- case 'between':
639
- if (Array.isArray(filterValue) && filterValue.length === 2) {
640
- this.whereBetween(dbField, filterValue);
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(dbField, operator, filterValue);
776
+ this.where(columnRef, operator, values[0]);
643
777
  }
644
778
  break;
645
- default:
646
- this.where(dbField, operator, filterValue);
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
- hasSearchSchema: !!searchSchema,
812
- searchSchemaKeys: searchSchema ? Object.keys(searchSchema.structure || {}) : [],
955
+ searchSchemaKeys: Object.keys(schemaInfo.searchSchemaStructure || {}),
813
956
  tableName
814
957
  });
815
958
 
816
- if (!filters || !searchSchema) {
817
- log.trace('[DEBUG basicFiltersHook] Returning early - no filters or searchSchema');
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
- return `${tableName}.${field}`;
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 = searchSchema.structure[filterKey];
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
- // Always qualify field names
864
- const dbField = qualifyField(field);
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
- const condition = `%${term}%`;
868
- if (index === 0) {
869
- this.where(dbField, 'like', condition);
1017
+ if (normalized === null || normalized === undefined) {
1018
+ this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
870
1019
  } else {
871
- this.orWhere(dbField, 'like', condition);
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 (index === 0) {
875
- this.where(dbField, operator, term);
1026
+ if (normalized === null && (operator === '=' || operator === '==')) {
1027
+ this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
876
1028
  } else {
877
- this.orWhere(dbField, operator, term);
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
- // Always qualify field names
887
- const dbField = qualifyField(field);
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
- const condition = `%${filterValue}%`;
891
- if (index === 0) {
892
- this.where(dbField, 'like', condition);
1042
+ if (primary === null || primary === undefined) {
1043
+ this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
893
1044
  } else {
894
- this.orWhere(dbField, 'like', condition);
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 (index === 0) {
898
- this.where(dbField, operator, filterValue);
1051
+ if (primary === null && (operator === '=' || operator === '==')) {
1052
+ this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef);
899
1053
  } else {
900
- this.orWhere(dbField, operator, filterValue);
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
- this.where(dbField, 'like', `%${filterValue}%`);
926
- break;
927
- case 'in':
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
- // Handle null values for 'in' operator
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
- case 'between':
940
- if (Array.isArray(filterValue) && filterValue.length === 2) {
941
- this.whereBetween(dbField, filterValue);
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
- // Handle null values for 'between' operator
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, filterValue);
1101
+ this.where(dbField, operator, values[0]);
948
1102
  }
949
1103
  }
950
1104
  break;
951
- default:
952
- // Handle null values specially
953
- if (filterValue === null) {
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, filterValue);
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
+ };