json-rest-api 1.0.28 → 2.0.0

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 (289) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +746 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. package/tests/virtual-fields.test.js +0 -404
@@ -1,5 +1,118 @@
1
- import { analyzeRequiredIndexes, buildJoinChain } from './knex-cross-table-search.js'
1
+ import { assertScalarQueryField } from '../querying-writing/field-utils.js'
2
+ import { buildJoinChain } from './knex-cross-table-search.js'
2
3
  import { createStorageAdapterUtilities } from './storage-adapter-utils.js'
4
+ import { unwrapQueryBuilderState } from './query-builder-utils.js'
5
+ import { RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
6
+ import { resolveSortField } from './query-field-sort-helpers.js'
7
+ import { databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
8
+
9
+ const referenceVisibility = Symbol('referenceVisibility')
10
+
11
+ async function buildSearchJoinQuery ({ scopeName, scopes, context, adapterUtils, db }) {
12
+ const scope = scopes[scopeName]
13
+ const storageAdapter = adapterUtils.fetchStorageAdapter(scopeName)
14
+ if (!scope || !storageAdapter) throw new RestApiResourceError(`Related resource '${scopeName}' not found`, { subtype: 'related_type_not_found' })
15
+ const tableName = storageAdapter.getTableName()
16
+ const query = storageAdapter.buildBaseQuery({ transaction: db }).select(`${tableName}.*`)
17
+ const state = await scope.applyQueryFilters({
18
+ query,
19
+ scopeName,
20
+ tableName,
21
+ db,
22
+ storageAdapter,
23
+ schemaInfo: scope.vars.schemaInfo,
24
+ queryPurpose: 'search-join',
25
+ isAnyApi: storageAdapter.isCanonical()
26
+ }, { ...context, queryParams: {}, scopeName, schemaInfo: scope.vars.schemaInfo, storageAdapter })
27
+ return { query: unwrapQueryBuilderState(state, query) }
28
+ }
29
+
30
+ async function resolveSearchColumns (references, { scopes, context, adapterUtils, db, operation = 'filter' }) {
31
+ const columns = new Map()
32
+ for (const [key, { scopeName, field, alias }] of references) {
33
+ const column = adapterUtils.translateColumn(scopeName, field, alias)
34
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
35
+ const fieldDef = schemaInfo?.schemaStructure?.[field]
36
+ assertScalarQueryField(fieldDef, field, operation)
37
+ let idField, typeField, targets
38
+ if (fieldDef?.belongsTo) {
39
+ idField = field
40
+ targets = [fieldDef.belongsTo]
41
+ } else {
42
+ for (const [name, definition] of Object.entries({ ...schemaInfo?.schemaStructure, ...schemaInfo?.schemaRelationships })) {
43
+ const polymorphic = definition?.belongsToPolymorphic
44
+ if (!polymorphic) continue
45
+ const referenceId = polymorphic.idField || `${name}_id`
46
+ const referenceType = polymorphic.typeField || `${name}_type`
47
+ if (field !== referenceId && field !== referenceType) continue
48
+ idField = referenceId
49
+ typeField = referenceType
50
+ targets = polymorphic.types
51
+ break
52
+ }
53
+ }
54
+ if (!targets) {
55
+ columns.set(key, column)
56
+ continue
57
+ }
58
+
59
+ const selections = []
60
+ const targetAlias = alias === '__jra_reference' ? '__jra_reference_target' : '__jra_reference'
61
+ for (const target of targets) {
62
+ const state = await buildSearchJoinQuery({ scopeName: target, scopes, context, adapterUtils, db })
63
+ const selection = db.queryBuilder().from(state.query.as(targetAlias)).select(db.raw('1'))
64
+ .whereColumn(adapterUtils.translateColumn(target, 'id', targetAlias), adapterUtils.translateColumn(scopeName, idField, alias))
65
+ if (typeField) selection.where(adapterUtils.translateColumn(scopeName, typeField, alias), adapterUtils.translateFilterValue(scopeName, typeField, target))
66
+ selections.push(selection)
67
+ }
68
+ columns.set(key, { column, [referenceVisibility]: selections })
69
+ }
70
+ return columns
71
+ }
72
+
73
+ export async function prepareReferenceSortColumns ({ query, fields, scopeName, tableAlias, context }, dependencies) {
74
+ const { scopes, knex, getStorageAdapter } = dependencies
75
+ const storageAdapter = getStorageAdapter(scopeName)
76
+ const tableName = storageAdapter.getTableName()
77
+ const db = context.db || context.transaction || knex
78
+ const schemaInfo = scopes[scopeName].vars.schemaInfo
79
+ const adapterUtils = createStorageAdapterUtilities({
80
+ context: {
81
+ ...context, knexQuery: { scopeName, tableName, storageAdapter }
82
+ }
83
+ }, dependencies)
84
+ const references = new Map(fields.map(field => [field, {
85
+ scopeName, alias: tableName, field: resolveSortField(field, schemaInfo)
86
+ }]))
87
+ const resolved = await resolveSearchColumns(references, { scopes, context, adapterUtils, db, operation: 'sort' })
88
+ const columns = new Map()
89
+ if (![...resolved.values()].some(reference => reference?.[referenceVisibility])) return columns
90
+ const resultNames = new Set([
91
+ ...fields,
92
+ ...Object.keys(scopes[scopeName].vars.schemaInfo?.queryFields || {}),
93
+ ...Object.keys(schemaInfo.schemaStructure).flatMap(field => [field, storageAdapter.translateColumn(field)])
94
+ ])
95
+ let resultIndex = 0
96
+ for (const [field, reference] of resolved) {
97
+ const selections = reference?.[referenceVisibility]
98
+ if (!selections) continue
99
+ const joinAlias = `__jra_sort_${columns.size}${tableAlias === `__jra_sort_${columns.size}` ? '_target' : ''}`
100
+ while (resultNames.has(`__jra_sort_value_${resultIndex}`)) resultIndex++
101
+ const resultColumn = `__jra_sort_value_${resultIndex++}`
102
+ // A filtered copy preserves the source column's affinity and collation.
103
+ const visibleSource = storageAdapter.buildBaseQuery({ transaction: db })
104
+ .select({ __jra_id: `${tableName}.${storageAdapter.getIdColumn()}`, __jra_value: reference.column })
105
+ .where(function () {
106
+ if (selections.length === 0) this.whereRaw('1 = 0')
107
+ for (const selection of selections) this.orWhereExists(selection)
108
+ })
109
+ const column = `${joinAlias}.__jra_value`
110
+ query.leftJoin(visibleSource.as(joinAlias), `${tableAlias}.${storageAdapter.getIdColumn()}`, `${joinAlias}.__jra_id`)
111
+ query.select({ [resultColumn]: databaseIdentityExpression(db, column) })
112
+ columns.set(field, { column, resultColumn, referenceField: references.get(field).field })
113
+ }
114
+ return columns
115
+ }
3
116
 
4
117
  // Resolve operator with sensible defaults for fields declared in searchSchema.
5
118
  // - If filterOperator is provided, use it as-is
@@ -10,22 +123,41 @@ export function resolveSearchOperator (fieldDef) {
10
123
  }
11
124
 
12
125
  // Apply a comparison for a single field/operator/value onto a query builder.
13
- // Handles contains/startsWith/endsWith, IN, BETWEEN, =/== null semantics,
14
- // and uses ILIKE for case-insensitive matching on Postgres.
126
+ // Handles text matching, IN, BETWEEN, and explicit null comparisons.
15
127
  export function applyWhereForOperator ({ builder, columnRef, operator, value, knex, or = false }) {
16
128
  const method = or ? 'orWhere' : 'where'
17
129
  const methodNull = or ? 'orWhereNull' : 'whereNull'
18
130
  const methodIn = or ? 'orWhereIn' : 'whereIn'
131
+
132
+ const selections = columnRef?.[referenceVisibility]
133
+ if (selections) {
134
+ const visible = function () {
135
+ if (selections.length === 0) this.whereRaw('1 = 0')
136
+ for (const selection of selections) this.orWhereExists(selection)
137
+ }
138
+ // Compare the column itself to preserve driver affinity and collation.
139
+ builder[method](function () {
140
+ this.where(function () {
141
+ this.where(visible)
142
+ applyWhereForOperator({ builder: this, columnRef: columnRef.column, operator, value, knex })
143
+ }).orWhere(function () {
144
+ this.whereNot(visible)
145
+ applyWhereForOperator({ builder: this, columnRef: knex.raw('null'), operator, value, knex })
146
+ })
147
+ })
148
+ return
149
+ }
19
150
  const likeOp = 'like'
20
151
 
21
152
  const op = typeof operator === 'string' ? operator.toLowerCase() : operator
153
+ const textSearch = ['like', 'contains', 'startswith', 'endswith'].includes(op)
22
154
 
23
155
  // Normalize scalar from possibly array input
24
156
  const firstVal = Array.isArray(value) ? value[0] : value
25
157
 
26
158
  // Null handling for equality and string ops
27
159
  if (firstVal === null || firstVal === undefined) {
28
- if (op === 'like' || op === 'contains' || op === 'startswith' || op === 'endswith') {
160
+ if (textSearch) {
29
161
  builder[methodNull](columnRef)
30
162
  return
31
163
  }
@@ -33,19 +165,18 @@ export function applyWhereForOperator ({ builder, columnRef, operator, value, kn
33
165
  builder[methodNull](columnRef)
34
166
  return
35
167
  }
168
+ if (op === '!=' || op === '<>' || op === 'is not') {
169
+ builder[or ? 'orWhereNotNull' : 'whereNotNull'](columnRef)
170
+ return
171
+ }
36
172
  }
37
173
 
38
174
  // Text search operators
39
- if (op === 'like' || op === 'contains') {
40
- builder[method](columnRef, likeOp, `%${String(firstVal)}%`)
41
- return
42
- }
43
- if (op === 'startswith') {
44
- builder[method](columnRef, likeOp, `${String(firstVal)}%`)
45
- return
46
- }
47
- if (op === 'endswith') {
48
- builder[method](columnRef, likeOp, `%${String(firstVal)}`)
175
+ if (textSearch) {
176
+ const textColumn = knex.client.config.client === 'pg' ? knex.raw('cast(?? as text)', [columnRef]) : columnRef
177
+ const prefix = op === 'startswith' ? '' : '%'
178
+ const suffix = op === 'endswith' ? '' : '%'
179
+ builder[method](textColumn, likeOp, `${prefix}${String(firstVal)}${suffix}`)
49
180
  return
50
181
  }
51
182
 
@@ -56,16 +187,10 @@ export function applyWhereForOperator ({ builder, columnRef, operator, value, kn
56
187
  return
57
188
  }
58
189
  if (op === 'between') {
59
- const values = Array.isArray(value) ? value : [value]
60
- if (values.length === 2) {
61
- builder.whereBetween(columnRef, values)
62
- } else if (values.length === 1) {
63
- if (values[0] === null || values[0] === undefined) {
64
- builder[methodNull](columnRef)
65
- } else {
66
- builder[method](columnRef, '=', values[0])
67
- }
190
+ if (!Array.isArray(value) || value.length !== 2 || value.some(bound => bound == null)) {
191
+ throw new RestApiValidationError('A between filter requires exactly two non-null bounds')
68
192
  }
193
+ builder[or ? 'orWhereBetween' : 'whereBetween'](columnRef, value)
69
194
  return
70
195
  }
71
196
 
@@ -78,73 +203,6 @@ export function applyWhereForOperator ({ builder, columnRef, operator, value, kn
78
203
  *
79
204
  * @param {Object} hookParams - Hook parameters containing context
80
205
  * @param {Object} dependencies - Dependencies injected by the plugin
81
- *
82
- * @example
83
- * // Input: Search schema with polymorphic filter
84
- * const searchSchema = {
85
- * commentable_title: {
86
- * type: 'string',
87
- * polymorphicField: 'commentable', // Points to the polymorphic relationship
88
- * targetFields: {
89
- * posts: 'title', // When commentable_type='posts', search posts.title
90
- * videos: 'title', // When commentable_type='videos', search videos.title
91
- * articles: 'headline' // When commentable_type='articles', search articles.headline
92
- * },
93
- * filterOperator: 'like'
94
- * }
95
- * };
96
- *
97
- * // Filter request: { commentable_title: 'JavaScript' }
98
- *
99
- * // Result: Adds conditional LEFT JOINs and WHERE conditions
100
- * // SQL generated:
101
- * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
102
- * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
103
- * // LEFT JOIN articles ON comments.commentable_type = 'articles' AND comments.commentable_id = articles.id
104
- * // WHERE (
105
- * // (comments.commentable_type = 'posts' AND posts.title LIKE '%JavaScript%') OR
106
- * // (comments.commentable_type = 'videos' AND videos.title LIKE '%JavaScript%') OR
107
- * // (comments.commentable_type = 'articles' AND articles.headline LIKE '%JavaScript%')
108
- * // )
109
- *
110
- * @example
111
- * // Input: Complex polymorphic filter with cross-table paths
112
- * const searchSchema = {
113
- * commentable_author: {
114
- * type: 'string',
115
- * polymorphicField: 'commentable',
116
- * targetFields: {
117
- * posts: 'author.name', // Search post author's name
118
- * videos: 'creator.name' // Search video creator's name
119
- * }
120
- * }
121
- * };
122
- *
123
- * // Result: Creates nested JOINs for each polymorphic type
124
- * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
125
- * // LEFT JOIN users AS posts_author ON posts.author_id = posts_author.id
126
- * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
127
- * // LEFT JOIN users AS videos_creator ON videos.creator_id = videos_creator.id
128
- *
129
- * @description
130
- * Used by:
131
- * - rest-api-knex-plugin calls this hook first during query building
132
- * - Must run before other filter hooks to establish JOINs
133
- * - Applied when searchSchema contains polymorphicField definitions
134
- *
135
- * Purpose:
136
- * - Enables filtering on polymorphic relationships without knowing the concrete type
137
- * - Searches across multiple tables with a single filter parameter
138
- * - Supports queries like "find all comments on content containing 'JavaScript'"
139
- * - Uses conditional JOINs that only match when type field equals expected value
140
- * - Maintains performance by leveraging indexed type/id columns
141
- *
142
- * Data flow:
143
- * 1. Identifies filters with polymorphicField in searchSchema
144
- * 2. For each polymorphic type, adds conditional LEFT JOIN
145
- * 3. Builds OR conditions checking type field and target field together
146
- * 4. Sets hasJoins flag for subsequent hooks
147
- * 5. Returns modified query with polymorphic search capabilities
148
206
  */
149
207
  export const polymorphicFiltersHook = async (hookParams, dependencies) => {
150
208
  const { log, scopes, knex } = dependencies
@@ -222,7 +280,8 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
222
280
  })
223
281
 
224
282
  // Conditional JOIN - only matches when type is correct
225
- query.leftJoin(`${targetTable} as ${baseAlias}`, function () {
283
+ const targetState = await buildSearchJoinQuery({ scopeName: targetType, scopes, context: hookParams.context, adapterUtils, db })
284
+ query.leftJoin(targetState.query.as(baseAlias), function () {
226
285
  const typeColumn = adapterUtils.translateColumn(scopeName, typeField, tableAlias)
227
286
  const idColumn = adapterUtils.translateColumn(scopeName, idField, tableAlias)
228
287
  const targetIdColumn = adapterUtils.translateColumn(targetType, targetIdField, baseAlias)
@@ -243,71 +302,74 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
243
302
  polymorphicJoins.get(baseAlias).aliasScopeMap = new Map()
244
303
  }
245
304
  polymorphicJoins.get(baseAlias).aliasScopeMap.set(baseAlias, targetType)
305
+ }
246
306
 
247
- // Handle cross-table paths
248
- if (targetFieldPath.includes('.')) {
249
- log.trace('[POLYMORPHIC-SEARCH] Building cross-table JOINs for path:', targetFieldPath)
250
-
251
- const pathParts = targetFieldPath.split('.')
252
- let currentAlias = baseAlias
253
- let currentScope = targetType
254
-
255
- // Build JOIN for each segment except the last
256
- for (let i = 0; i < pathParts.length - 1; i++) {
257
- const relationshipName = pathParts[i]
258
-
259
- // Find the foreign key for this relationship
260
- const currentSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
261
- let foreignKeyField = null
262
- let nextScope = null
263
-
264
- // Search schema for matching belongsTo
265
- for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
266
- if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
267
- foreignKeyField = fieldName
268
- nextScope = fieldDef.belongsTo
269
- break
270
- }
307
+ // Handle cross-table paths
308
+ if (targetFieldPath.includes('.')) {
309
+ log.trace('[POLYMORPHIC-SEARCH] Building cross-table JOINs for path:', targetFieldPath)
310
+
311
+ const pathParts = targetFieldPath.split('.')
312
+ let currentAlias = baseAlias
313
+ let currentScope = targetType
314
+
315
+ // Build JOIN for each segment except the last
316
+ for (let i = 0; i < pathParts.length - 1; i++) {
317
+ const relationshipName = pathParts[i]
318
+
319
+ // Find the foreign key for this relationship
320
+ const currentSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
321
+ let foreignKeyField = null
322
+ let nextScope = null
323
+
324
+ // Search schema for matching belongsTo
325
+ for (const [fieldName, fieldDef] of Object.entries(currentSchema.structure)) {
326
+ if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
327
+ foreignKeyField = fieldName
328
+ nextScope = fieldDef.belongsTo
329
+ break
271
330
  }
331
+ }
272
332
 
273
- if (!foreignKeyField) {
274
- // Check relationships for hasOne
275
- const currentRelationships = scopes[currentScope].vars.schemaInfo.schemaRelationships
276
- const rel = currentRelationships?.[relationshipName]
277
- if (rel?.hasOne) {
278
- // Handle hasOne - more complex
279
- throw new Error(
280
- 'Cross-table polymorphic search through hasOne relationships not yet supported'
281
- )
282
- }
283
-
333
+ if (!foreignKeyField) {
334
+ // Check relationships for hasOne
335
+ const currentRelationships = scopes[currentScope].vars.schemaInfo.schemaRelationships
336
+ const rel = currentRelationships?.[relationshipName]
337
+ if (rel?.hasOne) {
338
+ // Handle hasOne - more complex
284
339
  throw new Error(
285
- `Cannot resolve relationship '${relationshipName}' in path '${targetFieldPath}' for scope '${currentScope}'`
340
+ 'Cross-table polymorphic search through hasOne relationships not yet supported'
286
341
  )
287
342
  }
288
343
 
289
- // Build next JOIN
290
- const nextAlias = `${currentAlias}_${relationshipName}`
291
- const nextSchema = scopes[nextScope].vars.schemaInfo.schemaInstance
292
- const nextTable = nextSchema?.tableName || nextScope
293
-
294
- log.trace('[POLYMORPHIC-SEARCH] Adding cross-table JOIN:', {
295
- from: currentAlias,
296
- to: nextAlias,
297
- table: nextTable
298
- })
344
+ throw new Error(
345
+ `Cannot resolve relationship '${relationshipName}' in path '${targetFieldPath}' for scope '${currentScope}'`
346
+ )
347
+ }
299
348
 
300
- const nextIdField = scopes[nextScope].vars.schemaInfo.idProperty || 'id'
301
- const sourceColumn = adapterUtils.translateColumn(currentScope, foreignKeyField, currentAlias)
302
- const targetColumn = adapterUtils.translateColumn(nextScope, nextIdField, nextAlias)
349
+ // Build next JOIN
350
+ const nextAlias = `${currentAlias}_${relationshipName}`
351
+ const nextSchema = scopes[nextScope].vars.schemaInfo.schemaInstance
352
+ const nextTable = nextSchema?.tableName || nextScope
303
353
 
304
- query.leftJoin(`${nextTable} as ${nextAlias}`, sourceColumn, targetColumn)
354
+ log.trace('[POLYMORPHIC-SEARCH] Adding cross-table JOIN:', {
355
+ from: currentAlias,
356
+ to: nextAlias,
357
+ table: nextTable
358
+ })
305
359
 
306
- currentAlias = nextAlias
307
- currentScope = nextScope
360
+ const nextIdField = scopes[nextScope].vars.schemaInfo.idProperty || 'id'
361
+ const sourceColumn = adapterUtils.translateColumn(currentScope, foreignKeyField, currentAlias)
362
+ const targetColumn = adapterUtils.translateColumn(nextScope, nextIdField, nextAlias)
308
363
 
309
- polymorphicJoins.get(baseAlias).aliasScopeMap.set(currentAlias, currentScope)
364
+ if (!polymorphicJoins.get(baseAlias).aliasScopeMap.has(nextAlias)) {
365
+ const nextState = await buildSearchJoinQuery({ scopeName: nextScope, scopes, context: hookParams.context, adapterUtils, db })
366
+ query.leftJoin(nextState.query.as(nextAlias), sourceColumn, targetColumn)
310
367
  }
368
+
369
+ currentAlias = nextAlias
370
+ currentScope = nextScope
371
+
372
+ polymorphicJoins.get(baseAlias).aliasScopeMap.set(currentAlias, currentScope)
311
373
  }
312
374
  }
313
375
  }
@@ -327,10 +389,24 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
327
389
  // Mark that we have JOINs for other hooks
328
390
  hookParams.context.knexQuery.hasJoins = true
329
391
 
392
+ const references = new Map()
393
+ for (const { fieldDef, polymorphicField } of polymorphicSearches.values()) {
394
+ for (const [target, path] of Object.entries(fieldDef.targetFields)) {
395
+ const baseAlias = `${tableAlias}_${polymorphicField}_${target}`
396
+ const parts = path.split('.')
397
+ const field = parts.pop()
398
+ const alias = [baseAlias, ...parts].join('_')
399
+ const targetScope = polymorphicJoins.get(baseAlias).aliasScopeMap.get(alias)
400
+ references.set(adapterUtils.translateColumn(targetScope, field, alias), { scopeName: targetScope, field, alias })
401
+ }
402
+ }
403
+ const columns = await resolveSearchColumns(references, { scopes, context: hookParams.context, adapterUtils, db })
404
+
330
405
  // Step 3: Apply WHERE conditions
331
406
  query.where(function applyPolymorphicWhere () {
332
407
  const applyComparison = (builder, scope, alias, field, operator, rawValue) => {
333
- const columnRef = adapterUtils.translateColumn(scope, field, alias)
408
+ const qualified = adapterUtils.translateColumn(scope, field, alias)
409
+ const columnRef = columns.get(qualified) || qualified
334
410
  const normalizedValue = adapterUtils.translateFilterValue(scope, field, rawValue)
335
411
  applyWhereForOperator({ builder, columnRef, operator, value: normalizedValue, knex })
336
412
  }
@@ -351,6 +427,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
351
427
  applyComparison(this, scopeName, typeColumnAlias, typeField, '=', targetType)
352
428
 
353
429
  const baseAlias = `${tableAlias}_${searchInfo.polymorphicField}_${targetType}`
430
+ this.whereNotNull(adapterUtils.translateColumn(targetType, 'id', baseAlias))
354
431
  const joinMeta = polymorphicJoins.get(baseAlias)
355
432
  const aliasScopeMap = joinMeta?.aliasScopeMap || new Map([[baseAlias, targetType]])
356
433
 
@@ -383,92 +460,6 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
383
460
  *
384
461
  * @param {Object} hookParams - Hook parameters containing context
385
462
  * @param {Object} dependencies - Dependencies injected by the plugin
386
- *
387
- * @example
388
- * // Input: Simple cross-table filter
389
- * const searchSchema = {
390
- * author_name: {
391
- * type: 'string',
392
- * actualField: 'author.name', // Dot notation indicates JOIN needed
393
- * filterOperator: 'like'
394
- * }
395
- * };
396
- *
397
- * // Filter request: { author_name: 'Smith' }
398
- * // Query before: SELECT * FROM articles
399
- *
400
- * // Result: Adds JOIN and qualified WHERE
401
- * // Query after:
402
- * // SELECT * FROM articles
403
- * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
404
- * // WHERE articles_author.name LIKE '%Smith%'
405
- *
406
- * @example
407
- * // Input: Multi-field search across tables
408
- * const searchSchema = {
409
- * search: {
410
- * type: 'string',
411
- * oneOf: [
412
- * 'title', // Local field
413
- * 'content', // Local field
414
- * 'author.name', // Requires JOIN to users
415
- * 'category.title' // Requires JOIN to categories
416
- * ],
417
- * filterOperator: 'like'
418
- * }
419
- * };
420
- *
421
- * // Filter request: { search: 'JavaScript' }
422
- *
423
- * // Result: Multiple JOINs and OR conditions
424
- * // SELECT DISTINCT * FROM articles
425
- * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
426
- * // LEFT JOIN categories AS articles_category ON articles.category_id = articles_category.id
427
- * // WHERE (
428
- * // articles.title LIKE '%JavaScript%' OR
429
- * // articles.content LIKE '%JavaScript%' OR
430
- * // articles_author.name LIKE '%JavaScript%' OR
431
- * // articles_category.title LIKE '%JavaScript%'
432
- * // )
433
- *
434
- * @example
435
- * // Input: Deep nested relationships (3 levels)
436
- * const searchSchema = {
437
- * company_country: {
438
- * type: 'string',
439
- * actualField: 'author.company.country.name'
440
- * }
441
- * };
442
- *
443
- * // Filter request: { company_country: 'USA' }
444
- *
445
- * // Result: Chain of JOINs following relationships
446
- * // SELECT * FROM articles
447
- * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
448
- * // LEFT JOIN companies AS articles_author_company ON articles_author.company_id = articles_author_company.id
449
- * // LEFT JOIN countries AS articles_author_company_country ON articles_author_company.country_id = articles_author_company_country.id
450
- * // WHERE articles_author_company_country.name = 'USA'
451
- *
452
- * @description
453
- * Used by:
454
- * - rest-api-knex-plugin calls this hook second during query building
455
- * - Runs after polymorphicFiltersHook but before basicFiltersHook
456
- * - Applied when filters contain dot notation in actualField or oneOf
457
- *
458
- * Purpose:
459
- * - Enables filtering on related table fields without manual JOIN writing
460
- * - Automatically builds JOIN chains from dot notation paths
461
- * - Detects one-to-many relationships and adds DISTINCT to prevent duplicates
462
- * - Creates unique aliases to avoid naming conflicts
463
- * - Validates that target fields are indexed for performance
464
- *
465
- * Data flow:
466
- * 1. Scans filters for dot notation in actualField or oneOf arrays
467
- * 2. For each cross-table reference, builds JOIN chain via buildJoinChain
468
- * 3. Applies JOINs to query with proper aliasing
469
- * 4. Adds DISTINCT if any one-to-many JOINs detected
470
- * 5. Applies WHERE conditions using qualified field names
471
- * 6. Sets hasJoins flag for basicFiltersHook to use
472
463
  */
473
464
  export const crossTableFiltersHook = async (hookParams, dependencies) => {
474
465
  const { log, scopes, knex } = dependencies
@@ -489,13 +480,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
489
480
  const aliasScopeMap = new Map()
490
481
  aliasScopeMap.set(tableAlias, scopeName)
491
482
 
492
- // Step 1: Analyze indexes
493
- const requiredIndexes = analyzeRequiredIndexes(scopes, log, scopeName, schemaInfo)
494
- if (requiredIndexes.length > 0) {
495
- log.debug('Cross-table search requires indexes:', requiredIndexes)
496
- }
497
-
498
- // Step 2: Build JOIN maps
483
+ // Build JOIN maps.
499
484
  const joinMap = new Map()
500
485
  const fieldPathMap = new Map()
501
486
  let hasCrossTableFilters = false
@@ -562,13 +547,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
562
547
  return adapterUtils.translateColumn(scopeForAlias, field, alias)
563
548
  }
564
549
 
565
- const resolveFieldColumn = (field) => {
566
- if (field.includes('.')) {
567
- const qualified = fieldPathMap.get(field) || field
568
- return translateQualifiedColumn(qualified)
569
- }
570
- return adapterUtils.translateColumn(scopeName, field, tableAlias)
571
- }
550
+ const resolveFieldColumn = field => columns.get(field)
572
551
 
573
552
  const normalizeFieldValue = (field, value) => {
574
553
  if (field.includes('.')) {
@@ -587,11 +566,11 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
587
566
  return adapterUtils.translateFilterValue(scopeName, field, value)
588
567
  }
589
568
 
590
- // Step 3: Apply JOINs
569
+ // Apply JOINs.
591
570
  const appliedJoins = new Set()
592
571
 
593
- const applyPolymorphicJoin = (join) => {
594
- query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function () {
572
+ const applyPolymorphicJoin = (join, source) => {
573
+ query.leftJoin(source.as(join.joinAlias), function () {
595
574
  const parts = join.joinCondition.split(' AND ')
596
575
  const [typeCondition, idCondition] = parts
597
576
 
@@ -617,37 +596,40 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
617
596
  })
618
597
  }
619
598
 
620
- const applyStandardJoin = (join) => {
599
+ const applyStandardJoin = (join, source) => {
621
600
  const [leftSide, rightSide] = join.joinCondition.split(' = ')
622
601
  const translatedLeft = translateQualifiedColumn(leftSide.trim())
623
602
  const translatedRight = translateQualifiedColumn(rightSide.trim())
624
- query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function () {
603
+ query.leftJoin(source.as(join.joinAlias), function () {
625
604
  this.on(translatedLeft, translatedRight)
626
605
  })
627
606
  }
628
607
 
629
- const processJoin = (join) => {
608
+ const processJoin = async (join) => {
630
609
  const joinKey = `${join.joinAlias}:${join.joinCondition}`
631
610
  if (appliedJoins.has(joinKey)) return
632
611
 
612
+ const state = join.relationshipType === 'manyToMany_pivot' && dependencies.buildSearchMembershipQuery
613
+ ? await dependencies.buildSearchMembershipQuery({ ...join, db, context: hookParams.context })
614
+ : await buildSearchJoinQuery({ scopeName: join.targetScopeName, scopes, context: hookParams.context, adapterUtils, db })
633
615
  if (join.isPolymorphic && join.joinCondition.includes(' AND ')) {
634
- applyPolymorphicJoin(join)
616
+ applyPolymorphicJoin(join, state.query)
635
617
  } else {
636
- applyStandardJoin(join)
618
+ applyStandardJoin(join, state.query)
637
619
  }
638
620
 
639
621
  appliedJoins.add(joinKey)
640
622
  }
641
623
 
642
- joinMap.forEach((joinInfo) => {
624
+ for (const joinInfo of joinMap.values()) {
643
625
  if (joinInfo.isMultiLevel && Array.isArray(joinInfo.joinChain)) {
644
- joinInfo.joinChain.forEach(processJoin)
626
+ for (const join of joinInfo.joinChain) await processJoin(join)
645
627
  } else {
646
- processJoin(joinInfo)
628
+ await processJoin(joinInfo)
647
629
  }
648
- })
630
+ }
649
631
 
650
- // Step 4: Handle DISTINCT
632
+ // Handle DISTINCT.
651
633
  let hasOneToManyJoins = false
652
634
  joinMap.forEach((joinInfo) => {
653
635
  if (joinInfo.isOneToMany) {
@@ -667,6 +649,22 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
667
649
  // Store state for basic filters hook
668
650
  hookParams.context.knexQuery.hasJoins = true
669
651
 
652
+ const references = new Map()
653
+ for (const filterKey of Object.keys(filters)) {
654
+ const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
655
+ if (!fieldDef || fieldDef.polymorphicField ||
656
+ (!fieldDef.actualField?.includes('.') && !fieldDef.oneOf?.some(field => field.includes('.')))) continue
657
+ for (const field of fieldDef.oneOf || [fieldDef.actualField || filterKey]) {
658
+ if (field.includes('.')) {
659
+ const [alias, ...parts] = (fieldPathMap.get(field) || field).split('.')
660
+ references.set(field, { scopeName: aliasScopeMap.get(alias) || scopeName, field: parts.join('.'), alias })
661
+ } else {
662
+ references.set(field, { scopeName, field, alias: tableAlias })
663
+ }
664
+ }
665
+ }
666
+ const columns = await resolveSearchColumns(references, { scopes, context: hookParams.context, adapterUtils, db })
667
+
670
668
  // Step 5: Apply WHERE conditions for cross-table filters
671
669
  query.where(function () {
672
670
  for (const [filterKey, filterValue] of Object.entries(filters)) {
@@ -688,8 +686,6 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
688
686
  let searchTerms = [filterValue]
689
687
  if (fieldDef.splitBy && typeof filterValue === 'string') {
690
688
  searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim())
691
- } else if (Array.isArray(filterValue)) {
692
- searchTerms = filterValue
693
689
  }
694
690
 
695
691
  const applyTermComparison = (builder, method, field, raw) => {
@@ -706,27 +702,25 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
706
702
  }
707
703
 
708
704
  this.where(function () {
709
- if (fieldDef.matchAll && searchTerms.length > 1) {
710
- searchTerms.forEach(term => {
711
- this.andWhere(function () {
712
- fieldDef.oneOf.forEach((field, index) => {
713
- const method = index === 0 ? 'and' : 'or'
714
- applyTermComparison(this, method, field, term)
715
- })
705
+ searchTerms.forEach((term, termIndex) => {
706
+ const join = fieldDef.matchAll || termIndex === 0 ? 'where' : 'orWhere'
707
+ this[join](function () {
708
+ fieldDef.oneOf.forEach((field, index) => {
709
+ applyTermComparison(this, index === 0 ? 'and' : 'or', field, term)
716
710
  })
717
711
  })
718
- } else {
719
- fieldDef.oneOf.forEach((field, index) => {
720
- const method = index === 0 ? 'and' : 'or'
721
- applyTermComparison(this, method, field, filterValue)
722
- })
723
- }
712
+ })
724
713
  })
725
714
  break
726
715
  }
727
716
 
728
717
  case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
729
- fieldDef.applyFilter.call(this, this, filterValue)
718
+ fieldDef.applyFilter.call(this, this, filterValue, {
719
+ column: field => translateQualifiedColumn(fieldPathMap.get(field) || field),
720
+ value: normalizeFieldValue,
721
+ context: hookParams.context,
722
+ scopeName
723
+ })
730
724
  break
731
725
 
732
726
  default: {
@@ -747,141 +741,6 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
747
741
  *
748
742
  * @param {Object} hookParams - Hook parameters containing context
749
743
  * @param {Object} dependencies - Dependencies injected by the plugin
750
- *
751
- * @example
752
- * // Input: Basic equality filter
753
- * const searchSchema = {
754
- * status: {
755
- * type: 'string',
756
- * filterOperator: '=' // Default is '=' if not specified
757
- * }
758
- * };
759
- *
760
- * // Filter request: { status: 'published' }
761
- * // Query before: SELECT * FROM articles
762
- *
763
- * // Result: Adds qualified WHERE clause
764
- * // Query after: SELECT * FROM articles WHERE articles.status = 'published'
765
- *
766
- * @example
767
- * // Input: LIKE filter for partial text matching
768
- * const searchSchema = {
769
- * title: {
770
- * type: 'string',
771
- * filterOperator: 'like'
772
- * },
773
- * content: {
774
- * type: 'string',
775
- * filterOperator: 'like'
776
- * }
777
- * };
778
- *
779
- * // Filter request: { title: 'JavaScript', content: 'async' }
780
- *
781
- * // Result: Multiple LIKE conditions
782
- * // WHERE articles.title LIKE '%JavaScript%'
783
- * // AND articles.content LIKE '%async%'
784
- *
785
- * @example
786
- * // Input: Multi-field OR search with oneOf
787
- * const searchSchema = {
788
- * search: {
789
- * type: 'string',
790
- * oneOf: ['title', 'content', 'summary'],
791
- * filterOperator: 'like',
792
- * splitBy: ' ', // Split search terms by space
793
- * matchAll: true // All terms must match somewhere
794
- * }
795
- * };
796
- *
797
- * // Filter request: { search: 'REST API' }
798
- *
799
- * // Result: Each term must match in at least one field
800
- * // WHERE (
801
- * // (articles.title LIKE '%REST%' OR articles.content LIKE '%REST%' OR articles.summary LIKE '%REST%')
802
- * // AND
803
- * // (articles.title LIKE '%API%' OR articles.content LIKE '%API%' OR articles.summary LIKE '%API%')
804
- * // )
805
- *
806
- * @example
807
- * // Input: Advanced operators - IN and BETWEEN
808
- * const searchSchema = {
809
- * category_id: {
810
- * type: 'array',
811
- * filterOperator: 'in'
812
- * },
813
- * price: {
814
- * type: 'array',
815
- * filterOperator: 'between'
816
- * },
817
- * tags: {
818
- * type: 'array',
819
- * filterOperator: 'in'
820
- * }
821
- * };
822
- *
823
- * // Filter request: {
824
- * // category_id: [1, 2, 3],
825
- * // price: [10.00, 99.99],
826
- * // tags: ['javascript', 'nodejs']
827
- * // }
828
- *
829
- * // Result: IN and BETWEEN clauses
830
- * // WHERE articles.category_id IN (1, 2, 3)
831
- * // AND articles.price BETWEEN 10.00 AND 99.99
832
- * // AND articles.tags IN ('javascript', 'nodejs')
833
- *
834
- * @example
835
- * // Input: Custom filter function
836
- * const searchSchema = {
837
- * has_comments: {
838
- * type: 'boolean',
839
- * applyFilter: function(query, value) {
840
- * if (value === true) {
841
- * query.whereExists(function() {
842
- * this.select('id')
843
- * .from('comments')
844
- * .whereRaw('comments.article_id = articles.id');
845
- * });
846
- * } else if (value === false) {
847
- * query.whereNotExists(function() {
848
- * this.select('id')
849
- * .from('comments')
850
- * .whereRaw('comments.article_id = articles.id');
851
- * });
852
- * }
853
- * }
854
- * }
855
- * };
856
- *
857
- * // Filter request: { has_comments: true }
858
- *
859
- * // Result: Subquery to check existence
860
- * // WHERE EXISTS (
861
- * // SELECT id FROM comments WHERE comments.article_id = articles.id
862
- * // )
863
- *
864
- * @description
865
- * Used by:
866
- * - rest-api-knex-plugin calls this hook last during query building
867
- * - Runs after all JOINs have been established by previous hooks
868
- * - Handles all non-cross-table, non-polymorphic filters
869
- *
870
- * Purpose:
871
- * - Implements standard SQL filtering operations safely via Knex
872
- * - Always qualifies field names with table name to prevent ambiguity
873
- * - Supports various operators: =, like, in, between, and custom
874
- * - Enables multi-field OR searches with optional term splitting
875
- * - Handles null values appropriately (using whereNull)
876
- * - Allows custom filter logic via applyFilter functions
877
- *
878
- * Data flow:
879
- * 1. Skips filters already handled by polymorphic/cross-table hooks
880
- * 2. Qualifies all field names with table name (e.g., articles.title)
881
- * 3. Applies appropriate WHERE clause based on filterOperator
882
- * 4. For oneOf filters, creates OR conditions across specified fields
883
- * 5. For custom filters, delegates to applyFilter function
884
- * 6. Returns query with all basic filters applied
885
744
  */
886
745
  export const basicFiltersHook = async (hookParams, dependencies) => {
887
746
  const { log, scopes, knex } = dependencies
@@ -891,6 +750,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
891
750
  const scopeName = hookParams.context?.knexQuery?.scopeName
892
751
  const filters = hookParams.context?.knexQuery?.filters
893
752
  const query = hookParams.context?.knexQuery?.query
753
+ const db = hookParams.context?.knexQuery?.db || knex
894
754
  if (!scopeName || !filters || !scopes[scopeName]) {
895
755
  return
896
756
  }
@@ -902,14 +762,18 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
902
762
  log.trace('[DEBUG basicFiltersHook] Called with:', {
903
763
  scopeName,
904
764
  hasFilters: !!filters,
905
- filters,
906
- searchSchemaKeys: Object.keys(schemaInfo.searchSchemaStructure || {}),
907
765
  tableName
908
766
  })
909
767
 
910
- // Check if we have any JOINs applied (to know if we need to qualify fields)
911
- // Instead of relying on hasJoins flag, always qualify fields for safety
912
- const qualifyField = (field) => adapterUtils.translateColumn(scopeName, field, tableAlias)
768
+ const references = new Map()
769
+ for (const filterKey of Object.keys(filters)) {
770
+ const fieldDef = schemaInfo.searchSchemaStructure[filterKey]
771
+ if (!fieldDef || fieldDef.polymorphicField || fieldDef.actualField?.includes('.') ||
772
+ fieldDef.oneOf?.some(field => field.includes('.')) || (fieldDef.applyFilter && !fieldDef.oneOf)) continue
773
+ for (const field of fieldDef.oneOf || [fieldDef.actualField || filterKey]) references.set(field, { scopeName, field, alias: tableAlias })
774
+ }
775
+ const columns = await resolveSearchColumns(references, { scopes, context: hookParams.context, adapterUtils, db })
776
+ const qualifyField = field => columns.get(field)
913
777
  const normalizeValue = (field, value) => {
914
778
  if (Array.isArray(value)) {
915
779
  return value.map((entry) => adapterUtils.translateFilterValue(scopeName, field, entry))
@@ -925,7 +789,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
925
789
  log.trace(`[DEBUG basicFiltersHook] No field definition for filter key: ${filterKey}`)
926
790
  continue
927
791
  }
928
- log.trace(`[DEBUG basicFiltersHook] Processing filter: ${filterKey} = ${filterValue}, fieldDef:`, fieldDef)
792
+ log.trace(`[DEBUG basicFiltersHook] Processing filter: ${filterKey}`)
929
793
 
930
794
  // Skip if this is a cross-table filter
931
795
  if (fieldDef.actualField?.includes('.') ||
@@ -948,47 +812,33 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
948
812
  }
949
813
 
950
814
  this.where(function () {
951
- if (fieldDef.matchAll && searchTerms.length > 1) {
952
- searchTerms.forEach(term => {
953
- const normalizedTerm = normalizeValue(fieldDef.oneOf[0], term)
954
- this.andWhere(function () {
955
- fieldDef.oneOf.forEach((field, index) => {
956
- const columnRef = qualifyField(field)
957
- const perFieldValue = Array.isArray(normalizedTerm)
958
- ? normalizeValue(field, term)
959
- : normalizeValue(field, term)
960
- applyWhereForOperator({
961
- builder: this,
962
- columnRef,
963
- operator,
964
- value: perFieldValue,
965
- knex,
966
- or: index !== 0,
967
- })
815
+ searchTerms.forEach((term, termIndex) => {
816
+ const join = fieldDef.matchAll || termIndex === 0 ? 'where' : 'orWhere'
817
+ this[join](function () {
818
+ fieldDef.oneOf.forEach((field, index) => {
819
+ applyWhereForOperator({
820
+ builder: this,
821
+ columnRef: qualifyField(field),
822
+ operator,
823
+ value: normalizeValue(field, term),
824
+ knex,
825
+ or: index !== 0,
968
826
  })
969
827
  })
970
828
  })
971
- } else {
972
- fieldDef.oneOf.forEach((field, index) => {
973
- const columnRef = qualifyField(field)
974
- const normalizedValue = normalizeValue(field, filterValue)
975
- applyWhereForOperator({
976
- builder: this,
977
- columnRef,
978
- operator,
979
- value: normalizedValue,
980
- knex,
981
- or: index !== 0,
982
- })
983
- })
984
- }
829
+ })
985
830
  })
986
831
  break
987
832
  }
988
833
 
989
834
  case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
990
835
  // Custom filter
991
- fieldDef.applyFilter.call(this, this, filterValue)
836
+ fieldDef.applyFilter.call(this, this, filterValue, {
837
+ column: field => adapterUtils.translateColumn(scopeName, field, tableAlias),
838
+ value: normalizeValue,
839
+ context: hookParams.context,
840
+ scopeName
841
+ })
992
842
  break
993
843
 
994
844
  default: {
@@ -999,7 +849,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
999
849
 
1000
850
  const operator = resolveSearchOperator(fieldDef)
1001
851
  const normalized = normalizeValue(actualField, filterValue)
1002
- log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator} ${filterValue}`)
852
+ log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator}`)
1003
853
  applyWhereForOperator({ builder: this, columnRef: dbField, operator, value: normalized, knex })
1004
854
  break
1005
855
  }