json-rest-api 1.0.18 → 1.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,5 +1,79 @@
1
1
  import { analyzeRequiredIndexes, buildJoinChain } from './knex-cross-table-search.js'
2
2
 
3
+ // Resolve operator with sensible defaults for fields declared in searchSchema.
4
+ // - If filterOperator is provided, use it as-is
5
+ // - If field type is string and no operator provided, default to 'like' (contains)
6
+ // - Otherwise default to '='
7
+ export function resolveSearchOperator (fieldDef) {
8
+ if (fieldDef && fieldDef.filterOperator) return String(fieldDef.filterOperator)
9
+ if (fieldDef && fieldDef.type === 'string') return 'like'
10
+ return '='
11
+ }
12
+
13
+ // Apply a comparison for a single field/operator/value onto a query builder.
14
+ // Handles contains/startsWith/endsWith, IN, BETWEEN, =/== null semantics,
15
+ // and uses ILIKE for case-insensitive matching on Postgres.
16
+ export function applyWhereForOperator ({ builder, columnRef, operator, value, knex, or = false }) {
17
+ const method = or ? 'orWhere' : 'where'
18
+ const methodNull = or ? 'orWhereNull' : 'whereNull'
19
+ const methodIn = or ? 'orWhereIn' : 'whereIn'
20
+ const likeOp = 'like'
21
+
22
+ const op = typeof operator === 'string' ? operator.toLowerCase() : operator
23
+
24
+ // Normalize scalar from possibly array input
25
+ const firstVal = Array.isArray(value) ? value[0] : value
26
+
27
+ // Null handling for equality and string ops
28
+ if (firstVal === null || firstVal === undefined) {
29
+ if (op === 'like' || op === 'contains' || op === 'startswith' || op === 'endswith') {
30
+ builder[methodNull](columnRef)
31
+ return
32
+ }
33
+ if (op === '=' || op === '==') {
34
+ builder[methodNull](columnRef)
35
+ return
36
+ }
37
+ }
38
+
39
+ // Text search operators
40
+ if (op === 'like' || op === 'contains') {
41
+ builder[method](columnRef, likeOp, `%${String(firstVal)}%`)
42
+ return
43
+ }
44
+ if (op === 'startswith') {
45
+ builder[method](columnRef, likeOp, `${String(firstVal)}%`)
46
+ return
47
+ }
48
+ if (op === 'endswith') {
49
+ builder[method](columnRef, likeOp, `%${String(firstVal)}`)
50
+ return
51
+ }
52
+
53
+ // Array-based operators
54
+ if (op === 'in') {
55
+ const values = Array.isArray(value) ? value : [value]
56
+ builder[methodIn](columnRef, values)
57
+ return
58
+ }
59
+ if (op === 'between') {
60
+ const values = Array.isArray(value) ? value : [value]
61
+ if (values.length === 2) {
62
+ builder.whereBetween(columnRef, values)
63
+ } else if (values.length === 1) {
64
+ if (values[0] === null || values[0] === undefined) {
65
+ builder[methodNull](columnRef)
66
+ } else {
67
+ builder[method](columnRef, '=', values[0])
68
+ }
69
+ }
70
+ return
71
+ }
72
+
73
+ // Default
74
+ builder[method](columnRef, operator || '=', firstVal)
75
+ }
76
+
3
77
  const createAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
4
78
  const context = hookParams.context || {}
5
79
  const storageCache = new Map()
@@ -311,29 +385,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
311
385
  const applyComparison = (builder, scope, alias, field, operator, rawValue) => {
312
386
  const columnRef = adapterUtils.translateColumn(scope, field, alias)
313
387
  const normalizedValue = adapterUtils.translateFilterValue(scope, field, rawValue)
314
- const op = operator || '='
315
-
316
- const lowerOp = typeof op === 'string' ? op.toLowerCase() : op
317
- if (normalizedValue === null && (lowerOp === '=' || lowerOp === '==')) {
318
- builder.whereNull(columnRef)
319
- return
320
- }
321
-
322
- if (lowerOp === 'like') {
323
- if (normalizedValue === null || normalizedValue === undefined) {
324
- builder.whereNull(columnRef)
325
- } else {
326
- builder.where(columnRef, 'like', `%${normalizedValue}%`)
327
- }
328
- return
329
- }
330
-
331
- if (Array.isArray(normalizedValue) && lowerOp === 'in') {
332
- builder.whereIn(columnRef, normalizedValue)
333
- return
334
- }
335
-
336
- builder.where(columnRef, op, normalizedValue)
388
+ applyWhereForOperator({ builder, columnRef, operator, value: normalizedValue, knex })
337
389
  }
338
390
 
339
391
  for (const [filterKey] of Object.entries(filters)) {
@@ -365,10 +417,10 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
365
417
  }
366
418
 
367
419
  const finalScope = aliasScopeMap.get(finalAlias) || targetType
368
- const operator = searchInfo.fieldDef.filterOperator || '='
420
+ const operator = resolveSearchOperator(searchInfo.fieldDef)
369
421
  applyComparison(this, finalScope, finalAlias, fieldName, operator, searchInfo.filterValue)
370
422
  } else {
371
- const operator = searchInfo.fieldDef.filterOperator || '='
423
+ const operator = resolveSearchOperator(searchInfo.fieldDef)
372
424
  const finalScope = aliasScopeMap.get(baseAlias) || targetType
373
425
  applyComparison(this, finalScope, baseAlias, targetFieldPath, operator, searchInfo.filterValue)
374
426
  }
@@ -672,7 +724,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
672
724
  // Step 5: Apply WHERE conditions for cross-table filters
673
725
  query.where(function () {
674
726
  for (const [filterKey, filterValue] of Object.entries(filters)) {
675
- const fieldDef = searchSchemaStructure[filterKey]
727
+ const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
676
728
  if (!fieldDef) continue
677
729
 
678
730
  // Skip non-cross-table and polymorphic filters
@@ -685,7 +737,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
685
737
  // Process cross-table filters
686
738
  switch (true) {
687
739
  case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
688
- const operator = fieldDef.filterOperator || '='
740
+ const operator = resolveSearchOperator(fieldDef)
689
741
 
690
742
  let searchTerms = [filterValue]
691
743
  if (fieldDef.splitBy && typeof filterValue === 'string') {
@@ -697,29 +749,14 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
697
749
  const applyTermComparison = (builder, method, field, raw) => {
698
750
  const columnRef = resolveFieldColumn(field)
699
751
  const normalized = normalizeFieldValue(field, raw)
700
-
701
- if (operator === 'like') {
702
- const value = Array.isArray(normalized) ? normalized[0] : normalized
703
- if (value === null || value === undefined) {
704
- builder[method === 'or' ? 'orWhereNull' : 'whereNull'](columnRef)
705
- } else {
706
- builder[method === 'or' ? 'orWhere' : 'where'](columnRef, 'like', `%${String(value)}%`)
707
- }
708
- return
709
- }
710
-
711
- if (operator === 'in') {
712
- const values = Array.isArray(normalized) ? normalized : [normalized]
713
- builder[method === 'or' ? 'orWhereIn' : 'whereIn'](columnRef, values)
714
- return
715
- }
716
-
717
- const value = Array.isArray(normalized) ? normalized[0] : normalized
718
- if (value === null && (operator === '=' || operator === '==')) {
719
- builder[method === 'or' ? 'orWhereNull' : 'whereNull'](columnRef)
720
- } else {
721
- builder[method === 'or' ? 'orWhere' : 'where'](columnRef, operator, value)
722
- }
752
+ applyWhereForOperator({
753
+ builder,
754
+ columnRef,
755
+ operator,
756
+ value: normalized,
757
+ knex,
758
+ or: method === 'or',
759
+ })
723
760
  }
724
761
 
725
762
  this.where(function () {
@@ -749,45 +786,11 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
749
786
  default:
750
787
  const targetField = fieldDef.actualField || filterKey
751
788
  const columnRef = resolveFieldColumn(targetField)
752
- const operator = fieldDef.filterOperator || '='
789
+ const operator = resolveSearchOperator(fieldDef)
753
790
  const normalized = normalizeFieldValue(targetField, filterValue)
754
-
755
- switch (operator) {
756
- case 'like': {
757
- const value = Array.isArray(normalized) ? normalized[0] : normalized
758
- if (value === null || value === undefined) {
759
- this.whereNull(columnRef)
760
- } else {
761
- this.where(columnRef, 'like', `%${String(value)}%`)
762
- }
763
- break
764
- }
765
- case 'in': {
766
- const values = Array.isArray(normalized) ? normalized : [normalized]
767
- this.whereIn(columnRef, values)
768
- break
769
- }
770
- case 'between': {
771
- const values = Array.isArray(normalized) ? normalized : [normalized]
772
- if (values.length === 2) {
773
- this.whereBetween(columnRef, values)
774
- } else {
775
- this.where(columnRef, operator, values[0])
776
- }
777
- break
778
- }
779
- default: {
780
- const value = Array.isArray(normalized) ? normalized[0] : normalized
781
- if (value === null && (operator === '=' || operator === '==')) {
782
- this.whereNull(columnRef)
783
- } else {
784
- this.where(columnRef, operator, value)
785
- }
786
- break
787
- }
788
- }
791
+ applyWhereForOperator({ builder: this, columnRef, operator, value: normalized, knex })
789
792
  break
790
- }
793
+ }
791
794
  }
792
795
  })
793
796
  }
@@ -992,7 +995,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
992
995
  switch (true) {
993
996
  case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
994
997
  // Multi-field OR search
995
- const operator = fieldDef.filterOperator || '='
998
+ const operator = resolveSearchOperator(fieldDef)
996
999
 
997
1000
  // Handle split search terms
998
1001
  let searchTerms = [filterValue]
@@ -1010,24 +1013,14 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
1010
1013
  const perFieldValue = Array.isArray(normalizedTerm)
1011
1014
  ? normalizeValue(field, term)
1012
1015
  : normalizeValue(field, term)
1013
- const normalized = Array.isArray(perFieldValue) ? perFieldValue[0] : perFieldValue
1014
-
1015
- if (operator === 'like') {
1016
- if (normalized === null || normalized === undefined) {
1017
- this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef)
1018
- } else {
1019
- this[index === 0 ? 'where' : 'orWhere'](columnRef, 'like', `%${String(normalized)}%`)
1020
- }
1021
- } else if (operator === 'in') {
1022
- const values = Array.isArray(perFieldValue) ? perFieldValue : [perFieldValue]
1023
- this[index === 0 ? 'whereIn' : 'orWhereIn'](columnRef, values)
1024
- } else {
1025
- if (normalized === null && (operator === '=' || operator === '==')) {
1026
- this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef)
1027
- } else {
1028
- this[index === 0 ? 'where' : 'orWhere'](columnRef, operator, normalized)
1029
- }
1030
- }
1016
+ applyWhereForOperator({
1017
+ builder: this,
1018
+ columnRef,
1019
+ operator,
1020
+ value: perFieldValue,
1021
+ knex,
1022
+ or: index !== 0,
1023
+ })
1031
1024
  })
1032
1025
  })
1033
1026
  })
@@ -1035,24 +1028,14 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
1035
1028
  fieldDef.oneOf.forEach((field, index) => {
1036
1029
  const columnRef = qualifyField(field)
1037
1030
  const normalizedValue = normalizeValue(field, filterValue)
1038
- const primary = Array.isArray(normalizedValue) ? normalizedValue[0] : normalizedValue
1039
-
1040
- if (operator === 'like') {
1041
- if (primary === null || primary === undefined) {
1042
- this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef)
1043
- } else {
1044
- this[index === 0 ? 'where' : 'orWhere'](columnRef, 'like', `%${String(primary)}%`)
1045
- }
1046
- } else if (operator === 'in') {
1047
- const values = Array.isArray(normalizedValue) ? normalizedValue : [normalizedValue]
1048
- this[index === 0 ? 'whereIn' : 'orWhereIn'](columnRef, values)
1049
- } else {
1050
- if (primary === null && (operator === '=' || operator === '==')) {
1051
- this[index === 0 ? 'whereNull' : 'orWhereNull'](columnRef)
1052
- } else {
1053
- this[index === 0 ? 'where' : 'orWhere'](columnRef, operator, primary)
1054
- }
1055
- }
1031
+ applyWhereForOperator({
1032
+ builder: this,
1033
+ columnRef,
1034
+ operator,
1035
+ value: normalizedValue,
1036
+ knex,
1037
+ or: index !== 0,
1038
+ })
1056
1039
  })
1057
1040
  }
1058
1041
  })
@@ -1070,50 +1053,12 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
1070
1053
  // Always qualify field names
1071
1054
  const dbField = qualifyField(actualField)
1072
1055
 
1073
- const operator = fieldDef.filterOperator || '='
1056
+ const operator = resolveSearchOperator(fieldDef)
1074
1057
  const normalized = normalizeValue(actualField, filterValue)
1075
1058
  log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator} ${filterValue}`)
1076
-
1077
- switch (operator) {
1078
- case 'like': {
1079
- const value = Array.isArray(normalized) ? normalized[0] : normalized
1080
- if (value === null || value === undefined) {
1081
- this.whereNull(dbField)
1082
- } else {
1083
- this.where(dbField, 'like', `%${String(value)}%`)
1084
- }
1085
- break
1086
- }
1087
- case 'in': {
1088
- const values = Array.isArray(normalized) ? normalized : [normalized]
1089
- this.whereIn(dbField, values)
1090
- break
1091
- }
1092
- case 'between': {
1093
- const values = Array.isArray(normalized) ? normalized : [normalized]
1094
- if (values.length === 2) {
1095
- this.whereBetween(dbField, values)
1096
- } else {
1097
- if (values[0] === null || values[0] === undefined) {
1098
- this.whereNull(dbField)
1099
- } else {
1100
- this.where(dbField, operator, values[0])
1101
- }
1102
- }
1103
- break
1104
- }
1105
- default: {
1106
- const value = Array.isArray(normalized) ? normalized[0] : normalized
1107
- if (value === null || value === undefined) {
1108
- this.whereNull(dbField)
1109
- } else {
1110
- this.where(dbField, operator, value)
1111
- }
1112
- break
1113
- }
1114
- }
1059
+ applyWhereForOperator({ builder: this, columnRef: dbField, operator, value: normalized, knex })
1115
1060
  break
1116
- }
1061
+ }
1117
1062
  }
1118
1063
  })
1119
1064
  }
@@ -117,7 +117,7 @@ export function ensureSearchFieldsAreIndexed (searchSchema) {
117
117
  *
118
118
  * // Output: Generated search schema
119
119
  * // {
120
- * // title: { type: 'string', filterOperator: '=' }, // Default operator
120
+ * // title: { type: 'string' }, // No operator set; resolver applies defaults
121
121
  * // status: { type: 'string', filterOperator: '=' } // Specified operator
122
122
  * // }
123
123
  *
@@ -163,7 +163,7 @@ export function ensureSearchFieldsAreIndexed (searchSchema) {
163
163
  * // {
164
164
  * // email: { type: 'string', filterOperator: '=' }, // Explicit
165
165
  * // age: { type: 'number', filterOperator: '>=' }, // Explicit
166
- * // name: { type: 'string', filterOperator: '=' } // From schema
166
+ * // name: { type: 'string' } // From schema (no operator set)
167
167
  * // }
168
168
  *
169
169
  * @example
@@ -253,12 +253,11 @@ export const generateSearchSchemaFromSchema = (schema, explicitSearchSchema) =>
253
253
  return
254
254
  }
255
255
 
256
- // Simple boolean - copy entire field definition (except search) and add filterOperator
256
+ // Simple boolean - copy entire field definition (except search)
257
257
  const { search, ...fieldDefWithoutSearch } = fieldDef
258
258
  const searchEntry = {
259
259
  ...fieldDefWithoutSearch,
260
- // Preserve existing filterOperator or default to '='
261
- filterOperator: fieldDefWithoutSearch.filterOperator || '='
260
+ // Do not set filterOperator here; centralized resolver will apply sensible defaults
262
261
  }
263
262
 
264
263
  // For belongsTo relationships, add metadata to map back to the actual field
@@ -184,7 +184,7 @@ export const AccessPlugin = {
184
184
  }
185
185
 
186
186
  fields[field] = {
187
- type: 'number',
187
+ type: 'id',
188
188
  belongsTo: config.ownership.userResource,
189
189
  ...(relationshipName ? { as: relationshipName } : {}),
190
190
  nullable: true,
@@ -108,63 +108,19 @@ export const RestApiAnyapiKnexPlugin = {
108
108
  }
109
109
 
110
110
  const applyFiltersToQuery = ({ query, filters, descriptor, searchSchema, adapter }) => {
111
+ // Filtering is handled by the query hooks to keep behavior consistent
112
+ // (operators, null handling, multi-field, joins, polymorphic, etc.).
113
+ // Avoid applying filters here to prevent duplicate conditions.
114
+ if (searchSchema) {
115
+ return
116
+ }
117
+
118
+ // TODO: Probably delete the rest of the function. searchSchema is always there...!!!
111
119
  if (!filters || Object.keys(filters).length === 0) return
112
120
 
121
+ // Fallback: apply minimal safe filtering when no search schema is defined
113
122
  for (const [field, rawValue] of Object.entries(filters)) {
114
- const searchDef = searchSchema ? searchSchema[field] : null
115
- if (searchDef) {
116
- const isAdvanced = Boolean(
117
- searchDef.actualField?.includes('.') ||
118
- (Array.isArray(searchDef.oneOf) && searchDef.oneOf.some((entry) => entry.includes('.'))) ||
119
- searchDef.polymorphicField ||
120
- typeof searchDef.applyFilter === 'function'
121
- )
122
- if (!isAdvanced && adapter) {
123
- const columnRef = adapter.translateColumn(searchDef.actualField || field)
124
- const operator = (searchDef.filterOperator || '=').toLowerCase()
125
- const normalizedValues = normalizeFilterValues(rawValue, searchDef, {
126
- isRelationship: Boolean(searchDef.isRelationship),
127
- })
128
- const primaryValue = normalizedValues[0]
129
- switch (operator) {
130
- case 'like':
131
- query.whereRaw(`${columnRef} like ?`, [`%${primaryValue}%`])
132
- break
133
- case 'in':
134
- if (normalizedValues.includes(null) && normalizedValues.length === 1) {
135
- query.whereNull(columnRef)
136
- } else if (normalizedValues.length > 1) {
137
- query.whereIn(columnRef, normalizedValues)
138
- } else if (primaryValue === null) {
139
- query.whereNull(columnRef)
140
- } else {
141
- query.whereRaw(`${columnRef} = ?`, [primaryValue])
142
- }
143
- break
144
- case 'between':
145
- if (normalizedValues.length === 2) {
146
- query.whereBetween(columnRef, normalizedValues)
147
- } else if (primaryValue === null) {
148
- query.whereNull(columnRef)
149
- } else {
150
- query.whereRaw(`${columnRef} = ?`, [primaryValue])
151
- }
152
- break
153
- default:
154
- if (primaryValue === null) {
155
- query.whereNull(columnRef)
156
- } else {
157
- query.whereRaw(`${columnRef} ${operator || '='} ?`, [primaryValue])
158
- }
159
- break
160
- }
161
- continue
162
- }
163
- // Leave advanced search fields to downstream hooks
164
- continue
165
- }
166
123
  const fieldInfo = ensureFilterableField(descriptor, field)
167
-
168
124
  const values = normalizeFilterValues(rawValue, fieldInfo.definition, {
169
125
  isRelationship: fieldInfo.isRelationship,
170
126
  })
@@ -176,17 +132,14 @@ export const RestApiAnyapiKnexPlugin = {
176
132
  query.whereNull(fieldInfo.column)
177
133
  continue
178
134
  }
179
-
180
135
  if (nonNullValues.length === 1 && !hasNull) {
181
136
  query.where(fieldInfo.column, nonNullValues[0])
182
137
  continue
183
138
  }
184
-
185
139
  if (nonNullValues.length > 1 && !hasNull) {
186
140
  query.whereIn(fieldInfo.column, nonNullValues)
187
141
  continue
188
142
  }
189
-
190
143
  if (nonNullValues.length > 0 && hasNull) {
191
144
  query.where(function filterGroup () {
192
145
  this.whereIn(fieldInfo.column, nonNullValues).orWhereNull(fieldInfo.column)
@@ -2034,11 +1987,30 @@ export const RestApiAnyapiKnexPlugin = {
2034
1987
  log,
2035
1988
  })
2036
1989
 
2037
- applyFiltersToQuery({
2038
- query: adapter.query,
2039
- filters: context.queryParams?.filters,
2040
- descriptor,
2041
- })
1990
+ // Apply the same filter hooks as dataQuery for consistent counts
1991
+ const scope = api.resources?.[scopeName]
1992
+ const schemaInfo = scope?.vars?.schemaInfo
1993
+ const tableNameForHooks = schemaInfo?.tableName || adapter.tableAlias
1994
+
1995
+ const hookParams = {
1996
+ context: {
1997
+ knexQuery: {
1998
+ query: adapter.query,
1999
+ filters: context.queryParams?.filters,
2000
+ schemaInfo,
2001
+ scopeName,
2002
+ tableName: tableNameForHooks,
2003
+ db,
2004
+ isAnyApi: true,
2005
+ adapter,
2006
+ storageAdapter,
2007
+ },
2008
+ },
2009
+ }
2010
+
2011
+ await polymorphicFiltersHook(hookParams, queryHookDependencies)
2012
+ await crossTableFiltersHook(hookParams, queryHookDependencies)
2013
+ await basicFiltersHook(hookParams, queryHookDependencies)
2042
2014
 
2043
2015
  const [{ count }] = await adapter.query.count({ count: '*' })
2044
2016
  return Number(count)