json-rest-api 1.0.28 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +746 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +634 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +100 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
- package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +36 -0
- package/index.js +1 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +64 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -174
- package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +25 -16
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +20 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +142 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1069
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -385
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +38 -69
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +39 -82
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +53 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -150
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -118
- package/tests/fixtures/api-configs.js +0 -2509
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-cursor-multifield.test.js +0 -643
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,43 +1,208 @@
|
|
|
1
|
+
import { assertScalarQueryField } from '../querying-writing/field-utils.js'
|
|
1
2
|
import { normalizeStableSort, parseSortEntry } from './sort-helpers.js'
|
|
3
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
4
|
+
import { createSchemaFactory } from 'json-rest-schema'
|
|
5
|
+
import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
|
|
6
|
+
|
|
7
|
+
const cursorContractCache = new WeakMap()
|
|
8
|
+
|
|
9
|
+
function getCursorContract (schema, definition) {
|
|
10
|
+
let cache = cursorContractCache.get(schema)
|
|
11
|
+
if (!cache) {
|
|
12
|
+
cache = {
|
|
13
|
+
factory: createSchemaFactory({ types: schema.types, validators: schema.validators, installCore: false }),
|
|
14
|
+
contracts: new Map()
|
|
15
|
+
}
|
|
16
|
+
cursorContractCache.set(schema, cache)
|
|
17
|
+
}
|
|
18
|
+
// Unusual/invalid declarations retain schema validation without lossy JSON keys.
|
|
19
|
+
if (typeof definition.type !== 'string' ||
|
|
20
|
+
(definition.temporalPrecision !== undefined && !Number.isInteger(definition.temporalPrecision))) {
|
|
21
|
+
return cache.factory({ value: definition })
|
|
22
|
+
}
|
|
23
|
+
const key = JSON.stringify(definition)
|
|
24
|
+
if (!cache.contracts.has(key)) cache.contracts.set(key, cache.factory({ value: definition }))
|
|
25
|
+
return cache.contracts.get(key)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const validateCursorValues = (descriptors, cursorValues, parameter, schema) => {
|
|
29
|
+
const validated = Object.create(null)
|
|
30
|
+
for (const { field, definition = {}, isRelationship, queryFieldRuntime } of descriptors) {
|
|
31
|
+
if (!Object.hasOwn(cursorValues, field)) continue
|
|
32
|
+
const value = cursorValues[field]
|
|
33
|
+
// Public resource IDs may be opaque strings, even when the storage schema
|
|
34
|
+
// uses the numeric id type for ordinary attributes.
|
|
35
|
+
const type = field === 'id' || isRelationship || definition?.type === 'id'
|
|
36
|
+
? 'string'
|
|
37
|
+
: definition?.type
|
|
38
|
+
if (!type) {
|
|
39
|
+
validated[field] = value
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
const contract = getCursorContract(schema, {
|
|
43
|
+
type,
|
|
44
|
+
noTrim: true,
|
|
45
|
+
nullable: field !== 'id',
|
|
46
|
+
...(definition?.temporalPrecision !== undefined
|
|
47
|
+
? { temporalPrecision: definition.temporalPrecision }
|
|
48
|
+
: {})
|
|
49
|
+
})
|
|
50
|
+
let validationValue = value
|
|
51
|
+
let invalidStorageValue = false
|
|
52
|
+
const storedTemporal = Boolean(queryFieldRuntime || definition.storage?.serialize) && ['date', 'dateTime', 'time'].includes(type)
|
|
53
|
+
if (storedTemporal && value !== null) {
|
|
54
|
+
try {
|
|
55
|
+
validationValue = normalizeDateValue(value, type, { temporalPrecision: definition.temporalPrecision, fieldName: field, source: 'cursor' })
|
|
56
|
+
} catch {
|
|
57
|
+
invalidStorageValue = true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const { validatedObject, errors } = contract.patch({ value: validationValue })
|
|
61
|
+
if (invalidStorageValue || Object.keys(errors).length > 0 || (field === 'id' && !String(value ?? '').trim())) {
|
|
62
|
+
const path = `page.${parameter}`
|
|
63
|
+
throw new RestApiValidationError(`Invalid cursor value for sort field '${field}'.`, {
|
|
64
|
+
fields: [path],
|
|
65
|
+
violations: [{ field: path, rule: 'invalid_cursor_value', message: `Invalid ${type} value for '${field}'.` }]
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
// Preserve temporal SQL spelling, but bind epochs and scalar numbers as
|
|
69
|
+
// numbers: SQLite expressions have no column affinity to coerce strings.
|
|
70
|
+
validated[field] = storedTemporal
|
|
71
|
+
? (typeof value === 'string' && /^-?\d+$/.test(value) ? Number(value) : value)
|
|
72
|
+
: validatedObject.value
|
|
73
|
+
}
|
|
74
|
+
return validated
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const resolveSortField = (field, schemaInfo = {}) => {
|
|
78
|
+
if (field === 'id' || Object.hasOwn(schemaInfo.schemaStructure || {}, field) || Object.hasOwn(schemaInfo.queryFields || {}, field)) return field
|
|
79
|
+
return schemaInfo.searchSchemaStructure?.[field]?.actualField || field
|
|
80
|
+
}
|
|
2
81
|
|
|
3
82
|
export const getEffectiveSortableFields = (vars = {}) => {
|
|
4
|
-
const
|
|
83
|
+
const schema = vars.schemaInfo?.schemaStructure || {}
|
|
84
|
+
const baseSortableFields = Array.isArray(vars.sortableFields) && vars.sortableFields.length > 0
|
|
5
85
|
? vars.sortableFields
|
|
6
|
-
: [
|
|
7
|
-
|
|
86
|
+
: ['id', ...Object.keys(schema).filter((field) => {
|
|
87
|
+
const definition = schema[field]
|
|
88
|
+
return !definition.hidden && !definition.virtual && !definition.computed && !definition.belongsToPolymorphic
|
|
89
|
+
}), ...Object.entries(vars.schemaInfo?.searchSchemaStructure || {})
|
|
90
|
+
.filter(([, definition]) => definition.isRelationship && schema[definition.actualField]?.hidden !== true)
|
|
91
|
+
.map(([field]) => field)]
|
|
92
|
+
const queryFieldSortableFields = Object.entries(vars.schemaInfo?.queryFields || {})
|
|
8
93
|
.filter(([, fieldDef]) => fieldDef?.sortable === true)
|
|
9
94
|
.map(([fieldName]) => fieldName)
|
|
10
95
|
|
|
11
|
-
return Array.from(new Set([...baseSortableFields, ...queryFieldSortableFields]))
|
|
96
|
+
return Array.from(new Set([...baseSortableFields, ...queryFieldSortableFields])).filter(field => {
|
|
97
|
+
const actualField = resolveSortField(field, vars.schemaInfo)
|
|
98
|
+
const queryFields = vars.schemaInfo?.queryFields || {}
|
|
99
|
+
const definition = Object.hasOwn(queryFields, actualField) ? queryFields[actualField] : schema[actualField]
|
|
100
|
+
return definition?.hidden !== true && !['object', 'array'].includes(definition?.type)
|
|
101
|
+
})
|
|
12
102
|
}
|
|
13
103
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
sortList = [`${direction}${field}`]
|
|
26
|
-
}
|
|
104
|
+
/**
|
|
105
|
+
* @param {unknown} sort
|
|
106
|
+
* @param {{ defaultSort?: unknown, idField?: string, schemaInfo?: Partial<import('../storage/storage-types.js').DataOperationSchema> }} [options]
|
|
107
|
+
* @returns {string[]}
|
|
108
|
+
*/
|
|
109
|
+
export const buildEffectiveSortList = (sort, { defaultSort, idField = 'id', schemaInfo } = {}) => {
|
|
110
|
+
const toList = (value, option) => {
|
|
111
|
+
if (value === undefined || value === null) return []
|
|
112
|
+
const list = Array.isArray(value) ? [...value] : [value]
|
|
113
|
+
if (list.some(entry => typeof entry !== 'string')) throw new Error(`${option} must be a string or an array of strings, such as ['-name', 'id'].`)
|
|
114
|
+
return list
|
|
27
115
|
}
|
|
116
|
+
let sortList = toList(sort, 'sort')
|
|
117
|
+
if (sortList.length === 0) sortList = toList(defaultSort, 'defaultSort')
|
|
28
118
|
|
|
29
|
-
|
|
119
|
+
const normalized = normalizeStableSort(sortList.length > 0 ? sortList : [idField], { idField })
|
|
120
|
+
for (const entry of normalized) {
|
|
121
|
+
const { field } = parseSortEntry(entry)
|
|
122
|
+
const actualField = resolveSortField(field, schemaInfo)
|
|
123
|
+
const queryFields = schemaInfo?.queryFields || {}
|
|
124
|
+
const definition = Object.hasOwn(queryFields, actualField) ? queryFields[actualField] : schemaInfo?.schemaStructure?.[actualField]
|
|
125
|
+
if (definition?.hidden === true) {
|
|
126
|
+
throw new RestApiValidationError(`Hidden field '${field}' cannot be used for sorting.`, {
|
|
127
|
+
fields: [field],
|
|
128
|
+
violations: [{ field, rule: 'hidden_sort', message: 'Sort fields must be publicly readable because their values may appear in cursors.' }]
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
assertScalarQueryField(definition, field, 'sort')
|
|
132
|
+
}
|
|
133
|
+
return normalized
|
|
30
134
|
}
|
|
31
135
|
|
|
32
|
-
export const applyQueryFieldOrder = (query, queryFieldRuntime, direction) => {
|
|
136
|
+
export const applyQueryFieldOrder = (query, queryFieldRuntime, direction, nulls = 'last') => {
|
|
137
|
+
if (['pg', 'postgresql'].includes(query.client.config.client)) {
|
|
138
|
+
// The selected alias also avoids different bound parameter numbers under DISTINCT.
|
|
139
|
+
query.orderBy(queryFieldRuntime.fieldName, direction, nulls)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (['better-sqlite3', 'sqlite3'].includes(query.client.config.client)) {
|
|
143
|
+
const order = String(direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC'
|
|
144
|
+
query.orderByRaw(`(${queryFieldRuntime.sql}) ${order} NULLS ${nulls === 'last' ? 'LAST' : 'FIRST'}`, queryFieldRuntime.bindings)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
query.orderByRaw(`(${queryFieldRuntime.sql}) IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, queryFieldRuntime.bindings)
|
|
33
148
|
query.orderByRaw(`(${queryFieldRuntime.sql}) ${direction}`, queryFieldRuntime.bindings)
|
|
34
149
|
}
|
|
35
150
|
|
|
151
|
+
export const applyColumnOrder = (query, column, direction, nulls = 'last') => {
|
|
152
|
+
if (['pg', 'postgresql'].includes(query.client.config.client)) {
|
|
153
|
+
query.orderBy(column, direction, nulls)
|
|
154
|
+
} else if (['better-sqlite3', 'sqlite3'].includes(query.client.config.client)) {
|
|
155
|
+
// Native null placement lets SQLite use the column's ordering index.
|
|
156
|
+
const order = String(direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC'
|
|
157
|
+
query.orderByRaw(`?? ${order} NULLS ${nulls === 'last' ? 'LAST' : 'FIRST'}`, [column])
|
|
158
|
+
} else {
|
|
159
|
+
query.orderByRaw(`?? IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, [column])
|
|
160
|
+
query.orderBy(column, direction)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Both storage modes use the same cursor direction and null placement.
|
|
165
|
+
/**
|
|
166
|
+
* @param {import('../storage/storage-types.js').StorageQuery} query
|
|
167
|
+
* @param {{ column?: string, queryFieldRuntime?: import('../querying-writing/query-field-types.js').ProjectionRuntime, direction: string }} descriptor
|
|
168
|
+
* @param {{ before?: boolean }} [options]
|
|
169
|
+
*/
|
|
170
|
+
export const applySortDescriptorOrder = (query, { column, queryFieldRuntime, direction }, { before = false } = {}) => {
|
|
171
|
+
const ascending = String(direction).toLowerCase() === 'asc'
|
|
172
|
+
const queryDirection = (before ? !ascending : ascending) ? 'asc' : 'desc'
|
|
173
|
+
const nulls = before ? 'first' : 'last'
|
|
174
|
+
if (queryFieldRuntime) {
|
|
175
|
+
applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), nulls)
|
|
176
|
+
} else {
|
|
177
|
+
applyColumnOrder(query, column, queryDirection, nulls)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
36
181
|
export const applyQueryFieldPredicate = (builder, queryFieldRuntime, operator, value) => {
|
|
37
182
|
builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, value])
|
|
38
183
|
}
|
|
39
184
|
|
|
40
|
-
export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate) => {
|
|
185
|
+
export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate, includeNull = false) => {
|
|
186
|
+
if (includeNull) {
|
|
187
|
+
return builder.where(function () {
|
|
188
|
+
applySortDescriptorPredicate(this, descriptor, operator, value, applyPlainPredicate)
|
|
189
|
+
if (descriptor.queryFieldRuntime) {
|
|
190
|
+
this.orWhereRaw(`(${descriptor.queryFieldRuntime.sql}) IS NULL`, descriptor.queryFieldRuntime.bindings)
|
|
191
|
+
} else {
|
|
192
|
+
this.orWhereNull(descriptor.column)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
if (value === null) {
|
|
197
|
+
const isNotNull = operator === 'IS NOT NULL'
|
|
198
|
+
if (descriptor.queryFieldRuntime) {
|
|
199
|
+
return builder.whereRaw(`(${descriptor.queryFieldRuntime.sql}) IS ${isNotNull ? 'NOT ' : ''}NULL`, descriptor.queryFieldRuntime.bindings)
|
|
200
|
+
}
|
|
201
|
+
return isNotNull ? builder.whereNotNull(descriptor.column) : builder.whereNull(descriptor.column)
|
|
202
|
+
}
|
|
203
|
+
if (descriptor.definition?.storage?.serialize) {
|
|
204
|
+
return builder.where(descriptor.column, operator, value)
|
|
205
|
+
}
|
|
41
206
|
if (descriptor?.queryFieldRuntime) {
|
|
42
207
|
return applyQueryFieldPredicate(builder, descriptor.queryFieldRuntime, operator, value)
|
|
43
208
|
}
|
|
@@ -62,19 +227,27 @@ export const buildCursorPredicateChains = (
|
|
|
62
227
|
|
|
63
228
|
if (cursorValue === undefined) {
|
|
64
229
|
onMissingValue?.(descriptor.field)
|
|
65
|
-
|
|
230
|
+
throw new RestApiValidationError(`Cursor is missing a value for sort field '${descriptor.field}'.`, {
|
|
231
|
+
fields: [descriptor.field],
|
|
232
|
+
violations: [{
|
|
233
|
+
field: descriptor.field,
|
|
234
|
+
rule: 'missing_cursor_value',
|
|
235
|
+
message: 'Cursor must include every sort field.'
|
|
236
|
+
}]
|
|
237
|
+
})
|
|
66
238
|
}
|
|
67
239
|
|
|
240
|
+
const operator = operatorSelector(descriptor.direction)
|
|
241
|
+
const forward = operator === (descriptor.direction.toUpperCase() === 'DESC' ? '<' : '>')
|
|
242
|
+
// Nulls sort last. There are no values after null at this sort position,
|
|
243
|
+
// but later tie breakers can still advance within the null group.
|
|
244
|
+
if (cursorValue === null && forward) return
|
|
245
|
+
|
|
68
246
|
const chain = []
|
|
69
247
|
for (let i = 0; i < index; i += 1) {
|
|
70
248
|
const previousDescriptor = descriptors[i]
|
|
71
249
|
const previousValue = cursorValues?.[previousDescriptor.field]
|
|
72
250
|
|
|
73
|
-
if (previousValue === undefined) {
|
|
74
|
-
onMissingValue?.(previousDescriptor.field)
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
251
|
chain.push({
|
|
79
252
|
descriptor: previousDescriptor,
|
|
80
253
|
operator: '=',
|
|
@@ -84,8 +257,9 @@ export const buildCursorPredicateChains = (
|
|
|
84
257
|
|
|
85
258
|
chain.push({
|
|
86
259
|
descriptor,
|
|
87
|
-
operator:
|
|
88
|
-
value: cursorValue
|
|
260
|
+
operator: cursorValue === null ? 'IS NOT NULL' : operator,
|
|
261
|
+
value: cursorValue,
|
|
262
|
+
includeNull: cursorValue !== null && forward
|
|
89
263
|
})
|
|
90
264
|
|
|
91
265
|
predicateChains.push(chain)
|
|
@@ -116,13 +290,14 @@ export const applyCursorPredicate = (
|
|
|
116
290
|
query.where(function () {
|
|
117
291
|
predicateChains.forEach((chain) => {
|
|
118
292
|
this.orWhere(function () {
|
|
119
|
-
chain.forEach(({ descriptor, operator, value }) => {
|
|
293
|
+
chain.forEach(({ descriptor, operator, value, includeNull }) => {
|
|
120
294
|
applySortDescriptorPredicate(
|
|
121
295
|
this,
|
|
122
296
|
descriptor,
|
|
123
297
|
operator,
|
|
124
298
|
value,
|
|
125
|
-
applyPlainPredicate
|
|
299
|
+
applyPlainPredicate,
|
|
300
|
+
includeNull
|
|
126
301
|
)
|
|
127
302
|
})
|
|
128
303
|
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { whereInIdentifiers } from './identifier-query.js'
|
|
2
|
+
import { RELATIONSHIPS_KEY } from '../querying-writing/knex-constants.js'
|
|
3
|
+
import { databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
4
|
+
import { getResourceFieldset, parseFieldset } from '../querying-writing/field-utils.js'
|
|
5
|
+
import {
|
|
6
|
+
getIdColumnForScope,
|
|
7
|
+
getFieldValueForScope,
|
|
8
|
+
translateColumnForScope,
|
|
9
|
+
applyScopeFiltersToIncludeQuery,
|
|
10
|
+
parseIncludeTree,
|
|
11
|
+
} from './include-query-helpers.js'
|
|
12
|
+
/**
|
|
13
|
+
* Loads relationship identifiers for all hasMany relationships without fetching full related records.
|
|
14
|
+
*
|
|
15
|
+
* ## Purpose
|
|
16
|
+
* This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
|
|
17
|
+
* always return resource identifiers in the JSON:API response, even when the related resources
|
|
18
|
+
* are not included via the ?include parameter.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this is needed
|
|
21
|
+
* 1. **JSON:API Consistency**: The JSON:API spec allows servers to include relationship identifiers
|
|
22
|
+
* without including the full related resources. This provides a consistent API surface where
|
|
23
|
+
* clients always know what relationships exist and their IDs.
|
|
24
|
+
*
|
|
25
|
+
* 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
|
|
26
|
+
* minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
|
|
27
|
+
* these objects only appear when using ?include, creating an inconsistent API where fields appear/disappear.
|
|
28
|
+
*
|
|
29
|
+
* 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
|
|
30
|
+
* clients the ability to know what relationships exist without the cost of fetching all data.
|
|
31
|
+
*
|
|
32
|
+
* ## What it does
|
|
33
|
+
* - Runs ONE query per relationship type (not per record) to fetch all related IDs
|
|
34
|
+
* - Populates the relationship data with resource identifiers: `{ type: 'resource', id: '123' }`
|
|
35
|
+
* - Handles all relationship types: one-to-many, many-to-many, and polymorphic
|
|
36
|
+
* - Works for both JSON:API and simplified modes (transformation happens elsewhere)
|
|
37
|
+
*
|
|
38
|
+
* ## When it runs
|
|
39
|
+
* This runs after the main records are fetched but before includes are processed.
|
|
40
|
+
* If includes ARE specified, they will overwrite these IDs with full data.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array<Object>} records - The parent records to load relationships for
|
|
43
|
+
* @param {string} scopeName - The parent scope name (e.g., 'authors')
|
|
44
|
+
* @param {Object} scopes - All available scopes with their schemas
|
|
45
|
+
* @param {Object} knex - Knex instance for database queries
|
|
46
|
+
* @param {import('../storage/storage-types.js').DataReadContext | null} [requestContext]
|
|
47
|
+
* @returns {Promise<void>} Modifies records in place by adding relationship data
|
|
48
|
+
*/
|
|
49
|
+
export const loadRelationshipIdentifiers = async (records, scopeName, scopes, knex, requestContext = null) => {
|
|
50
|
+
if (!records.length) return
|
|
51
|
+
|
|
52
|
+
const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
53
|
+
if (!schemaInfo) throw new Error(`Missing relationship schema for scope '${scopeName}'`)
|
|
54
|
+
|
|
55
|
+
const relationships = schemaInfo.schemaRelationships || {}
|
|
56
|
+
const recordIds = records.map(record => String(getFieldValueForScope(scopes, scopeName, record, 'id')))
|
|
57
|
+
const requestedFields = parseFieldset(getResourceFieldset(requestContext?.queryParams?.fields, scopeName))
|
|
58
|
+
const includeTree = parseIncludeTree(requestContext?.queryParams?.include)
|
|
59
|
+
|
|
60
|
+
// Process each hasMany relationship
|
|
61
|
+
for (const [relName, relDef] of Object.entries(relationships)) {
|
|
62
|
+
if (requestedFields !== null && !requestedFields.includes(relName)) continue
|
|
63
|
+
if (Object.hasOwn(includeTree, relName)) continue
|
|
64
|
+
const idsMap = Object.create(null)
|
|
65
|
+
|
|
66
|
+
if (relDef.type === 'hasMany' && !relDef.via) {
|
|
67
|
+
// Regular one-to-many
|
|
68
|
+
// Example: publisher hasMany authors
|
|
69
|
+
const foreignKey = relDef.foreignKey
|
|
70
|
+
|
|
71
|
+
if (!foreignKey) {
|
|
72
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
|
|
73
|
+
}
|
|
74
|
+
const targetScope = scopes[relDef.target]
|
|
75
|
+
const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target
|
|
76
|
+
const targetIdColumn = getIdColumnForScope(scopes, relDef.target)
|
|
77
|
+
const foreignKeyColumn = translateColumnForScope(scopes, relDef.target, foreignKey)
|
|
78
|
+
|
|
79
|
+
let query = knex(targetTable)
|
|
80
|
+
.modify(whereInIdentifiers, foreignKeyColumn, recordIds)
|
|
81
|
+
.select({ id: databaseIdentityExpression(knex, `${targetTable}.${targetIdColumn}`), [foreignKeyColumn]: databaseIdentityExpression(knex, `${targetTable}.${foreignKeyColumn}`) })
|
|
82
|
+
|
|
83
|
+
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
84
|
+
query,
|
|
85
|
+
scopes,
|
|
86
|
+
scopeName: relDef.target,
|
|
87
|
+
requestContext,
|
|
88
|
+
db: knex,
|
|
89
|
+
tableName: targetTable,
|
|
90
|
+
queryPurpose: 'relationship-identifiers'
|
|
91
|
+
})
|
|
92
|
+
query = scopedQueryState.query
|
|
93
|
+
|
|
94
|
+
const results = await query
|
|
95
|
+
|
|
96
|
+
results.forEach(row => {
|
|
97
|
+
const parentId = String(row[foreignKeyColumn])
|
|
98
|
+
if (!idsMap[parentId]) idsMap[parentId] = []
|
|
99
|
+
idsMap[parentId].push(String(row.id))
|
|
100
|
+
})
|
|
101
|
+
} else if (relDef.type === 'manyToMany') {
|
|
102
|
+
// Many-to-many relationship
|
|
103
|
+
// Example: { type: 'manyToMany', through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' }
|
|
104
|
+
const { through, foreignKey, otherKey } = relDef
|
|
105
|
+
const fk = translateColumnForScope(scopes, through, foreignKey)
|
|
106
|
+
const ok = translateColumnForScope(scopes, through, otherKey)
|
|
107
|
+
|
|
108
|
+
if (!fk || !ok) {
|
|
109
|
+
throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Get the actual table name from the pivot scope
|
|
113
|
+
const pivotScope = scopes[through]
|
|
114
|
+
const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through
|
|
115
|
+
|
|
116
|
+
const targetScopeName = relDef.target || relName
|
|
117
|
+
const targetTable = scopes[targetScopeName]?.vars?.schemaInfo?.tableName || targetScopeName
|
|
118
|
+
const targetIdColumn = getIdColumnForScope(scopes, targetScopeName)
|
|
119
|
+
let query = knex(targetTable)
|
|
120
|
+
.join(`${pivotTable} as pivot`, `${targetTable}.${targetIdColumn}`, `pivot.${ok}`)
|
|
121
|
+
.modify(whereInIdentifiers, `pivot.${fk}`, recordIds)
|
|
122
|
+
.select({ __parent_id: databaseIdentityExpression(knex, `pivot.${fk}`), id: databaseIdentityExpression(knex, `${targetTable}.${targetIdColumn}`) })
|
|
123
|
+
|
|
124
|
+
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
125
|
+
query,
|
|
126
|
+
scopes,
|
|
127
|
+
scopeName: targetScopeName,
|
|
128
|
+
requestContext,
|
|
129
|
+
db: knex,
|
|
130
|
+
tableName: targetTable,
|
|
131
|
+
queryPurpose: 'relationship-identifiers'
|
|
132
|
+
})
|
|
133
|
+
const pivotState = await applyScopeFiltersToIncludeQuery({
|
|
134
|
+
query: scopedQueryState.query, scopes, scopeName: through, requestContext, db: knex, tableName: 'pivot', queryPurpose: 'relationship-identifiers'
|
|
135
|
+
})
|
|
136
|
+
query = pivotState.query
|
|
137
|
+
|
|
138
|
+
const results = await query
|
|
139
|
+
|
|
140
|
+
results.forEach(row => {
|
|
141
|
+
const parentId = String(row.__parent_id)
|
|
142
|
+
const childId = String(row.id)
|
|
143
|
+
if (!idsMap[parentId]) idsMap[parentId] = []
|
|
144
|
+
idsMap[parentId].push(childId)
|
|
145
|
+
})
|
|
146
|
+
} else if (relDef.type === 'hasMany' && relDef.via) {
|
|
147
|
+
// Polymorphic reverse (via)
|
|
148
|
+
// Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
|
|
149
|
+
const targetScope = relDef.target
|
|
150
|
+
const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships
|
|
151
|
+
const viaRel = targetRelationships?.[relDef.via]
|
|
152
|
+
|
|
153
|
+
if (!viaRel?.belongsToPolymorphic) {
|
|
154
|
+
throw new Error(`Reverse relationship '${scopeName}.${relName}' requires polymorphic relationship '${targetScope}.${relDef.via}'`)
|
|
155
|
+
}
|
|
156
|
+
const { typeField, idField } = viaRel.belongsToPolymorphic
|
|
157
|
+
|
|
158
|
+
// Get the actual table name
|
|
159
|
+
const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope
|
|
160
|
+
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
161
|
+
const typeColumn = translateColumnForScope(scopes, targetScope, typeField)
|
|
162
|
+
const idColumn = translateColumnForScope(scopes, targetScope, idField)
|
|
163
|
+
|
|
164
|
+
let query = knex(targetTable)
|
|
165
|
+
.where(typeColumn, scopeName)
|
|
166
|
+
.modify(whereInIdentifiers, idColumn, recordIds)
|
|
167
|
+
.select({ id: databaseIdentityExpression(knex, `${targetTable}.${targetIdColumn}`), [idColumn]: databaseIdentityExpression(knex, `${targetTable}.${idColumn}`) })
|
|
168
|
+
|
|
169
|
+
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
170
|
+
query,
|
|
171
|
+
scopes,
|
|
172
|
+
scopeName: targetScope,
|
|
173
|
+
requestContext,
|
|
174
|
+
db: knex,
|
|
175
|
+
tableName: targetTable,
|
|
176
|
+
queryPurpose: 'relationship-identifiers'
|
|
177
|
+
})
|
|
178
|
+
query = scopedQueryState.query
|
|
179
|
+
|
|
180
|
+
const results = await query
|
|
181
|
+
|
|
182
|
+
results.forEach(row => {
|
|
183
|
+
const parentId = String(row[idColumn])
|
|
184
|
+
if (!idsMap[parentId]) idsMap[parentId] = []
|
|
185
|
+
idsMap[parentId].push(String(row.id))
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Apply the collected IDs to all records
|
|
190
|
+
if (Object.keys(idsMap).length > 0 || relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
191
|
+
records.forEach(record => {
|
|
192
|
+
if (!record[RELATIONSHIPS_KEY]) {
|
|
193
|
+
record[RELATIONSHIPS_KEY] = {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ids = idsMap[String(getFieldValueForScope(scopes, scopeName, record, 'id'))] || []
|
|
197
|
+
const targetType = relDef.target || relName
|
|
198
|
+
|
|
199
|
+
record[RELATIONSHIPS_KEY][relName] = {
|
|
200
|
+
data: ids.map(id => ({
|
|
201
|
+
type: targetType,
|
|
202
|
+
id
|
|
203
|
+
}))
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {unknown} [sort]
|
|
5
|
+
* @param {{ idField?: string }} [options]
|
|
6
|
+
* @returns {string[]}
|
|
7
|
+
*/
|
|
1
8
|
export function normalizeStableSort (sort = [], { idField = 'id' } = {}) {
|
|
9
|
+
/** @type {Set<string>} */
|
|
10
|
+
const seenFields = new Set()
|
|
2
11
|
const sortList = Array.isArray(sort)
|
|
3
|
-
? sort.filter(
|
|
12
|
+
? sort.filter(/** @param {unknown} entry @returns {entry is string} */ (entry) => {
|
|
13
|
+
if (typeof entry !== 'string' || !entry.trim()) return false
|
|
14
|
+
const field = entry.startsWith('-') ? entry.slice(1) : entry
|
|
15
|
+
if (seenFields.has(field)) return false
|
|
16
|
+
seenFields.add(field)
|
|
17
|
+
return true
|
|
18
|
+
})
|
|
4
19
|
: []
|
|
5
20
|
|
|
6
21
|
if (sortList.length === 0) {
|
|
@@ -19,6 +34,10 @@ export function normalizeStableSort (sort = [], { idField = 'id' } = {}) {
|
|
|
19
34
|
return sortList
|
|
20
35
|
}
|
|
21
36
|
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} sortEntry
|
|
39
|
+
* @returns {{ raw: string, field: string, desc: boolean, direction: 'asc' | 'desc', sqlDirection: 'ASC' | 'DESC' }}
|
|
40
|
+
*/
|
|
22
41
|
export function parseSortEntry (sortEntry) {
|
|
23
42
|
const desc = typeof sortEntry === 'string' && sortEntry.startsWith('-')
|
|
24
43
|
const field = desc ? sortEntry.slice(1) : sortEntry
|
|
@@ -1,27 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @import { StorageAdapter, StorageHookParams } from '../storage/storage-types.js' */
|
|
3
|
+
|
|
4
|
+
/** @param {StorageHookParams} hookParams @param {{ getStorageAdapter?: (scopeName: string) => StorageAdapter | null | undefined }} [options] */
|
|
1
5
|
export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter } = {}) => {
|
|
2
6
|
const context = hookParams?.context || {}
|
|
3
7
|
const activeQuery = context.knexQuery || {}
|
|
4
8
|
const activeScopeName = activeQuery.scopeName
|
|
5
9
|
const activeTableName = activeQuery.tableName
|
|
6
10
|
const activeStorageAdapter = activeQuery.storageAdapter || context.storageAdapter
|
|
11
|
+
/** @type {Map<string, StorageAdapter | null | undefined>} */
|
|
7
12
|
const storageCache = new Map()
|
|
8
13
|
|
|
14
|
+
/** @param {string} scopeName @returns {StorageAdapter | null | undefined} */
|
|
9
15
|
const fetchStorageAdapter = (scopeName) => {
|
|
10
16
|
if (!scopeName) return null
|
|
11
17
|
if (storageCache.has(scopeName)) return storageCache.get(scopeName)
|
|
12
18
|
|
|
13
19
|
let adapter = null
|
|
14
|
-
if (scopeName === activeScopeName) {
|
|
15
|
-
adapter = activeStorageAdapter
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
adapter = typeof getStorageAdapter === 'function' ? getStorageAdapter(scopeName) : null
|
|
20
|
+
if (scopeName === activeScopeName && activeStorageAdapter) {
|
|
21
|
+
adapter = activeStorageAdapter
|
|
22
|
+
} else if (typeof getStorageAdapter === 'function') {
|
|
23
|
+
adapter = getStorageAdapter(scopeName)
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
storageCache.set(scopeName, adapter)
|
|
22
27
|
return adapter
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
/** @param {string} scopeName @returns {string} */
|
|
25
31
|
const defaultAliasForScope = (scopeName) => {
|
|
26
32
|
if (scopeName === activeScopeName) {
|
|
27
33
|
return activeTableName || scopeName
|
|
@@ -29,6 +35,7 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
29
35
|
return scopeName
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** @param {string} scopeName @param {string} field @param {string | null} [alias] @returns {string} */
|
|
32
39
|
const translateColumn = (scopeName, field, alias = defaultAliasForScope(scopeName)) => {
|
|
33
40
|
const adapter = fetchStorageAdapter(scopeName)
|
|
34
41
|
const translated = adapter?.translateColumn?.(field) ?? field
|
|
@@ -36,6 +43,7 @@ export const createStorageAdapterUtilities = (hookParams, { getStorageAdapter }
|
|
|
36
43
|
return `${alias}.${translated}`
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
/** @param {string} scopeName @param {string} field @param {unknown} value @returns {unknown} */
|
|
39
47
|
const translateFilterValue = (scopeName, field, value) => {
|
|
40
48
|
const adapter = fetchStorageAdapter(scopeName)
|
|
41
49
|
if (!adapter?.translateFilterValue) return value
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* and support flexible URL prefix override capabilities.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
9
|
+
|
|
10
|
+
export const buildJsonApiLink = (baseUrl, queryParams, page) => {
|
|
11
|
+
const queryString = serializeJsonApiQuery(queryParams, { page })
|
|
12
|
+
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
16
|
* Get the complete URL prefix, handling all calculation and override logic
|
|
10
17
|
*
|
|
@@ -63,7 +70,7 @@ export function getUrlPrefix (context, scope) {
|
|
|
63
70
|
*/
|
|
64
71
|
export function buildResourceUrl (context, scope, scopeName, id) {
|
|
65
72
|
const urlPrefix = getUrlPrefix(context, scope)
|
|
66
|
-
return `${urlPrefix}/${scopeName}/${id}`
|
|
73
|
+
return `${urlPrefix}/${scopeName}/${encodeURIComponent(id)}`
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -87,11 +94,11 @@ export function buildResourceUrl (context, scope, scopeName, id) {
|
|
|
87
94
|
* // Returns: "https://api.example.com/api/books/1/author"
|
|
88
95
|
*/
|
|
89
96
|
export function buildRelationshipUrl (context, scope, scopeName, id, relationshipName, isRelationshipEndpoint = false) {
|
|
90
|
-
const
|
|
97
|
+
const resourceUrl = buildResourceUrl(context, scope, scopeName, id)
|
|
91
98
|
if (isRelationshipEndpoint) {
|
|
92
|
-
return `${
|
|
99
|
+
return `${resourceUrl}/relationships/${relationshipName}`
|
|
93
100
|
}
|
|
94
|
-
return `${
|
|
101
|
+
return `${resourceUrl}/${relationshipName}`
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
/**
|