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
@@ -0,0 +1,328 @@
1
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js';
2
+ import { ROW_NUMBER_KEY, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_MAX_INCLUDE_LIMIT } from '../querying-writing/knex-constants.js';
3
+
4
+ /**
5
+ * Builds a window function query for limited includes per parent record
6
+ *
7
+ * @param {Object} knex - Knex instance
8
+ * @param {string} tableName - Target table name
9
+ * @param {string} foreignKey - Foreign key field
10
+ * @param {Array} parentIds - Parent record IDs
11
+ * @param {Array} fieldsToSelect - Fields to select
12
+ * @param {Object} includeConfig - Include configuration (limit, orderBy)
13
+ * @param {Object} capabilities - Database capabilities
14
+ * @param {Object} scopeVars - Scope variables for defaults and limits
15
+ * @returns {Object} Knex query
16
+ *
17
+ * @example
18
+ * // Input: Load max 3 comments per article, ordered by newest first
19
+ * const query = buildWindowedIncludeQuery(
20
+ * knex,
21
+ * 'comments',
22
+ * 'article_id',
23
+ * [1, 2, 3], // Article IDs
24
+ * ['id', 'text', 'created_at'],
25
+ * { limit: 3, orderBy: ['-created_at'] },
26
+ * { windowFunctions: true },
27
+ * { queryDefaultLimit: 10 }
28
+ * );
29
+ *
30
+ * // Generated SQL:
31
+ * // WITH _windowed AS (
32
+ * // SELECT id, text, created_at, article_id,
33
+ * // ROW_NUMBER() OVER (
34
+ * // PARTITION BY article_id
35
+ * // ORDER BY created_at DESC
36
+ * // ) as __$jsonrestapi_rn$__
37
+ * // FROM comments
38
+ * // WHERE article_id IN (1, 2, 3)
39
+ * // )
40
+ * // SELECT * FROM _windowed
41
+ * // WHERE __$jsonrestapi_rn$__ <= 3
42
+ *
43
+ * // Result: Each article gets max 3 comments, not 3 total
44
+ *
45
+ * @example
46
+ * // Input: Database doesn't support window functions
47
+ * const query = buildWindowedIncludeQuery(
48
+ * knex,
49
+ * 'comments',
50
+ * 'article_id',
51
+ * [1, 2, 3],
52
+ * '*',
53
+ * { limit: 5 },
54
+ * { windowFunctions: false, dbInfo: { client: 'mysql', version: '5.7' } }
55
+ * );
56
+ *
57
+ * // Throws RestApiResourceError:
58
+ * // "Include limits require window function support. Your database (mysql 5.7)
59
+ * // does not support this feature. Window functions are supported in:
60
+ * // PostgreSQL 8.4+, MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+, SQL Server 2005+"
61
+ *
62
+ * @description
63
+ * Used by:
64
+ * - loadHasMany when strategy: 'window' and database supports window functions
65
+ * - Enables per-parent limits for one-to-many relationships
66
+ *
67
+ * Purpose:
68
+ * - Solves the "N+1 limit" problem where you want X records per parent
69
+ * - Without window functions, LIMIT 10 gives 10 total across all parents
70
+ * - With window functions, each parent gets up to 10 related records
71
+ * - Critical for consistent API responses with includes
72
+ *
73
+ * Data flow:
74
+ * 1. Creates subquery with ROW_NUMBER() partitioned by foreign key
75
+ * 2. Numbers rows within each partition based on orderBy
76
+ * 3. Outer query filters to keep only rows within limit
77
+ * 4. Returns query ready for execution
78
+ * 5. Caller removes the ROW_NUMBER column from results
79
+ */
80
+ export const buildWindowedIncludeQuery = (
81
+ knex,
82
+ tableName,
83
+ foreignKey,
84
+ parentIds,
85
+ fieldsToSelect,
86
+ includeConfig,
87
+ capabilities,
88
+ scopeVars = {}
89
+ ) => {
90
+ const { orderBy = [] } = includeConfig;
91
+
92
+ // Apply defaults for limit
93
+ const effectiveLimit = includeConfig.limit ?? scopeVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT;
94
+
95
+ // Validate against max
96
+ if (scopeVars.queryMaxLimit && effectiveLimit > scopeVars.queryMaxLimit) {
97
+ throw new RestApiResourceError({
98
+ title: 'Include Limit Exceeds Maximum',
99
+ detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${scopeVars.queryMaxLimit})`,
100
+ status: 400
101
+ });
102
+ }
103
+
104
+ // Check if window functions are supported
105
+ if (!capabilities.windowFunctions) {
106
+ const { dbInfo } = capabilities;
107
+ throw new RestApiResourceError(
108
+ `Include limits require window function support. Your database (${dbInfo.client} ${dbInfo.version}) does not support this feature. ` +
109
+ `Window functions are supported in: PostgreSQL 8.4+, MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+, SQL Server 2005+`,
110
+ {
111
+ subtype: 'unsupported_operation',
112
+ database: dbInfo.client,
113
+ version: dbInfo.version,
114
+ requiredFeature: 'window_functions'
115
+ }
116
+ );
117
+ }
118
+
119
+ // Build the window function query
120
+ // This creates a subquery that partitions by the foreign key and numbers rows
121
+ const subquery = knex(tableName)
122
+ .select('*')
123
+ .select(
124
+ knex.raw(
125
+ 'ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ' +
126
+ buildOrderByClause(orderBy) +
127
+ ') as ' + ROW_NUMBER_KEY,
128
+ [foreignKey]
129
+ )
130
+ )
131
+ .whereIn(foreignKey, parentIds);
132
+
133
+ // Apply field selection if specified
134
+ if (fieldsToSelect !== '*' && Array.isArray(fieldsToSelect)) {
135
+ // Include the foreign key and row number in selection
136
+ const fieldsWithFK = [...new Set([...fieldsToSelect, foreignKey, ROW_NUMBER_KEY])];
137
+ subquery.select(fieldsWithFK);
138
+ }
139
+
140
+ // Wrap in outer query to filter by row number
141
+ const query = knex
142
+ .select('*')
143
+ .from(subquery.as('_windowed'))
144
+ .where(ROW_NUMBER_KEY, '<=', effectiveLimit);
145
+
146
+ return query;
147
+ };
148
+
149
+ /**
150
+ * Builds ORDER BY clause from array of sort fields
151
+ *
152
+ * @param {Array<string>} orderBy - Array of field names, prefix with '-' for DESC
153
+ * @param {string} tablePrefix - Optional table name to prefix fields
154
+ * @returns {string} SQL ORDER BY clause
155
+ *
156
+ * @example
157
+ * // Input: Simple ascending sort
158
+ * const clause = buildOrderByClause(['name', 'created_at']);
159
+ * // Output: "name ASC, created_at ASC"
160
+ *
161
+ * @example
162
+ * // Input: Mixed ascending/descending with '-' prefix
163
+ * const clause = buildOrderByClause(['name', '-created_at', 'status']);
164
+ * // Output: "name ASC, created_at DESC, status ASC"
165
+ *
166
+ * @example
167
+ * // Input: With table prefix for joins
168
+ * const clause = buildOrderByClause(['-updated_at', 'title'], 'articles');
169
+ * // Output: "articles.updated_at DESC, articles.title ASC"
170
+ *
171
+ * @example
172
+ * // Input: Empty array (default to id)
173
+ * const clause = buildOrderByClause([]);
174
+ * // Output: "id ASC"
175
+ *
176
+ * @description
177
+ * Used by:
178
+ * - buildWindowedIncludeQuery for PARTITION BY ordering
179
+ * - applyStandardIncludeConfig for regular ORDER BY
180
+ * - Query builders that need consistent sort syntax
181
+ *
182
+ * Purpose:
183
+ * - Converts API sort syntax (with '-' prefix) to SQL ORDER BY
184
+ * - Handles table prefixing for queries with joins
185
+ * - Provides default ordering by id when none specified
186
+ * - Ensures consistent sort behavior across the API
187
+ *
188
+ * Data flow:
189
+ * 1. Receives array of sort fields from API parameters
190
+ * 2. Detects DESC sorts by '-' prefix
191
+ * 3. Optionally prefixes with table name
192
+ * 4. Joins into SQL-compatible ORDER BY clause
193
+ */
194
+ export const buildOrderByClause = (orderBy, tablePrefix) => {
195
+ if (!orderBy || orderBy.length === 0) {
196
+ const defaultField = tablePrefix ? `${tablePrefix}.id` : 'id';
197
+ return `${defaultField} ASC`; // Default ordering
198
+ }
199
+
200
+ return orderBy.map(field => {
201
+ const isDesc = field.startsWith('-');
202
+ const fieldName = isDesc ? field.substring(1) : field;
203
+ const qualifiedField = tablePrefix ? `${tablePrefix}.${fieldName}` : fieldName;
204
+ return `${qualifiedField} ${isDesc ? 'DESC' : 'ASC'}`;
205
+ }).join(', ');
206
+ };
207
+
208
+ /**
209
+ * Applies standard (non-windowed) include configuration to a query
210
+ *
211
+ * @param {Object} query - Knex query builder instance
212
+ * @param {Object} includeConfig - Include configuration object
213
+ * @param {Object} scopeVars - Scope variables with defaults and limits
214
+ * @param {Object} log - Logger instance
215
+ * @returns {Object} Modified query builder
216
+ *
217
+ * @example
218
+ * // Input: Basic include with limit and ordering
219
+ * let query = knex('comments').whereIn('article_id', [1, 2, 3]);
220
+ * query = applyStandardIncludeConfig(
221
+ * query,
222
+ * { limit: 20, orderBy: ['-created_at', 'id'] },
223
+ * { queryDefaultLimit: 100, queryMaxLimit: 1000 },
224
+ * logger
225
+ * );
226
+ *
227
+ * // Result: Query modified with:
228
+ * // ORDER BY created_at DESC, id ASC
229
+ * // LIMIT 20
230
+ *
231
+ * @example
232
+ * // Input: No explicit limit, uses defaults
233
+ * let query = knex('tags');
234
+ * query = applyStandardIncludeConfig(
235
+ * query,
236
+ * { orderBy: ['name'] }, // No limit specified
237
+ * { queryDefaultLimit: 50, queryMaxLimit: 500 },
238
+ * logger
239
+ * );
240
+ *
241
+ * // Result: Uses default limit
242
+ * // ORDER BY name ASC
243
+ * // LIMIT 50
244
+ * // Log: "Using default limit"
245
+ *
246
+ * @example
247
+ * // Input: Limit exceeds maximum
248
+ * let query = knex('reviews');
249
+ * query = applyStandardIncludeConfig(
250
+ * query,
251
+ * { limit: 5000 }, // Exceeds max
252
+ * { queryMaxLimit: 1000, maxIncludeLimit: 500 },
253
+ * logger
254
+ * );
255
+ *
256
+ * // Result: Clamped to effective maximum
257
+ * // LIMIT 500 (min of maxIncludeLimit and queryMaxLimit)
258
+ *
259
+ * @example
260
+ * // Input: Explicitly disable limit
261
+ * let query = knex('categories');
262
+ * query = applyStandardIncludeConfig(
263
+ * query,
264
+ * { limit: null }, // Explicitly no limit
265
+ * { queryDefaultLimit: 100 },
266
+ * logger
267
+ * );
268
+ *
269
+ * // Result: No LIMIT clause added
270
+ * // Log: "No limit applied to include query (explicitly disabled)"
271
+ *
272
+ * @description
273
+ * Used by:
274
+ * - loadHasMany when window functions not available or not requested
275
+ * - loadReversePolymorphic for standard relationship queries
276
+ * - Any include loader that needs consistent limit/order handling
277
+ *
278
+ * Purpose:
279
+ * - Provides fallback when window functions unavailable
280
+ * - Applies consistent ordering based on API sort syntax
281
+ * - Enforces limit hierarchy: explicit > default > max
282
+ * - Respects both queryMaxLimit and maxIncludeLimit
283
+ * - Logs decisions for debugging
284
+ *
285
+ * Data flow:
286
+ * 1. Applies ORDER BY for each field in orderBy array
287
+ * 2. Determines effective limit from explicit/default/max values
288
+ * 3. Clamps limit to maximum allowed values
289
+ * 4. Adds LIMIT clause unless explicitly disabled
290
+ * 5. Logs the reasoning for the applied limit
291
+ */
292
+ export const applyStandardIncludeConfig = (query, includeConfig, scopeVars, log) => {
293
+ const { orderBy = [] } = includeConfig;
294
+
295
+ // Apply ordering
296
+ orderBy.forEach(field => {
297
+ const desc = field.startsWith('-');
298
+ const column = desc ? field.substring(1) : field;
299
+ query = query.orderBy(column, desc ? 'desc' : 'asc');
300
+ });
301
+
302
+ // Apply limit with defaults
303
+ const requestedLimit = includeConfig.limit;
304
+ const defaultLimit = scopeVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT;
305
+ const limit = requestedLimit ?? defaultLimit;
306
+
307
+ // Allow explicit null/false to mean no limit
308
+ if (limit !== null && limit !== false) {
309
+ const maxInclude = scopeVars.maxIncludeLimit || DEFAULT_MAX_INCLUDE_LIMIT;
310
+ const maxQuery = scopeVars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT;
311
+ const effectiveMax = Math.min(maxInclude, maxQuery);
312
+ const effectiveLimit = Math.min(limit, effectiveMax);
313
+
314
+ query = query.limit(effectiveLimit);
315
+
316
+ log.debug('Applied include limit:', {
317
+ requested: requestedLimit,
318
+ default: defaultLimit,
319
+ effective: effectiveLimit,
320
+ maxAllowed: effectiveMax,
321
+ note: requestedLimit !== undefined ? 'Using explicit limit' : 'Using default limit'
322
+ });
323
+ } else {
324
+ log.debug('No limit applied to include query (explicitly disabled)');
325
+ }
326
+
327
+ return query;
328
+ };
@@ -0,0 +1,328 @@
1
+ import { createSchema } from 'json-rest-schema';
2
+ import { ensureSearchFieldsAreIndexed, generateSearchSchemaFromSchema, sortFieldsByDependencies } from './schema-helpers.js';
3
+
4
+ /**
5
+ * Compiles and enriches schemas for a resource scope
6
+ *
7
+ * @param {Object} scope - Scope containing raw schema options
8
+ * @param {Object} deps - Dependencies with context and hooks
9
+ * @returns {Promise<void>} Resolves when compilation complete
10
+ *
11
+ * @example
12
+ * // Input: Raw schema with computed field
13
+ * const rawSchema = {
14
+ * title: { type: 'string', required: true },
15
+ * content: { type: 'string' },
16
+ * author_id: { belongsTo: 'users', as: 'author' }, // Missing type
17
+ * word_count: {
18
+ * type: 'number',
19
+ * computed: true,
20
+ * compute: (record) => record.content.split(' ').length
21
+ * }
22
+ * };
23
+ *
24
+ * await compileSchemas(scope, deps);
25
+ *
26
+ * // Output in scope.vars.schemaInfo:
27
+ * // {
28
+ * // schema: Schema {}, // json-rest-schema instance
29
+ * // schemaStructure: {
30
+ * // title: { type: 'string', required: true },
31
+ * // content: { type: 'string' },
32
+ * // author_id: { type: 'id', belongsTo: 'users', as: 'author' } // Type added!
33
+ * // // Note: word_count removed (it's computed)
34
+ * // },
35
+ * // computed: {
36
+ * // word_count: { type: 'number', computed: true, compute: [Function] }
37
+ * // }
38
+ * // }
39
+ *
40
+ * @example
41
+ * // Input: Schema with search fields
42
+ * const rawSchema = {
43
+ * title: { type: 'string', search: true }, // Searchable
44
+ * status: { type: 'string' }, // Not searchable
45
+ * author_id: { belongsTo: 'users', as: 'author' }
46
+ * };
47
+ *
48
+ * // Output: Auto-generated searchSchema
49
+ * // searchSchemaObject contains:
50
+ * // {
51
+ * // title: {
52
+ * // type: 'string',
53
+ * // indexed: true, // Added for DB optimization
54
+ * // filterOperator: '=' // Default operator
55
+ * // }
56
+ * // }
57
+ *
58
+ * @example
59
+ * // Input: Pivot table auto-detection
60
+ * const pivotSchema = {
61
+ * article_id: { belongsTo: 'articles', as: 'article' },
62
+ * tag_id: { belongsTo: 'tags', as: 'tag' },
63
+ * display_order: { type: 'number' }
64
+ * };
65
+ *
66
+ * // Output: Both foreign keys become searchable
67
+ * // schemaStructure will have:
68
+ * // {
69
+ * // article_id: { type: 'id', belongsTo: 'articles', as: 'article', search: true },
70
+ * // tag_id: { type: 'id', belongsTo: 'tags', as: 'tag', search: true },
71
+ * // display_order: { type: 'number' }
72
+ * // }
73
+ * // This enables efficient many-to-many filtering!
74
+ *
75
+ * @example
76
+ * // Input: Schema with dependent getters
77
+ * const schema = {
78
+ * first_name: { type: 'string' },
79
+ * last_name: { type: 'string' },
80
+ * full_name: {
81
+ * type: 'string',
82
+ * getter: (record) => `${record.first_name} ${record.last_name}`,
83
+ * runGetterAfter: ['first_name', 'last_name'] // Dependencies
84
+ * },
85
+ * email: {
86
+ * type: 'string',
87
+ * setter: (value) => value.toLowerCase().trim()
88
+ * }
89
+ * };
90
+ *
91
+ * // Output includes dependency-sorted fields:
92
+ * // {
93
+ * // fieldGetters: { full_name: { getter: [Function], runGetterAfter: [...] } },
94
+ * // sortedGetterFields: ['full_name'], // Topologically sorted
95
+ * // fieldSetters: { email: { setter: [Function], runSetterAfter: [] } },
96
+ * // sortedSetterFields: ['email']
97
+ * // }
98
+ *
99
+ * @description
100
+ * Used by:
101
+ * - Every REST method calls this before any operations
102
+ * - Compilation happens once per scope (cached)
103
+ *
104
+ * Purpose:
105
+ * - Separates computed fields from database fields
106
+ * - Adds missing types to belongsTo relationships
107
+ * - Auto-detects pivot tables for many-to-many
108
+ * - Generates search schemas from field markers
109
+ * - Validates getter/setter dependencies
110
+ * - Provides hooks for schema enrichment
111
+ * - Caches everything for performance
112
+ *
113
+ * Data flow:
114
+ * 1. Extracts computed fields to separate object
115
+ * 2. Adds type:'id' to belongsTo fields
116
+ * 3. Detects pivot tables (2+ foreign keys, 40%+ of fields)
117
+ * 4. Runs schema:enrich hook for plugins
118
+ * 5. Creates json-rest-schema validation instance
119
+ * 6. Generates searchSchema from search:true fields
120
+ * 7. Runs searchSchema:enrich hook
121
+ * 8. Extracts and topologically sorts getters/setters
122
+ * 9. Caches all results in scope.vars.schemaInfo
123
+ */
124
+ export async function compileSchemas(scope, deps) {
125
+ // Extract scopeName from context
126
+ const { context, runHooks } = deps;
127
+ const scopeName = context.scopeName;
128
+
129
+ // Get raw schema
130
+ const rawSchema = scope.scopeOptions?.schema || {};
131
+
132
+ // Extract computed fields from schema and build enriched schema
133
+ const computedFields = {};
134
+ const enrichedSchema = {};
135
+
136
+ for (const [fieldName, fieldDef] of Object.entries(rawSchema)) {
137
+ if (fieldDef.computed === true) {
138
+ // Extract computed field - copy entire definition
139
+ computedFields[fieldName] = { ...fieldDef };
140
+
141
+ // Validate computed field
142
+ if (!fieldDef.type) {
143
+ throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' must have a type`);
144
+ }
145
+
146
+ if (fieldDef.compute && typeof fieldDef.compute !== 'function') {
147
+ throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' has invalid compute function`);
148
+ }
149
+
150
+ // Don't include computed fields in the validation schema
151
+ } else {
152
+ enrichedSchema[fieldName] = { ...fieldDef };
153
+ }
154
+ }
155
+
156
+ // Default enrichment: Add type to belongsTo fields
157
+ for (const [fieldName, fieldDef] of Object.entries(enrichedSchema)) {
158
+ if (fieldDef.belongsTo && !fieldDef.type) {
159
+ fieldDef.type = 'id';
160
+ }
161
+ }
162
+
163
+ // ADD LOGIC TO MAKE FIELDS SEARCHABLE HERE
164
+ // Auto-detect pivot tables and make their foreign key fields searchable
165
+ // This is critical for many-to-many relationship operations which need to filter pivot records
166
+ const fields = Object.entries(enrichedSchema);
167
+ const belongsToFields = fields.filter(([_, def]) => def.belongsTo);
168
+
169
+ // If 2+ belongsTo fields and they make up 40%+ of non-id fields, likely a pivot table
170
+ const nonIdFields = fields.filter(([name, _]) => name !== 'id');
171
+ const isProbablyPivot = belongsToFields.length >= 2 &&
172
+ (nonIdFields.length === 0 || belongsToFields.length / nonIdFields.length >= 0.4);
173
+
174
+ if (isProbablyPivot) {
175
+ // Make all belongsTo fields searchable for pivot operations
176
+ for (const [fieldName, fieldDef] of belongsToFields) {
177
+ if (!fieldDef.search) {
178
+ fieldDef.search = true;
179
+ }
180
+ }
181
+ }
182
+
183
+
184
+ // Hook: schema:enrich
185
+ const schemaContext = {
186
+ schema: enrichedSchema, // Mutable
187
+ originalSchema: rawSchema, // Read-only
188
+ scopeName
189
+ };
190
+ await runHooks('schema:enrich', schemaContext);
191
+
192
+ // Create schema object
193
+ const schemaObject = createSchema(schemaContext.schema);
194
+
195
+ // Generate searchSchema by merging explicit searchSchema with fields marked search:true.
196
+ // This allows two ways to define searchable fields: either mark fields with search:true
197
+ // in the main schema, or provide an explicit searchSchema with more control over filtering.
198
+ // The explicit searchSchema takes precedence and can define virtual fields for joins.
199
+ // Example: title: {search: true} auto-generates a searchable field with sensible defaults,
200
+ // while searchSchema can specify filterOperator: 'contains' or complex join configurations.
201
+ let rawSearchSchema = scope.scopeOptions.searchSchema ||
202
+ generateSearchSchemaFromSchema(schemaContext.schema);
203
+
204
+ if (rawSearchSchema) {
205
+ // Mark all search fields as indexed for database optimization.
206
+ // This ensures that any field used for filtering will have a database index created,
207
+ // dramatically improving query performance. Without indexes, filtering large tables
208
+ // would require full table scans. The storage plugin uses these hints to create indexes.
209
+ // Example: A status field marked for search gets indexed: true, enabling efficient
210
+ // WHERE status = 'published' queries that can use index lookups instead of scanning all rows.
211
+ ensureSearchFieldsAreIndexed(rawSearchSchema);
212
+
213
+ // Hook: searchSchema:enrich
214
+ const searchSchemaContext = {
215
+ searchSchema: rawSearchSchema, // Mutable
216
+ schema: schemaContext.schema, // Read-only enriched schema
217
+ scopeName
218
+ };
219
+ await runHooks('searchSchema:enrich', searchSchemaContext);
220
+
221
+ // Create searchSchema object
222
+ var searchSchemaObject = createSchema(searchSchemaContext.searchSchema);
223
+ } else {
224
+ var searchSchemaObject = null;
225
+ }
226
+
227
+ // Build schemaRelationships including polymorphic fields from schema
228
+ const schemaRelationships = { ...(scope.scopeOptions.relationships || {}) };
229
+
230
+ // Validate belongsTo fields
231
+ for (const [fieldName, fieldDef] of Object.entries(schemaContext.schema)) {
232
+ // Validate that belongsTo fields have 'as' property
233
+ if (fieldDef.belongsTo && !fieldDef.as) {
234
+ throw new Error(
235
+ `Field '${fieldName}' in resource '${scopeName}' has belongsTo: '${fieldDef.belongsTo}' but is missing the required 'as' property. ` +
236
+ `The 'as' property defines the relationship name used in JSON:API payloads. ` +
237
+ `Example: ${fieldName}: { type: 'number', belongsTo: '${fieldDef.belongsTo}', as: '${fieldName.replace(/_id$/, '')}' }`
238
+ );
239
+ }
240
+ }
241
+
242
+ // Extract and validate getter definitions
243
+ const fieldGetters = {};
244
+ const getterFields = [];
245
+
246
+ for (const [fieldName, fieldDef] of Object.entries(schemaContext.schema)) {
247
+ if (fieldDef.getter && typeof fieldDef.getter === 'function') {
248
+ fieldGetters[fieldName] = {
249
+ getter: fieldDef.getter,
250
+ runGetterAfter: fieldDef.runGetterAfter || [],
251
+ fieldDef: fieldDef
252
+ };
253
+ getterFields.push(fieldName);
254
+
255
+ // Validate dependencies exist
256
+ if (fieldDef.runGetterAfter && Array.isArray(fieldDef.runGetterAfter)) {
257
+ for (const dep of fieldDef.runGetterAfter) {
258
+ if (!schemaContext.schema[dep]) {
259
+ throw new Error(
260
+ `Field '${fieldName}' in resource '${scopeName}' has getter dependency '${dep}' that does not exist in schema`
261
+ );
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ // Sort getters by dependencies
269
+ let sortedGetterFields = [];
270
+ if (Object.keys(fieldGetters).length > 0) {
271
+ try {
272
+ sortedGetterFields = sortFieldsByDependencies(fieldGetters, 'runGetterAfter');
273
+ } catch (error) {
274
+ throw new Error(`Invalid getter dependencies in ${scopeName}: ${error.message}`);
275
+ }
276
+ }
277
+
278
+ // Extract and validate setter definitions
279
+ const fieldSetters = {};
280
+ const setterFields = [];
281
+
282
+ for (const [fieldName, fieldDef] of Object.entries(schemaContext.schema)) {
283
+ if (fieldDef.setter && typeof fieldDef.setter === 'function') {
284
+ fieldSetters[fieldName] = {
285
+ setter: fieldDef.setter,
286
+ runSetterAfter: fieldDef.runSetterAfter || [],
287
+ fieldDef: fieldDef
288
+ };
289
+ setterFields.push(fieldName);
290
+
291
+ // Validate dependencies exist
292
+ if (fieldDef.runSetterAfter && Array.isArray(fieldDef.runSetterAfter)) {
293
+ for (const dep of fieldDef.runSetterAfter) {
294
+ if (!schemaContext.schema[dep]) {
295
+ throw new Error(
296
+ `Field '${fieldName}' in resource '${scopeName}' has setter dependency '${dep}' that does not exist in schema`
297
+ );
298
+ }
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ // Sort setters by dependencies
305
+ let sortedSetterFields = [];
306
+ if (Object.keys(fieldSetters).length > 0) {
307
+ try {
308
+ sortedSetterFields = sortFieldsByDependencies(fieldSetters, 'runSetterAfter');
309
+ } catch (error) {
310
+ throw new Error(`Invalid setter dependencies in ${scopeName}: ${error.message}`);
311
+ }
312
+ }
313
+
314
+ // Cache everything
315
+ scope.vars.schemaInfo = {
316
+ schema: schemaObject,
317
+ schemaStructure: schemaContext.schema,
318
+ computed: computedFields,
319
+ searchSchema: searchSchemaObject,
320
+ schemaRelationships: schemaRelationships,
321
+ tableName: scope.scopeOptions.tableName || scopeName,
322
+ idProperty: scope.scopeOptions.idProperty || scope.vars.idProperty,
323
+ fieldGetters: fieldGetters,
324
+ sortedGetterFields: sortedGetterFields,
325
+ fieldSetters: fieldSetters,
326
+ sortedSetterFields: sortedSetterFields
327
+ };
328
+ }