json-rest-api 1.0.24 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +605 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
- package/plugins/core/lib/anyapi/schema-utils.js +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +383 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +489 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +1 -0
- package/tests/test-development-guide.md +5 -5
|
@@ -350,37 +350,42 @@ export const toJsonApiRecord = (scope, record, scopeName) => {
|
|
|
350
350
|
export const buildJsonApiResponse = async (scope, records, included = [], isSingle = false, scopeName, context) => {
|
|
351
351
|
const {
|
|
352
352
|
vars: {
|
|
353
|
-
schemaInfo
|
|
353
|
+
schemaInfo
|
|
354
354
|
}
|
|
355
355
|
} = scope
|
|
356
356
|
|
|
357
|
-
const
|
|
357
|
+
const {
|
|
358
|
+
schemaInstance,
|
|
359
|
+
schemaRelationships: relationships
|
|
360
|
+
} = schemaInfo
|
|
358
361
|
|
|
359
362
|
const schemaStructure = getSchemaStructure(schemaInstance)
|
|
360
363
|
|
|
361
364
|
const processedRecords = records.map(record => {
|
|
362
365
|
const { [RELATIONSHIPS_KEY]: _relationships, ...cleanRecord } = record
|
|
366
|
+
const logicalRecord = translateRecordFromStorage(cleanRecord, schemaInfo)
|
|
363
367
|
const jsonApiRecord = toJsonApiRecord(scope, cleanRecord, scopeName)
|
|
368
|
+
const resourceId = jsonApiRecord.id
|
|
364
369
|
|
|
365
370
|
if (_relationships) {
|
|
366
371
|
jsonApiRecord.relationships = _relationships
|
|
367
372
|
}
|
|
368
373
|
|
|
369
374
|
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
370
|
-
if (fieldDef.belongsTo && fieldDef.as && fieldName in
|
|
375
|
+
if (fieldDef.belongsTo && fieldDef.as && fieldName in logicalRecord) {
|
|
371
376
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
372
377
|
if (!jsonApiRecord.relationships[fieldDef.as]) {
|
|
373
|
-
if (
|
|
378
|
+
if (logicalRecord[fieldName] !== null && logicalRecord[fieldName] !== undefined) {
|
|
374
379
|
const relationshipObject = {
|
|
375
380
|
data: {
|
|
376
381
|
type: fieldDef.belongsTo,
|
|
377
|
-
id: String(
|
|
382
|
+
id: String(logicalRecord[fieldName])
|
|
378
383
|
}
|
|
379
384
|
}
|
|
380
385
|
|
|
381
386
|
relationshipObject.links = {
|
|
382
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
383
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
387
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
388
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -390,8 +395,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
390
395
|
}
|
|
391
396
|
|
|
392
397
|
relationshipObject.links = {
|
|
393
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
394
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
398
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
399
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
395
400
|
}
|
|
396
401
|
|
|
397
402
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -402,8 +407,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
402
407
|
|
|
403
408
|
Object.entries(relationships || {}).forEach(([relName, relDef]) => {
|
|
404
409
|
if (relDef.belongsToPolymorphic) {
|
|
405
|
-
const typeValue =
|
|
406
|
-
const idValue =
|
|
410
|
+
const typeValue = logicalRecord[relDef.belongsToPolymorphic.typeField]
|
|
411
|
+
const idValue = logicalRecord[relDef.belongsToPolymorphic.idField]
|
|
407
412
|
|
|
408
413
|
if (typeValue && idValue) {
|
|
409
414
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
@@ -415,8 +420,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
415
420
|
}
|
|
416
421
|
|
|
417
422
|
relationshipObject.links = {
|
|
418
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
419
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
423
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
424
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
420
425
|
}
|
|
421
426
|
|
|
422
427
|
jsonApiRecord.relationships[relName] = relationshipObject
|
|
@@ -427,8 +432,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
427
432
|
}
|
|
428
433
|
|
|
429
434
|
relationshipObject.links = {
|
|
430
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
431
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
435
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
436
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
432
437
|
}
|
|
433
438
|
|
|
434
439
|
jsonApiRecord.relationships[relName] = relationshipObject
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { getFieldValue } from '../storage/storage-mapping.js'
|
|
2
|
+
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
3
|
+
|
|
4
|
+
const buildJsonApiLink = (baseUrl, queryParams, page) => {
|
|
5
|
+
const queryString = serializeJsonApiQuery(queryParams, { page })
|
|
6
|
+
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Calculates pagination metadata from query results
|
|
3
11
|
*
|
|
@@ -127,7 +135,7 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
127
135
|
* Purpose:
|
|
128
136
|
* - JSON:API spec requires pagination links for easy navigation
|
|
129
137
|
* - Preserves all other query parameters (filters, sorts, includes)
|
|
130
|
-
* -
|
|
138
|
+
* - Uses the shared JSON:API query serializer for transport-supported params
|
|
131
139
|
* - Only includes prev/next when applicable
|
|
132
140
|
*
|
|
133
141
|
* Data flow:
|
|
@@ -143,52 +151,20 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
143
151
|
const { page, pageCount, pageSize } = paginationMeta
|
|
144
152
|
const links = {}
|
|
145
153
|
|
|
146
|
-
const otherParams = Object.entries(queryParams)
|
|
147
|
-
.filter(([key]) => key !== 'page')
|
|
148
|
-
.flatMap(([key, value]) => {
|
|
149
|
-
if (Array.isArray(value)) {
|
|
150
|
-
return value.length === 0
|
|
151
|
-
? []
|
|
152
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
153
|
-
}
|
|
154
|
-
if (typeof value === 'object' && value !== null) {
|
|
155
|
-
const parts = []
|
|
156
|
-
const processObject = (obj, prefix) => {
|
|
157
|
-
Object.entries(obj).forEach(([k, v]) => {
|
|
158
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
159
|
-
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
160
|
-
processObject(v, newKey)
|
|
161
|
-
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
162
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
processObject(value, key)
|
|
167
|
-
return parts
|
|
168
|
-
}
|
|
169
|
-
if (value === undefined || value === null) {
|
|
170
|
-
return []
|
|
171
|
-
}
|
|
172
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
173
|
-
})
|
|
174
|
-
.filter(Boolean)
|
|
175
|
-
.join('&')
|
|
176
|
-
|
|
177
154
|
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
178
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
179
155
|
|
|
180
|
-
links.self =
|
|
156
|
+
links.self = buildJsonApiLink(baseUrl, queryParams, { number: page, size: pageSize })
|
|
181
157
|
|
|
182
158
|
if (pageCount !== undefined) {
|
|
183
|
-
links.first =
|
|
184
|
-
links.last =
|
|
159
|
+
links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
|
|
160
|
+
links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
|
|
185
161
|
|
|
186
162
|
if (page > 1) {
|
|
187
|
-
links.prev =
|
|
163
|
+
links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
|
|
188
164
|
}
|
|
189
165
|
|
|
190
166
|
if (page < pageCount) {
|
|
191
|
-
links.next =
|
|
167
|
+
links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
|
|
192
168
|
}
|
|
193
169
|
}
|
|
194
170
|
|
|
@@ -257,11 +233,13 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
257
233
|
* 4. Cursor used in 'next' link for fetching subsequent pages
|
|
258
234
|
* 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
|
|
259
235
|
*/
|
|
260
|
-
export const createCursor = (record, sortFields = ['id']) => {
|
|
236
|
+
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
261
237
|
const parts = []
|
|
262
238
|
sortFields.forEach(field => {
|
|
263
|
-
|
|
264
|
-
|
|
239
|
+
const value = schemaInfo
|
|
240
|
+
? getFieldValue(record, schemaInfo, field)
|
|
241
|
+
: record[field]
|
|
242
|
+
if (value !== undefined) {
|
|
265
243
|
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
266
244
|
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
267
245
|
}
|
|
@@ -427,60 +405,36 @@ export const parseCursor = (cursor) => {
|
|
|
427
405
|
* 4. Added to response.links for navigation
|
|
428
406
|
* 5. Client uses next link to fetch subsequent pages
|
|
429
407
|
*/
|
|
430
|
-
export const generateCursorPaginationLinks = (
|
|
408
|
+
export const generateCursorPaginationLinks = (
|
|
409
|
+
urlPrefix,
|
|
410
|
+
scopeName,
|
|
411
|
+
queryParams,
|
|
412
|
+
records,
|
|
413
|
+
pageSize,
|
|
414
|
+
hasMore,
|
|
415
|
+
sortFields = ['id'],
|
|
416
|
+
{ schemaInfo = null } = {}
|
|
417
|
+
) => {
|
|
431
418
|
// Allow empty urlPrefix to generate relative links
|
|
432
419
|
if (!records.length) return null
|
|
433
420
|
|
|
434
421
|
const links = {}
|
|
435
422
|
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
436
|
-
|
|
437
|
-
const otherParams = Object.entries(queryParams)
|
|
438
|
-
.filter(([key]) => key !== 'page')
|
|
439
|
-
.flatMap(([key, value]) => {
|
|
440
|
-
if (Array.isArray(value)) {
|
|
441
|
-
return value.length === 0
|
|
442
|
-
? []
|
|
443
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
444
|
-
}
|
|
445
|
-
if (typeof value === 'object' && value !== null) {
|
|
446
|
-
const parts = []
|
|
447
|
-
const processObject = (obj, prefix) => {
|
|
448
|
-
Object.entries(obj).forEach(([k, v]) => {
|
|
449
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
450
|
-
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
451
|
-
processObject(v, newKey)
|
|
452
|
-
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
453
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
454
|
-
}
|
|
455
|
-
})
|
|
456
|
-
}
|
|
457
|
-
processObject(value, key)
|
|
458
|
-
return parts
|
|
459
|
-
}
|
|
460
|
-
if (value === undefined || value === null) {
|
|
461
|
-
return []
|
|
462
|
-
}
|
|
463
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
464
|
-
})
|
|
465
|
-
.filter(Boolean)
|
|
466
|
-
.join('&')
|
|
467
|
-
|
|
468
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
423
|
+
const selfPage = { size: pageSize }
|
|
469
424
|
|
|
470
425
|
if (queryParams.page?.after) {
|
|
471
|
-
|
|
426
|
+
selfPage.after = queryParams.page.after
|
|
472
427
|
} else if (queryParams.page?.before) {
|
|
473
|
-
|
|
474
|
-
} else {
|
|
475
|
-
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
|
|
428
|
+
selfPage.before = queryParams.page.before
|
|
476
429
|
}
|
|
477
430
|
|
|
478
|
-
links.
|
|
431
|
+
links.self = buildJsonApiLink(baseUrl, queryParams, selfPage)
|
|
432
|
+
links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
|
|
479
433
|
|
|
480
434
|
if (hasMore && records.length > 0) {
|
|
481
435
|
const lastRecord = records[records.length - 1]
|
|
482
|
-
const nextCursor = createCursor(lastRecord, sortFields)
|
|
483
|
-
links.next =
|
|
436
|
+
const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
|
|
437
|
+
links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
|
|
484
438
|
}
|
|
485
439
|
|
|
486
440
|
return links
|
|
@@ -543,7 +497,7 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
|
|
|
543
497
|
* 3. Added to response.meta.pagination
|
|
544
498
|
* 4. Clients can use cursor directly or use the next link
|
|
545
499
|
*/
|
|
546
|
-
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
|
|
500
|
+
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
547
501
|
const meta = {
|
|
548
502
|
pageSize,
|
|
549
503
|
hasMore
|
|
@@ -552,7 +506,7 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
|
|
|
552
506
|
if (hasMore && records.length > 0) {
|
|
553
507
|
const lastRecord = records[records.length - 1]
|
|
554
508
|
meta.cursor = {
|
|
555
|
-
next: createCursor(lastRecord, sortFields)
|
|
509
|
+
next: createCursor(lastRecord, sortFields, { schemaInfo })
|
|
556
510
|
}
|
|
557
511
|
}
|
|
558
512
|
|
|
@@ -76,7 +76,11 @@
|
|
|
76
76
|
|
|
77
77
|
import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
78
78
|
import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
buildWindowedIncludeSubquery,
|
|
81
|
+
applyStandardIncludeConfig,
|
|
82
|
+
buildOrderByClause
|
|
83
|
+
} from './knex-window-queries.js'
|
|
80
84
|
import { unwrapQueryBuilderState } from './query-builder-utils.js'
|
|
81
85
|
import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
|
|
82
86
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
@@ -149,7 +153,8 @@ const applyScopeFiltersToIncludeQuery = async ({
|
|
|
149
153
|
scopeName,
|
|
150
154
|
requestContext,
|
|
151
155
|
db,
|
|
152
|
-
tableName
|
|
156
|
+
tableName,
|
|
157
|
+
queryPurpose = 'include'
|
|
153
158
|
}) => {
|
|
154
159
|
const scopeObject = scopes[scopeName]
|
|
155
160
|
if (!scopeObject?.applyQueryFilters) {
|
|
@@ -165,6 +170,7 @@ const applyScopeFiltersToIncludeQuery = async ({
|
|
|
165
170
|
tableName: tableName || scopeObject.vars.schemaInfo.tableName,
|
|
166
171
|
db,
|
|
167
172
|
schemaInfo: scopeObject.vars.schemaInfo,
|
|
173
|
+
queryPurpose,
|
|
168
174
|
storageAdapter
|
|
169
175
|
}, buildScopedIncludeContext({
|
|
170
176
|
requestContext,
|
|
@@ -981,46 +987,38 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
981
987
|
|
|
982
988
|
// Check if we should use window functions
|
|
983
989
|
if (relDef.include?.strategy === 'window') {
|
|
984
|
-
|
|
985
|
-
// Try to build window function query
|
|
986
|
-
if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
|
|
987
|
-
throw new Error('Query projection fields require the standard include query path.')
|
|
988
|
-
}
|
|
990
|
+
let windowedQuery
|
|
989
991
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
992
|
+
if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
|
|
993
|
+
log.debug('[INCLUDE] Query projection fields require the standard include query path')
|
|
994
|
+
} else {
|
|
995
|
+
windowedQuery = buildWindowedIncludeSubquery(
|
|
993
996
|
knex,
|
|
994
997
|
targetTable,
|
|
995
998
|
foreignKey,
|
|
996
999
|
mainIds,
|
|
997
|
-
|
|
1000
|
+
fieldSelectionInfo?.fieldsToSelect || null,
|
|
998
1001
|
relDef.include || {},
|
|
999
1002
|
capabilities,
|
|
1000
|
-
targetScopeObject.vars
|
|
1003
|
+
targetScopeObject.vars
|
|
1001
1004
|
)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (windowedQuery) {
|
|
1002
1008
|
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
1003
|
-
query,
|
|
1009
|
+
query: windowedQuery.subquery,
|
|
1004
1010
|
scopes,
|
|
1005
1011
|
scopeName: targetScope,
|
|
1006
1012
|
requestContext,
|
|
1007
1013
|
db: knex,
|
|
1008
1014
|
tableName: targetTable
|
|
1009
1015
|
})
|
|
1010
|
-
query =
|
|
1016
|
+
query = knex
|
|
1017
|
+
.select('*')
|
|
1018
|
+
.from(scopedQueryState.query.as('_windowed'))
|
|
1019
|
+
.where(ROW_NUMBER_KEY, '<=', windowedQuery.effectiveLimit)
|
|
1011
1020
|
usingWindowFunction = true
|
|
1012
1021
|
log.debug('[INCLUDE] Using window function strategy with limits')
|
|
1013
|
-
} catch (error) {
|
|
1014
|
-
// If window functions not supported, this will throw a clear error
|
|
1015
|
-
if (error.details?.requiredFeature === 'window_functions') {
|
|
1016
|
-
throw error // Re-throw the descriptive error
|
|
1017
|
-
}
|
|
1018
|
-
// For other errors, fall back to standard query
|
|
1019
|
-
log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
|
|
1020
|
-
error: error.message,
|
|
1021
|
-
stack: error.stack
|
|
1022
|
-
})
|
|
1023
|
-
usingWindowFunction = false
|
|
1024
1022
|
}
|
|
1025
1023
|
}
|
|
1026
1024
|
|
|
@@ -2114,7 +2112,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2114
2112
|
scopeName: relDef.target,
|
|
2115
2113
|
requestContext,
|
|
2116
2114
|
db: knex,
|
|
2117
|
-
tableName: targetTable
|
|
2115
|
+
tableName: targetTable,
|
|
2116
|
+
queryPurpose: 'relationship-identifiers'
|
|
2118
2117
|
})
|
|
2119
2118
|
query = scopedQueryState.query
|
|
2120
2119
|
|
|
@@ -2159,7 +2158,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2159
2158
|
scopeName: targetScopeName,
|
|
2160
2159
|
requestContext,
|
|
2161
2160
|
db: knex,
|
|
2162
|
-
tableName: targetTable
|
|
2161
|
+
tableName: targetTable,
|
|
2162
|
+
queryPurpose: 'relationship-identifiers'
|
|
2163
2163
|
})
|
|
2164
2164
|
query = scopedQueryState.query
|
|
2165
2165
|
|
|
@@ -2198,7 +2198,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2198
2198
|
scopeName: targetScope,
|
|
2199
2199
|
requestContext,
|
|
2200
2200
|
db: knex,
|
|
2201
|
-
tableName: targetTable
|
|
2201
|
+
tableName: targetTable,
|
|
2202
|
+
queryPurpose: 'relationship-identifiers'
|
|
2202
2203
|
})
|
|
2203
2204
|
query = scopedQueryState.query
|
|
2204
2205
|
|
|
@@ -2,82 +2,21 @@ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
|
2
2
|
import { ROW_NUMBER_KEY, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_MAX_INCLUDE_LIMIT } from '../querying-writing/knex-constants.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Builds
|
|
5
|
+
* Builds the inner window-function query used to limit included children per
|
|
6
|
+
* parent. Mandatory target filters must be applied to this inner query before
|
|
7
|
+
* the caller wraps it and filters by ROW_NUMBER.
|
|
6
8
|
*
|
|
7
9
|
* @param {Object} knex - Knex instance
|
|
8
10
|
* @param {string} tableName - Target table name
|
|
9
11
|
* @param {string} foreignKey - Foreign key field
|
|
10
12
|
* @param {Array} parentIds - Parent record IDs
|
|
11
|
-
* @param {Array} fieldsToSelect - Fields to select
|
|
12
|
-
* @param {Object} includeConfig - Include
|
|
13
|
+
* @param {Array|string} fieldsToSelect - Fields to select, or '*'
|
|
14
|
+
* @param {Object} includeConfig - Include limit and ordering
|
|
13
15
|
* @param {Object} capabilities - Database capabilities
|
|
14
|
-
* @param {Object} scopeVars -
|
|
15
|
-
* @returns {Object}
|
|
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
|
|
16
|
+
* @param {Object} scopeVars - Resource query limits
|
|
17
|
+
* @returns {{ subquery: Object, effectiveLimit: number }} Inner query and validated limit
|
|
79
18
|
*/
|
|
80
|
-
export const
|
|
19
|
+
export const buildWindowedIncludeSubquery = (
|
|
81
20
|
knex,
|
|
82
21
|
tableName,
|
|
83
22
|
foreignKey,
|
|
@@ -137,13 +76,7 @@ export const buildWindowedIncludeQuery = (
|
|
|
137
76
|
subquery.select(fieldsWithFK)
|
|
138
77
|
}
|
|
139
78
|
|
|
140
|
-
|
|
141
|
-
const query = knex
|
|
142
|
-
.select('*')
|
|
143
|
-
.from(subquery.as('_windowed'))
|
|
144
|
-
.where(ROW_NUMBER_KEY, '<=', effectiveLimit)
|
|
145
|
-
|
|
146
|
-
return query
|
|
79
|
+
return { subquery, effectiveLimit }
|
|
147
80
|
}
|
|
148
81
|
|
|
149
82
|
/**
|
|
@@ -175,7 +108,7 @@ export const buildWindowedIncludeQuery = (
|
|
|
175
108
|
*
|
|
176
109
|
* @description
|
|
177
110
|
* Used by:
|
|
178
|
-
* -
|
|
111
|
+
* - buildWindowedIncludeSubquery for PARTITION BY ordering
|
|
179
112
|
* - applyStandardIncludeConfig for regular ORDER BY
|
|
180
113
|
* - Query builders that need consistent sort syntax
|
|
181
114
|
*
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
|
|
2
2
|
const context = hookParams?.context || {}
|
|
3
|
+
const activeQuery = context.knexQuery || {}
|
|
4
|
+
const activeScopeName = activeQuery.scopeName
|
|
5
|
+
const activeTableName = activeQuery.tableName
|
|
6
|
+
const activeStorageAdapter = activeQuery.storageAdapter || context.storageAdapter
|
|
3
7
|
const storageCache = new Map()
|
|
4
8
|
|
|
5
9
|
const fetchStorageAdapter = (scopeName) => {
|
|
@@ -7,13 +11,9 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
7
11
|
if (storageCache.has(scopeName)) return storageCache.get(scopeName)
|
|
8
12
|
|
|
9
13
|
let adapter = null
|
|
10
|
-
if (scopeName ===
|
|
11
|
-
adapter =
|
|
12
|
-
context.knexQuery?.storageAdapter ||
|
|
14
|
+
if (scopeName === activeScopeName) {
|
|
15
|
+
adapter = activeStorageAdapter ||
|
|
13
16
|
(typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null)
|
|
14
|
-
if (adapter && !context.storageAdapter) {
|
|
15
|
-
context.storageAdapter = adapter
|
|
16
|
-
}
|
|
17
17
|
} else {
|
|
18
18
|
adapter = typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null
|
|
19
19
|
}
|
|
@@ -23,8 +23,8 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const defaultAliasForScope = (scopeName) => {
|
|
26
|
-
if (scopeName ===
|
|
27
|
-
return
|
|
26
|
+
if (scopeName === activeScopeName) {
|
|
27
|
+
return activeTableName || scopeName
|
|
28
28
|
}
|
|
29
29
|
return scopeName
|
|
30
30
|
}
|
|
@@ -11,13 +11,12 @@
|
|
|
11
11
|
* Priority order:
|
|
12
12
|
* 1. context.urlPrefixOverride - Explicit override (from hooks or middleware)
|
|
13
13
|
* 2. context.urlPrefix - Pre-calculated URL prefix
|
|
14
|
-
* 3.
|
|
15
|
-
* 4. scope.vars.transport?.mountPath - Fallback to mount path
|
|
14
|
+
* 3. scope.vars.transport?.publicBaseUrl - Explicit public URL prefix
|
|
15
|
+
* 4. scope.vars.transport?.mountPath - Fallback to relative mount path
|
|
16
16
|
* 5. '' - Empty string as final fallback
|
|
17
17
|
*
|
|
18
18
|
* @param {Object} context - Request context (may have urlPrefixOverride or urlPrefix)
|
|
19
19
|
* @param {Object} scope - Scope/resource object with transport vars
|
|
20
|
-
* @param {Object} req - Optional Express request object for auto-calculation
|
|
21
20
|
* @returns {string} The final URL prefix to use
|
|
22
21
|
*
|
|
23
22
|
* @example
|
|
@@ -25,10 +24,10 @@
|
|
|
25
24
|
* const urlPrefix = getUrlPrefix(context, scope);
|
|
26
25
|
*
|
|
27
26
|
* @example
|
|
28
|
-
* // With
|
|
29
|
-
* const urlPrefix = getUrlPrefix({}, api
|
|
27
|
+
* // With explicit public base URL
|
|
28
|
+
* const urlPrefix = getUrlPrefix({}, { vars: { transport: { publicBaseUrl: 'https://api.example.com/api' } } });
|
|
30
29
|
*/
|
|
31
|
-
export function getUrlPrefix (context, scope
|
|
30
|
+
export function getUrlPrefix (context, scope) {
|
|
32
31
|
// Priority 1: Explicit override
|
|
33
32
|
if (context?.urlPrefixOverride) {
|
|
34
33
|
return context.urlPrefixOverride
|
|
@@ -39,21 +38,10 @@ export function getUrlPrefix (context, scope, req = null) {
|
|
|
39
38
|
return context.urlPrefix
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
// Priority 3:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
req.protocol ||
|
|
47
|
-
'http'
|
|
48
|
-
const host = req.get?.('x-forwarded-host') ||
|
|
49
|
-
req.headers?.['x-forwarded-host'] ||
|
|
50
|
-
req.get?.('host') ||
|
|
51
|
-
req.headers?.host
|
|
52
|
-
const mountPath = scope?.vars?.transport?.mountPath || ''
|
|
53
|
-
|
|
54
|
-
if (host) {
|
|
55
|
-
return `${protocol}://${host}${mountPath}`
|
|
56
|
-
}
|
|
41
|
+
// Priority 3: Explicit public URL prefix configured by the transport
|
|
42
|
+
const publicBaseUrl = scope?.vars?.transport?.publicBaseUrl
|
|
43
|
+
if (publicBaseUrl) {
|
|
44
|
+
return String(publicBaseUrl).replace(/\/+$/, '')
|
|
57
45
|
}
|
|
58
46
|
|
|
59
47
|
// Priority 4: Fallback to mount path
|
|
@@ -163,3 +163,54 @@ export function parseJsonApiQuery (queryString) {
|
|
|
163
163
|
|
|
164
164
|
return result
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
const hasSerializableValue = (value) => (
|
|
168
|
+
value !== undefined &&
|
|
169
|
+
value !== null &&
|
|
170
|
+
!(Array.isArray(value) && value.length === 0)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const stringifyQueryValue = (value) => {
|
|
174
|
+
if (value instanceof Date) return value.toISOString()
|
|
175
|
+
if (Array.isArray(value)) return value.map(stringifyQueryValue).join(',')
|
|
176
|
+
return String(value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const appendCommaList = (params, key, value) => {
|
|
180
|
+
if (!hasSerializableValue(value)) return
|
|
181
|
+
const serialized = Array.isArray(value)
|
|
182
|
+
? value.map(stringifyQueryValue).filter((entry) => entry.length > 0).join(',')
|
|
183
|
+
: stringifyQueryValue(value)
|
|
184
|
+
|
|
185
|
+
if (serialized) {
|
|
186
|
+
params.set(key, serialized)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const appendScopedParams = (params, publicName, values) => {
|
|
191
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return
|
|
192
|
+
|
|
193
|
+
for (const [key, value] of Object.entries(values)) {
|
|
194
|
+
if (!key || !hasSerializableValue(value)) continue
|
|
195
|
+
params.set(`${publicName}[${key}]`, stringifyQueryValue(value))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Serializes internal REST API query parameters to public JSON:API query string form.
|
|
201
|
+
*
|
|
202
|
+
* This is intentionally the inverse of parseJsonApiQuery for the supported transport
|
|
203
|
+
* contract: include, sort, filter[...], fields[...], and page[...].
|
|
204
|
+
*/
|
|
205
|
+
export function serializeJsonApiQuery (queryParams = {}, { page } = {}) {
|
|
206
|
+
const params = new URLSearchParams()
|
|
207
|
+
const effectivePage = page === undefined ? queryParams.page : page
|
|
208
|
+
|
|
209
|
+
appendCommaList(params, 'include', queryParams.include)
|
|
210
|
+
appendCommaList(params, 'sort', queryParams.sort)
|
|
211
|
+
appendScopedParams(params, 'filter', queryParams.filters || queryParams.filter)
|
|
212
|
+
appendScopedParams(params, 'fields', queryParams.fields)
|
|
213
|
+
appendScopedParams(params, 'page', effectivePage)
|
|
214
|
+
|
|
215
|
+
return params.toString()
|
|
216
|
+
}
|