json-rest-api 1.0.6 → 1.0.8

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 (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +99 -33
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -1,56 +1,28 @@
1
1
  /**
2
- * @module knex-query-helpers
3
- * @description Knex query filter hooks for complex SQL query building
4
- *
5
- * This module contains three separate hooks for filtering queries:
6
- * 1. polymorphicFiltersHook - Handles polymorphic relationship filters
7
- * 2. crossTableFiltersHook - Handles filters requiring JOINs
8
- * 3. basicFiltersHook - Handles basic filters on the main table
9
- *
10
- * These hooks are designed to run in this specific order to ensure
11
- * proper field qualification when JOINs are present. They implement
12
- * sophisticated query building patterns to handle complex filtering
13
- * scenarios while maintaining SQL injection safety and performance.
14
- *
15
- * Why this is useful upstream:
16
- * - Enables filtering on polymorphic relationships (e.g., find all comments on posts by John)
17
- * - Supports cross-table filtering without N+1 queries
18
- * - Automatically adds JOINs based on filter requirements
19
- * - Handles field qualification to prevent ambiguous column errors
20
- * - Provides extensible hook system for custom filter logic
21
- * - Maintains SQL injection safety through parameterized queries
22
- */
23
-
24
- /**
25
- * Hook 1: Polymorphic Filters Hook
26
- *
27
- * Processes filters that target polymorphic relationships, where a single
28
- * relationship can point to different types of resources. This hook adds
29
- * conditional JOINs for each possible type and applies OR conditions to
30
- * search across all of them. Must run first to establish JOINs before
31
- * other hooks process their filters.
2
+ * Processes filters that target polymorphic relationships where a single relationship can point to different types of resources
32
3
  *
33
4
  * @param {Object} hookParams - Hook parameters containing context
34
5
  * @param {Object} dependencies - Dependencies injected by the plugin
35
6
  *
36
- * @example <caption>Polymorphic filter in searchSchema</caption>
37
- * // Find comments where the commentable item has 'JavaScript' in the title
38
- * searchSchema: {
7
+ * @example
8
+ * // Input: Search schema with polymorphic filter
9
+ * const searchSchema = {
39
10
  * commentable_title: {
40
11
  * type: 'string',
41
- * polymorphicField: 'commentable', // The polymorphic relationship
12
+ * polymorphicField: 'commentable', // Points to the polymorphic relationship
42
13
  * targetFields: {
43
14
  * posts: 'title', // When commentable_type='posts', search posts.title
44
- * videos: 'title', // When commentable_type='videos', search videos.title
15
+ * videos: 'title', // When commentable_type='videos', search videos.title
45
16
  * articles: 'headline' // When commentable_type='articles', search articles.headline
46
17
  * },
47
- * filterUsing: 'like'
18
+ * filterOperator: 'like'
48
19
  * }
49
- * }
20
+ * };
50
21
  *
51
- * @example <caption>Generated SQL for polymorphic search</caption>
52
- * // For filter: { commentable_title: 'JavaScript' }
53
- * // Generates:
22
+ * // Filter request: { commentable_title: 'JavaScript' }
23
+ *
24
+ * // Result: Adds conditional LEFT JOINs and WHERE conditions
25
+ * // SQL generated:
54
26
  * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
55
27
  * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
56
28
  * // LEFT JOIN articles ON comments.commentable_type = 'articles' AND comments.commentable_id = articles.id
@@ -60,13 +32,44 @@
60
32
  * // (comments.commentable_type = 'articles' AND articles.headline LIKE '%JavaScript%')
61
33
  * // )
62
34
  *
63
- * @example <caption>Why this is useful upstream</caption>
64
- * // The REST API Knex plugin uses this to:
65
- * // 1. Enable filtering on polymorphic relationships without knowing the type
66
- * // 2. Search across multiple tables with a single filter parameter
67
- * // 3. Support complex queries like "find all comments on content by John"
68
- * // 4. Handle conditional JOINs that only match when type is correct
69
- * // 5. Maintain query performance by using indexed type/id columns
35
+ * @example
36
+ * // Input: Complex polymorphic filter with cross-table paths
37
+ * const searchSchema = {
38
+ * commentable_author: {
39
+ * type: 'string',
40
+ * polymorphicField: 'commentable',
41
+ * targetFields: {
42
+ * posts: 'author.name', // Search post author's name
43
+ * videos: 'creator.name' // Search video creator's name
44
+ * }
45
+ * }
46
+ * };
47
+ *
48
+ * // Result: Creates nested JOINs for each polymorphic type
49
+ * // LEFT JOIN posts ON comments.commentable_type = 'posts' AND comments.commentable_id = posts.id
50
+ * // LEFT JOIN users AS posts_author ON posts.author_id = posts_author.id
51
+ * // LEFT JOIN videos ON comments.commentable_type = 'videos' AND comments.commentable_id = videos.id
52
+ * // LEFT JOIN users AS videos_creator ON videos.creator_id = videos_creator.id
53
+ *
54
+ * @description
55
+ * Used by:
56
+ * - rest-api-knex-plugin calls this hook first during query building
57
+ * - Must run before other filter hooks to establish JOINs
58
+ * - Applied when searchSchema contains polymorphicField definitions
59
+ *
60
+ * Purpose:
61
+ * - Enables filtering on polymorphic relationships without knowing the concrete type
62
+ * - Searches across multiple tables with a single filter parameter
63
+ * - Supports queries like "find all comments on content containing 'JavaScript'"
64
+ * - Uses conditional JOINs that only match when type field equals expected value
65
+ * - Maintains performance by leveraging indexed type/id columns
66
+ *
67
+ * Data flow:
68
+ * 1. Identifies filters with polymorphicField in searchSchema
69
+ * 2. For each polymorphic type, adds conditional LEFT JOIN
70
+ * 3. Builds OR conditions checking type field and target field together
71
+ * 4. Sets hasJoins flag for subsequent hooks
72
+ * 5. Returns modified query with polymorphic search capabilities
70
73
  */
71
74
  export const polymorphicFiltersHook = async (hookParams, dependencies) => {
72
75
  const { log, scopes, knex } = dependencies;
@@ -263,7 +266,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
263
266
  finalAlias = `${finalAlias}_${pathParts[i]}`;
264
267
  }
265
268
 
266
- const operator = searchInfo.fieldDef.filterUsing || '=';
269
+ const operator = searchInfo.fieldDef.filterOperator || '=';
267
270
  if (operator === 'like') {
268
271
  this.where(`${finalAlias}.${fieldName}`, 'like', `%${searchInfo.filterValue}%`);
269
272
  } else {
@@ -271,7 +274,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
271
274
  }
272
275
  } else {
273
276
  // Direct field
274
- const operator = searchInfo.fieldDef.filterUsing || '=';
277
+ const operator = searchInfo.fieldDef.filterOperator || '=';
275
278
  if (operator === 'like') {
276
279
  this.where(`${baseAlias}.${targetFieldPath}`, 'like', `%${searchInfo.filterValue}%`);
277
280
  } else {
@@ -286,66 +289,96 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
286
289
  };
287
290
 
288
291
  /**
289
- * Hook 2: Cross-Table Filters Hook
290
- *
291
- * Processes filters that require JOINs to access fields in related tables.
292
- * This hook handles dot-notation field paths (e.g., 'author.name') by building
293
- * the necessary JOIN chain and applying filters on the joined tables. It also
294
- * handles DISTINCT when one-to-many JOINs are detected to prevent duplicate results.
295
- * Must run after polymorphic but before basic to ensure hasJoins flag is set.
292
+ * Processes filters that require JOINs to access fields in related tables using dot notation
296
293
  *
297
294
  * @param {Object} hookParams - Hook parameters containing context
298
295
  * @param {Object} dependencies - Dependencies injected by the plugin
299
296
  *
300
- * @example <caption>Cross-table filter with actualField</caption>
301
- * // Find articles where the author's name contains 'Smith'
302
- * searchSchema: {
297
+ * @example
298
+ * // Input: Simple cross-table filter
299
+ * const searchSchema = {
303
300
  * author_name: {
304
301
  * type: 'string',
305
- * actualField: 'author.name', // Dot notation for cross-table access
306
- * filterUsing: 'like'
302
+ * actualField: 'author.name', // Dot notation indicates JOIN needed
303
+ * filterOperator: 'like'
307
304
  * }
308
- * }
309
- * // Generates:
305
+ * };
306
+ *
307
+ * // Filter request: { author_name: 'Smith' }
308
+ * // Query before: SELECT * FROM articles
309
+ *
310
+ * // Result: Adds JOIN and qualified WHERE
311
+ * // Query after:
312
+ * // SELECT * FROM articles
310
313
  * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
311
314
  * // WHERE articles_author.name LIKE '%Smith%'
312
315
  *
313
- * @example <caption>Multi-field search across tables with likeOneOf</caption>
314
- * searchSchema: {
316
+ * @example
317
+ * // Input: Multi-field search across tables
318
+ * const searchSchema = {
315
319
  * search: {
316
320
  * type: 'string',
317
- * likeOneOf: [
318
- * 'title', // Search in articles.title
319
- * 'content', // Search in articles.content
320
- * 'author.name', // Search in joined users.name
321
- * 'category.title' // Search in joined categories.title
322
- * ]
321
+ * oneOf: [
322
+ * 'title', // Local field
323
+ * 'content', // Local field
324
+ * 'author.name', // Requires JOIN to users
325
+ * 'category.title' // Requires JOIN to categories
326
+ * ],
327
+ * filterOperator: 'like'
323
328
  * }
324
- * }
325
- * // For filter: { search: 'JavaScript' }
326
- * // Generates multiple JOINs and OR conditions across all fields
329
+ * };
330
+ *
331
+ * // Filter request: { search: 'JavaScript' }
327
332
  *
328
- * @example <caption>Deep nested relationships</caption>
329
- * searchSchema: {
333
+ * // Result: Multiple JOINs and OR conditions
334
+ * // SELECT DISTINCT * FROM articles
335
+ * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
336
+ * // LEFT JOIN categories AS articles_category ON articles.category_id = articles_category.id
337
+ * // WHERE (
338
+ * // articles.title LIKE '%JavaScript%' OR
339
+ * // articles.content LIKE '%JavaScript%' OR
340
+ * // articles_author.name LIKE '%JavaScript%' OR
341
+ * // articles_category.title LIKE '%JavaScript%'
342
+ * // )
343
+ *
344
+ * @example
345
+ * // Input: Deep nested relationships (3 levels)
346
+ * const searchSchema = {
330
347
  * company_country: {
331
348
  * type: 'string',
332
- * actualField: 'author.company.country.name', // 3-level deep
333
- * filterUsing: '='
349
+ * actualField: 'author.company.country.name'
334
350
  * }
335
- * }
336
- * // Generates chain of JOINs:
351
+ * };
352
+ *
353
+ * // Filter request: { company_country: 'USA' }
354
+ *
355
+ * // Result: Chain of JOINs following relationships
356
+ * // SELECT * FROM articles
337
357
  * // LEFT JOIN users AS articles_author ON articles.author_id = articles_author.id
338
358
  * // LEFT JOIN companies AS articles_author_company ON articles_author.company_id = articles_author_company.id
339
359
  * // LEFT JOIN countries AS articles_author_company_country ON articles_author_company.country_id = articles_author_company_country.id
360
+ * // WHERE articles_author_company_country.name = 'USA'
340
361
  *
341
- * @example <caption>Why this is useful upstream</caption>
342
- * // The REST API Knex plugin uses this to:
343
- * // 1. Enable filtering on related table fields without manual JOINs
344
- * // 2. Support deep relationship traversal with dot notation
345
- * // 3. Automatically detect and handle one-to-many JOINs with DISTINCT
346
- * // 4. Build optimized JOIN chains with proper aliasing
347
- * // 5. Enable powerful search across multiple related tables
348
- * // 6. Maintain clean API design - clients don't need to know about JOINs
362
+ * @description
363
+ * Used by:
364
+ * - rest-api-knex-plugin calls this hook second during query building
365
+ * - Runs after polymorphicFiltersHook but before basicFiltersHook
366
+ * - Applied when filters contain dot notation in actualField or oneOf
367
+ *
368
+ * Purpose:
369
+ * - Enables filtering on related table fields without manual JOIN writing
370
+ * - Automatically builds JOIN chains from dot notation paths
371
+ * - Detects one-to-many relationships and adds DISTINCT to prevent duplicates
372
+ * - Creates unique aliases to avoid naming conflicts
373
+ * - Validates that target fields are indexed for performance
374
+ *
375
+ * Data flow:
376
+ * 1. Scans filters for dot notation in actualField or oneOf arrays
377
+ * 2. For each cross-table reference, builds JOIN chain via buildJoinChain
378
+ * 3. Applies JOINs to query with proper aliasing
379
+ * 4. Adds DISTINCT if any one-to-many JOINs detected
380
+ * 5. Applies WHERE conditions using qualified field names
381
+ * 6. Sets hasJoins flag for basicFiltersHook to use
349
382
  */
350
383
  export const crossTableFiltersHook = async (hookParams, dependencies) => {
351
384
  const { log, scopes, knex, crossTableSearchHelpers } = dependencies;
@@ -390,12 +423,12 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
390
423
  fieldPathMap.set(fieldDef.actualField, `${joinInfo.joinAlias}.${joinInfo.targetField}`);
391
424
  }
392
425
 
393
- // Check likeOneOf for cross-table references
394
- if (fieldDef.likeOneOf && Array.isArray(fieldDef.likeOneOf)) {
395
- for (const field of fieldDef.likeOneOf) {
426
+ // Check oneOf for cross-table references
427
+ if (fieldDef.oneOf && Array.isArray(fieldDef.oneOf)) {
428
+ for (const field of fieldDef.oneOf) {
396
429
  if (field.includes('.')) {
397
430
  hasCrossTableFilters = true;
398
- log.trace('[JOIN-DETECTION] Cross-table likeOneOf field found', { filterKey, field, scopeName });
431
+ log.trace('[JOIN-DETECTION] Cross-table oneOf field found', { filterKey, field, scopeName });
399
432
  const joinInfo = await crossTableSearchHelpers.buildJoinChain(scopeName, field);
400
433
  if (!joinMap.has(joinInfo.joinAlias)) {
401
434
  joinMap.set(joinInfo.joinAlias, joinInfo);
@@ -418,20 +451,66 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
418
451
  joinInfo.joinChain.forEach((join) => {
419
452
  const joinKey = `${join.joinAlias}:${join.joinCondition}`;
420
453
  if (!appliedJoins.has(joinKey)) {
421
- const [leftSide, rightSide] = join.joinCondition.split(' = ');
422
- query.leftJoin(`${join.targetTableName} as ${join.joinAlias}`, function() {
423
- this.on(leftSide, rightSide);
424
- });
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
+ }
425
481
  appliedJoins.add(joinKey);
426
482
  }
427
483
  });
428
484
  } else {
429
485
  const joinKey = `${joinInfo.joinAlias}:${joinInfo.joinCondition}`;
430
486
  if (!appliedJoins.has(joinKey)) {
431
- const [leftSide, rightSide] = joinInfo.joinCondition.split(' = ');
432
- query.leftJoin(`${joinInfo.targetTableName} as ${joinInfo.joinAlias}`, function() {
433
- this.on(leftSide, rightSide);
434
- });
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
+ }
435
514
  appliedJoins.add(joinKey);
436
515
  }
437
516
  }
@@ -466,27 +545,72 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
466
545
  // Skip non-cross-table and polymorphic filters
467
546
  if (fieldDef.polymorphicField) continue;
468
547
  if (!fieldDef.actualField?.includes('.') &&
469
- !fieldDef.likeOneOf?.some(f => f.includes('.'))) {
548
+ !fieldDef.oneOf?.some(f => f.includes('.'))) {
470
549
  continue;
471
550
  }
472
551
 
473
552
  // Process cross-table filters
474
553
  switch (true) {
475
- case fieldDef.likeOneOf && Array.isArray(fieldDef.likeOneOf):
554
+ case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
555
+ const operator = fieldDef.filterOperator || '=';
556
+
557
+ // Handle split search terms
558
+ let searchTerms = [filterValue];
559
+ if (fieldDef.splitBy && typeof filterValue === 'string') {
560
+ searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim());
561
+ }
562
+
476
563
  this.where(function() {
477
- fieldDef.likeOneOf.forEach((field, index) => {
478
- const dbField = fieldPathMap.get(field) ||
479
- (!field.includes('.') && joinMap.size > 0 ? `${tableName}.${field}` : field);
480
- const condition = `%${filterValue}%`;
481
-
482
- if (index === 0) {
483
- this.where(dbField, 'like', condition);
484
- } else {
485
- this.orWhere(dbField, 'like', condition);
486
- }
487
- });
564
+ if (fieldDef.matchAll && searchTerms.length > 1) {
565
+ // AND logic - all terms must match
566
+ searchTerms.forEach(term => {
567
+ this.andWhere(function() {
568
+ 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
+ }
586
+ });
587
+ });
588
+ });
589
+ } else {
590
+ // OR logic for single term or matchAll=false
591
+ 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
+ }
609
+ });
610
+ }
488
611
  });
489
612
  break;
613
+ }
490
614
 
491
615
  case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
492
616
  fieldDef.applyFilter.call(this, this, filterValue);
@@ -498,7 +622,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
498
622
  dbField = fieldPathMap.get(dbField) || dbField;
499
623
  }
500
624
 
501
- const operator = fieldDef.filterUsing || '=';
625
+ const operator = fieldDef.filterOperator || '=';
502
626
 
503
627
  switch (operator) {
504
628
  case 'like':
@@ -529,94 +653,145 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
529
653
  };
530
654
 
531
655
  /**
532
- * Hook 3: Basic Filters Hook
533
- *
534
- * Processes filters that apply directly to fields on the main table.
535
- * This hook handles standard filtering operations like equality, LIKE,
536
- * IN, BETWEEN, etc. It automatically qualifies field names with the table
537
- * name when JOINs are present to prevent ambiguous column errors.
538
- * Must run last to ensure all JOINs have been established.
656
+ * Processes filters that apply directly to fields on the main table
539
657
  *
540
658
  * @param {Object} hookParams - Hook parameters containing context
541
659
  * @param {Object} dependencies - Dependencies injected by the plugin
542
660
  *
543
- * @example <caption>Basic equality filter</caption>
544
- * searchSchema: {
661
+ * @example
662
+ * // Input: Basic equality filter
663
+ * const searchSchema = {
545
664
  * status: {
546
665
  * type: 'string',
547
- * filterUsing: '=' // Exact match
666
+ * filterOperator: '=' // Default is '=' if not specified
548
667
  * }
549
- * }
550
- * // For filter: { status: 'published' }
551
- * // Generates: WHERE articles.status = 'published'
668
+ * };
669
+ *
670
+ * // Filter request: { status: 'published' }
671
+ * // Query before: SELECT * FROM articles
552
672
  *
553
- * @example <caption>LIKE filter for partial matches</caption>
554
- * searchSchema: {
673
+ * // Result: Adds qualified WHERE clause
674
+ * // Query after: SELECT * FROM articles WHERE articles.status = 'published'
675
+ *
676
+ * @example
677
+ * // Input: LIKE filter for partial text matching
678
+ * const searchSchema = {
555
679
  * title: {
556
680
  * type: 'string',
557
- * filterUsing: 'like'
681
+ * filterOperator: 'like'
682
+ * },
683
+ * content: {
684
+ * type: 'string',
685
+ * filterOperator: 'like'
558
686
  * }
559
- * }
560
- * // For filter: { title: 'JavaScript' }
561
- * // Generates: WHERE articles.title LIKE '%JavaScript%'
687
+ * };
562
688
  *
563
- * @example <caption>Multi-field OR search with likeOneOf</caption>
564
- * searchSchema: {
689
+ * // Filter request: { title: 'JavaScript', content: 'async' }
690
+ *
691
+ * // Result: Multiple LIKE conditions
692
+ * // WHERE articles.title LIKE '%JavaScript%'
693
+ * // AND articles.content LIKE '%async%'
694
+ *
695
+ * @example
696
+ * // Input: Multi-field OR search with oneOf
697
+ * const searchSchema = {
565
698
  * search: {
566
699
  * type: 'string',
567
- * likeOneOf: ['title', 'content', 'summary']
700
+ * oneOf: ['title', 'content', 'summary'],
701
+ * filterOperator: 'like',
702
+ * splitBy: ' ', // Split search terms by space
703
+ * matchAll: true // All terms must match somewhere
568
704
  * }
569
- * }
570
- * // For filter: { search: 'API' }
571
- * // Generates: WHERE (
572
- * // articles.title LIKE '%API%' OR
573
- * // articles.content LIKE '%API%' OR
574
- * // articles.summary LIKE '%API%'
705
+ * };
706
+ *
707
+ * // Filter request: { search: 'REST API' }
708
+ *
709
+ * // Result: Each term must match in at least one field
710
+ * // WHERE (
711
+ * // (articles.title LIKE '%REST%' OR articles.content LIKE '%REST%' OR articles.summary LIKE '%REST%')
712
+ * // AND
713
+ * // (articles.title LIKE '%API%' OR articles.content LIKE '%API%' OR articles.summary LIKE '%API%')
575
714
  * // )
576
715
  *
577
- * @example <caption>Advanced filters - IN and BETWEEN</caption>
578
- * searchSchema: {
716
+ * @example
717
+ * // Input: Advanced operators - IN and BETWEEN
718
+ * const searchSchema = {
579
719
  * category_id: {
580
720
  * type: 'array',
581
- * filterUsing: 'in'
721
+ * filterOperator: 'in'
722
+ * },
723
+ * price: {
724
+ * type: 'array',
725
+ * filterOperator: 'between'
582
726
  * },
583
- * created_at: {
727
+ * tags: {
584
728
  * type: 'array',
585
- * filterUsing: 'between'
729
+ * filterOperator: 'in'
586
730
  * }
587
- * }
588
- * // For filters: {
731
+ * };
732
+ *
733
+ * // Filter request: {
589
734
  * // category_id: [1, 2, 3],
590
- * // created_at: ['2024-01-01', '2024-12-31']
735
+ * // price: [10.00, 99.99],
736
+ * // tags: ['javascript', 'nodejs']
591
737
  * // }
592
- * // Generates:
738
+ *
739
+ * // Result: IN and BETWEEN clauses
593
740
  * // WHERE articles.category_id IN (1, 2, 3)
594
- * // AND articles.created_at BETWEEN '2024-01-01' AND '2024-12-31'
741
+ * // AND articles.price BETWEEN 10.00 AND 99.99
742
+ * // AND articles.tags IN ('javascript', 'nodejs')
595
743
  *
596
- * @example <caption>Custom filter function</caption>
597
- * searchSchema: {
744
+ * @example
745
+ * // Input: Custom filter function
746
+ * const searchSchema = {
598
747
  * has_comments: {
599
748
  * type: 'boolean',
600
749
  * applyFilter: function(query, value) {
601
- * if (value) {
750
+ * if (value === true) {
602
751
  * query.whereExists(function() {
603
752
  * this.select('id')
604
753
  * .from('comments')
605
754
  * .whereRaw('comments.article_id = articles.id');
606
755
  * });
756
+ * } else if (value === false) {
757
+ * query.whereNotExists(function() {
758
+ * this.select('id')
759
+ * .from('comments')
760
+ * .whereRaw('comments.article_id = articles.id');
761
+ * });
607
762
  * }
608
763
  * }
609
764
  * }
610
- * }
611
- *
612
- * @example <caption>Why this is useful upstream</caption>
613
- * // The REST API Knex plugin uses this to:
614
- * // 1. Implement standard SQL filtering operations safely
615
- * // 2. Automatically qualify fields to prevent ambiguous column errors
616
- * // 3. Support multiple filter operators (=, like, in, between, etc.)
617
- * // 4. Enable multi-field OR searches with likeOneOf
618
- * // 5. Allow custom filter logic through applyFilter functions
619
- * // 6. Maintain SQL injection safety through Knex query builder
765
+ * };
766
+ *
767
+ * // Filter request: { has_comments: true }
768
+ *
769
+ * // Result: Subquery to check existence
770
+ * // WHERE EXISTS (
771
+ * // SELECT id FROM comments WHERE comments.article_id = articles.id
772
+ * // )
773
+ *
774
+ * @description
775
+ * Used by:
776
+ * - rest-api-knex-plugin calls this hook last during query building
777
+ * - Runs after all JOINs have been established by previous hooks
778
+ * - Handles all non-cross-table, non-polymorphic filters
779
+ *
780
+ * Purpose:
781
+ * - Implements standard SQL filtering operations safely via Knex
782
+ * - Always qualifies field names with table name to prevent ambiguity
783
+ * - Supports various operators: =, like, in, between, and custom
784
+ * - Enables multi-field OR searches with optional term splitting
785
+ * - Handles null values appropriately (using whereNull)
786
+ * - Allows custom filter logic via applyFilter functions
787
+ *
788
+ * Data flow:
789
+ * 1. Skips filters already handled by polymorphic/cross-table hooks
790
+ * 2. Qualifies all field names with table name (e.g., articles.title)
791
+ * 3. Applies appropriate WHERE clause based on filterOperator
792
+ * 4. For oneOf filters, creates OR conditions across specified fields
793
+ * 5. For custom filters, delegates to applyFilter function
794
+ * 6. Returns query with all basic filters applied
620
795
  */
621
796
  export const basicFiltersHook = async (hookParams, dependencies) => {
622
797
  const { log, scopes, knex } = dependencies;
@@ -629,7 +804,17 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
629
804
  const tableName = hookParams.context?.knexQuery?.tableName;
630
805
  const db = hookParams.context?.knexQuery?.db || knex;
631
806
 
807
+ log.trace('[DEBUG basicFiltersHook] Called with:', {
808
+ scopeName,
809
+ hasFilters: !!filters,
810
+ filters,
811
+ hasSearchSchema: !!searchSchema,
812
+ searchSchemaKeys: searchSchema ? Object.keys(searchSchema.structure || {}) : [],
813
+ tableName
814
+ });
815
+
632
816
  if (!filters || !searchSchema) {
817
+ log.trace('[DEBUG basicFiltersHook] Returning early - no filters or searchSchema');
633
818
  return;
634
819
  }
635
820
 
@@ -643,33 +828,83 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
643
828
  query.where(function() {
644
829
  for (const [filterKey, filterValue] of Object.entries(filters)) {
645
830
  const fieldDef = searchSchema.structure[filterKey];
646
- if (!fieldDef) continue;
831
+ if (!fieldDef) {
832
+ log.trace(`[DEBUG basicFiltersHook] No field definition for filter key: ${filterKey}`);
833
+ continue;
834
+ }
835
+ log.trace(`[DEBUG basicFiltersHook] Processing filter: ${filterKey} = ${filterValue}, fieldDef:`, fieldDef);
647
836
 
648
837
  // Skip if this is a cross-table filter
649
838
  if (fieldDef.actualField?.includes('.') ||
650
- fieldDef.likeOneOf?.some(f => f.includes('.')) ||
839
+ fieldDef.oneOf?.some(f => f.includes('.')) ||
651
840
  fieldDef.polymorphicField) {
841
+ log.trace(`[DEBUG basicFiltersHook] Skipping filter ${filterKey} - is cross-table or polymorphic`);
652
842
  continue;
653
843
  }
654
844
 
655
845
  // Process basic filters
656
846
  switch (true) {
657
- case fieldDef.likeOneOf && Array.isArray(fieldDef.likeOneOf):
847
+ case fieldDef.oneOf && Array.isArray(fieldDef.oneOf): {
658
848
  // Multi-field OR search
849
+ const operator = fieldDef.filterOperator || '=';
850
+
851
+ // Handle split search terms
852
+ let searchTerms = [filterValue];
853
+ if (fieldDef.splitBy && typeof filterValue === 'string') {
854
+ searchTerms = filterValue.split(fieldDef.splitBy).filter(term => term.trim());
855
+ }
856
+
659
857
  this.where(function() {
660
- fieldDef.likeOneOf.forEach((field, index) => {
661
- // Always qualify field names
662
- const dbField = qualifyField(field);
663
- const condition = `%${filterValue}%`;
664
-
665
- if (index === 0) {
666
- this.where(dbField, 'like', condition);
667
- } else {
668
- this.orWhere(dbField, 'like', condition);
669
- }
670
- });
858
+ if (fieldDef.matchAll && searchTerms.length > 1) {
859
+ // AND logic - all terms must match
860
+ searchTerms.forEach(term => {
861
+ this.andWhere(function() {
862
+ fieldDef.oneOf.forEach((field, index) => {
863
+ // Always qualify field names
864
+ const dbField = qualifyField(field);
865
+
866
+ if (operator === 'like') {
867
+ const condition = `%${term}%`;
868
+ if (index === 0) {
869
+ this.where(dbField, 'like', condition);
870
+ } else {
871
+ this.orWhere(dbField, 'like', condition);
872
+ }
873
+ } else {
874
+ if (index === 0) {
875
+ this.where(dbField, operator, term);
876
+ } else {
877
+ this.orWhere(dbField, operator, term);
878
+ }
879
+ }
880
+ });
881
+ });
882
+ });
883
+ } else {
884
+ // OR logic for single term or matchAll=false
885
+ fieldDef.oneOf.forEach((field, index) => {
886
+ // Always qualify field names
887
+ const dbField = qualifyField(field);
888
+
889
+ if (operator === 'like') {
890
+ const condition = `%${filterValue}%`;
891
+ if (index === 0) {
892
+ this.where(dbField, 'like', condition);
893
+ } else {
894
+ this.orWhere(dbField, 'like', condition);
895
+ }
896
+ } else {
897
+ if (index === 0) {
898
+ this.where(dbField, operator, filterValue);
899
+ } else {
900
+ this.orWhere(dbField, operator, filterValue);
901
+ }
902
+ }
903
+ });
904
+ }
671
905
  });
672
906
  break;
907
+ }
673
908
 
674
909
  case fieldDef.applyFilter && typeof fieldDef.applyFilter === 'function':
675
910
  // Custom filter
@@ -682,7 +917,8 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
682
917
  // Always qualify field names
683
918
  const dbField = qualifyField(actualField);
684
919
 
685
- const operator = fieldDef.filterUsing || '=';
920
+ const operator = fieldDef.filterOperator || '=';
921
+ log.trace(`[DEBUG basicFiltersHook] Applying filter: ${dbField} ${operator} ${filterValue}`);
686
922
 
687
923
  switch (operator) {
688
924
  case 'like':