json-rest-api 1.0.29 → 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 +744 -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} +74 -30
- 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 -177
- 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 +48 -317
- 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 +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -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 +179 -194
- 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 +100 -290
- 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 +93 -38
- 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 +128 -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 -2485
- 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 -1077
- 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 -395
- 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 -148
- 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 +36 -68
- 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 +37 -81
- 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 -1247
- 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 -148
- 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 -155
- package/tests/fixtures/api-configs.js +0 -2566
- 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-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- 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/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,261 +1,70 @@
|
|
|
1
1
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Apply defaults for limit
|
|
32
|
-
const effectiveLimit = includeConfig.limit ?? scopeVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT
|
|
33
|
-
|
|
34
|
-
// Validate against max
|
|
35
|
-
if (scopeVars.queryMaxLimit && effectiveLimit > scopeVars.queryMaxLimit) {
|
|
36
|
-
throw new RestApiResourceError({
|
|
37
|
-
title: 'Include Limit Exceeds Maximum',
|
|
38
|
-
detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${scopeVars.queryMaxLimit})`,
|
|
39
|
-
status: 400
|
|
40
|
-
})
|
|
3
|
+
import { prepareReferenceSortColumns } from './knex-query-helpers.js'
|
|
4
|
+
import { buildEffectiveSortList, parseSortEntry, resolveSortField } from './query-field-sort-helpers.js'
|
|
5
|
+
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
6
|
+
|
|
7
|
+
// Apply ordering and limits after target/pivot visibility has constrained the query.
|
|
8
|
+
export async function applyIncludeQueryConfig ({ query, scopeName, tableName, parentColumn, includeConfig = {}, context = {}, capabilities, queryFieldRuntimeByField = new Map() }, dependencies) {
|
|
9
|
+
const { scopes, knex, getStorageAdapter } = dependencies
|
|
10
|
+
const db = context.db || context.transaction || knex
|
|
11
|
+
const vars = scopes[scopeName].vars
|
|
12
|
+
const storageAdapter = getStorageAdapter(scopeName)
|
|
13
|
+
const fields = buildEffectiveSortList(includeConfig.orderBy, { defaultSort: vars.defaultSort, schemaInfo: vars.schemaInfo }).map(parseSortEntry)
|
|
14
|
+
const references = await prepareReferenceSortColumns({
|
|
15
|
+
query,
|
|
16
|
+
fields: fields.map(({ field }) => field).filter(field => !queryFieldRuntimeByField.has(field)),
|
|
17
|
+
scopeName,
|
|
18
|
+
tableAlias: tableName,
|
|
19
|
+
context
|
|
20
|
+
}, dependencies)
|
|
21
|
+
const names = new Set([
|
|
22
|
+
...Object.keys(vars.schemaInfo?.queryFields || {}),
|
|
23
|
+
...Object.keys(vars.schemaInfo.schemaStructure).flatMap(field => [field, storageAdapter.translateColumn(field)]),
|
|
24
|
+
...[...references.values()].map(reference => reference.resultColumn)
|
|
25
|
+
])
|
|
26
|
+
const uniqueName = base => {
|
|
27
|
+
let name = base
|
|
28
|
+
while (names.has(name)) name += '_'
|
|
29
|
+
names.add(name)
|
|
30
|
+
return name
|
|
41
31
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
database: dbInfo.client,
|
|
52
|
-
version: dbInfo.version,
|
|
53
|
-
requiredFeature: 'window_functions'
|
|
32
|
+
const parentResultColumn = uniqueName('__jra_include_parent')
|
|
33
|
+
const rowNumberColumn = uniqueName(ROW_NUMBER_KEY)
|
|
34
|
+
query.select({ [parentResultColumn]: typeof parentColumn === 'string' ? databaseIdentityExpression(db, parentColumn) : parentColumn })
|
|
35
|
+
const ordering = fields.map(({ field, sqlDirection }) => {
|
|
36
|
+
const runtime = queryFieldRuntimeByField.get(field)
|
|
37
|
+
if (runtime) {
|
|
38
|
+
return {
|
|
39
|
+
sql: `(${runtime.sql}) IS NULL ASC, (${runtime.sql}) ${sqlDirection}`,
|
|
40
|
+
bindings: [...runtime.bindings, ...runtime.bindings]
|
|
54
41
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// This creates a subquery that partitions by the foreign key and numbers rows
|
|
60
|
-
const subquery = knex(tableName)
|
|
61
|
-
.select('*')
|
|
62
|
-
.select(
|
|
63
|
-
knex.raw(
|
|
64
|
-
'ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ' +
|
|
65
|
-
buildOrderByClause(orderBy) +
|
|
66
|
-
') as ' + ROW_NUMBER_KEY,
|
|
67
|
-
[foreignKey]
|
|
68
|
-
)
|
|
69
|
-
)
|
|
70
|
-
.whereIn(foreignKey, parentIds)
|
|
71
|
-
|
|
72
|
-
// Apply field selection if specified
|
|
73
|
-
if (fieldsToSelect !== '*' && Array.isArray(fieldsToSelect)) {
|
|
74
|
-
// Include the foreign key and row number in selection
|
|
75
|
-
const fieldsWithFK = [...new Set([...fieldsToSelect, foreignKey, ROW_NUMBER_KEY])]
|
|
76
|
-
subquery.select(fieldsWithFK)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return { subquery, effectiveLimit }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Builds ORDER BY clause from array of sort fields
|
|
84
|
-
*
|
|
85
|
-
* @param {Array<string>} orderBy - Array of field names, prefix with '-' for DESC
|
|
86
|
-
* @param {string} tablePrefix - Optional table name to prefix fields
|
|
87
|
-
* @returns {string} SQL ORDER BY clause
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* // Input: Simple ascending sort
|
|
91
|
-
* const clause = buildOrderByClause(['name', 'created_at']);
|
|
92
|
-
* // Output: "name ASC, created_at ASC"
|
|
93
|
-
*
|
|
94
|
-
* @example
|
|
95
|
-
* // Input: Mixed ascending/descending with '-' prefix
|
|
96
|
-
* const clause = buildOrderByClause(['name', '-created_at', 'status']);
|
|
97
|
-
* // Output: "name ASC, created_at DESC, status ASC"
|
|
98
|
-
*
|
|
99
|
-
* @example
|
|
100
|
-
* // Input: With table prefix for joins
|
|
101
|
-
* const clause = buildOrderByClause(['-updated_at', 'title'], 'articles');
|
|
102
|
-
* // Output: "articles.updated_at DESC, articles.title ASC"
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* // Input: Empty array (default to id)
|
|
106
|
-
* const clause = buildOrderByClause([]);
|
|
107
|
-
* // Output: "id ASC"
|
|
108
|
-
*
|
|
109
|
-
* @description
|
|
110
|
-
* Used by:
|
|
111
|
-
* - buildWindowedIncludeSubquery for PARTITION BY ordering
|
|
112
|
-
* - applyStandardIncludeConfig for regular ORDER BY
|
|
113
|
-
* - Query builders that need consistent sort syntax
|
|
114
|
-
*
|
|
115
|
-
* Purpose:
|
|
116
|
-
* - Converts API sort syntax (with '-' prefix) to SQL ORDER BY
|
|
117
|
-
* - Handles table prefixing for queries with joins
|
|
118
|
-
* - Provides default ordering by id when none specified
|
|
119
|
-
* - Ensures consistent sort behavior across the API
|
|
120
|
-
*
|
|
121
|
-
* Data flow:
|
|
122
|
-
* 1. Receives array of sort fields from API parameters
|
|
123
|
-
* 2. Detects DESC sorts by '-' prefix
|
|
124
|
-
* 3. Optionally prefixes with table name
|
|
125
|
-
* 4. Joins into SQL-compatible ORDER BY clause
|
|
126
|
-
*/
|
|
127
|
-
export const buildOrderByClause = (orderBy, tablePrefix) => {
|
|
128
|
-
if (!orderBy || orderBy.length === 0) {
|
|
129
|
-
const defaultField = tablePrefix ? `${tablePrefix}.id` : 'id'
|
|
130
|
-
return `${defaultField} ASC` // Default ordering
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return orderBy.map(field => {
|
|
134
|
-
const isDesc = field.startsWith('-')
|
|
135
|
-
const fieldName = isDesc ? field.substring(1) : field
|
|
136
|
-
const qualifiedField = tablePrefix ? `${tablePrefix}.${fieldName}` : fieldName
|
|
137
|
-
return `${qualifiedField} ${isDesc ? 'DESC' : 'ASC'}`
|
|
138
|
-
}).join(', ')
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Applies standard (non-windowed) include configuration to a query
|
|
143
|
-
*
|
|
144
|
-
* @param {Object} query - Knex query builder instance
|
|
145
|
-
* @param {Object} includeConfig - Include configuration object
|
|
146
|
-
* @param {Object} scopeVars - Scope variables with defaults and limits
|
|
147
|
-
* @param {Object} log - Logger instance
|
|
148
|
-
* @returns {Object} Modified query builder
|
|
149
|
-
*
|
|
150
|
-
* @example
|
|
151
|
-
* // Input: Basic include with limit and ordering
|
|
152
|
-
* let query = knex('comments').whereIn('article_id', [1, 2, 3]);
|
|
153
|
-
* query = applyStandardIncludeConfig(
|
|
154
|
-
* query,
|
|
155
|
-
* { limit: 20, orderBy: ['-created_at', 'id'] },
|
|
156
|
-
* { queryDefaultLimit: 100, queryMaxLimit: 1000 },
|
|
157
|
-
* logger
|
|
158
|
-
* );
|
|
159
|
-
*
|
|
160
|
-
* // Result: Query modified with:
|
|
161
|
-
* // ORDER BY created_at DESC, id ASC
|
|
162
|
-
* // LIMIT 20
|
|
163
|
-
*
|
|
164
|
-
* @example
|
|
165
|
-
* // Input: No explicit limit, uses defaults
|
|
166
|
-
* let query = knex('tags');
|
|
167
|
-
* query = applyStandardIncludeConfig(
|
|
168
|
-
* query,
|
|
169
|
-
* { orderBy: ['name'] }, // No limit specified
|
|
170
|
-
* { queryDefaultLimit: 50, queryMaxLimit: 500 },
|
|
171
|
-
* logger
|
|
172
|
-
* );
|
|
173
|
-
*
|
|
174
|
-
* // Result: Uses default limit
|
|
175
|
-
* // ORDER BY name ASC
|
|
176
|
-
* // LIMIT 50
|
|
177
|
-
* // Log: "Using default limit"
|
|
178
|
-
*
|
|
179
|
-
* @example
|
|
180
|
-
* // Input: Limit exceeds maximum
|
|
181
|
-
* let query = knex('reviews');
|
|
182
|
-
* query = applyStandardIncludeConfig(
|
|
183
|
-
* query,
|
|
184
|
-
* { limit: 5000 }, // Exceeds max
|
|
185
|
-
* { queryMaxLimit: 1000, maxIncludeLimit: 500 },
|
|
186
|
-
* logger
|
|
187
|
-
* );
|
|
188
|
-
*
|
|
189
|
-
* // Result: Clamped to effective maximum
|
|
190
|
-
* // LIMIT 500 (min of maxIncludeLimit and queryMaxLimit)
|
|
191
|
-
*
|
|
192
|
-
* @example
|
|
193
|
-
* // Input: Explicitly disable limit
|
|
194
|
-
* let query = knex('categories');
|
|
195
|
-
* query = applyStandardIncludeConfig(
|
|
196
|
-
* query,
|
|
197
|
-
* { limit: null }, // Explicitly no limit
|
|
198
|
-
* { queryDefaultLimit: 100 },
|
|
199
|
-
* logger
|
|
200
|
-
* );
|
|
201
|
-
*
|
|
202
|
-
* // Result: No LIMIT clause added
|
|
203
|
-
* // Log: "No limit applied to include query (explicitly disabled)"
|
|
204
|
-
*
|
|
205
|
-
* @description
|
|
206
|
-
* Used by:
|
|
207
|
-
* - loadHasMany when window functions not available or not requested
|
|
208
|
-
* - loadReversePolymorphic for standard relationship queries
|
|
209
|
-
* - Any include loader that needs consistent limit/order handling
|
|
210
|
-
*
|
|
211
|
-
* Purpose:
|
|
212
|
-
* - Provides fallback when window functions unavailable
|
|
213
|
-
* - Applies consistent ordering based on API sort syntax
|
|
214
|
-
* - Enforces limit hierarchy: explicit > default > max
|
|
215
|
-
* - Respects both queryMaxLimit and maxIncludeLimit
|
|
216
|
-
* - Logs decisions for debugging
|
|
217
|
-
*
|
|
218
|
-
* Data flow:
|
|
219
|
-
* 1. Applies ORDER BY for each field in orderBy array
|
|
220
|
-
* 2. Determines effective limit from explicit/default/max values
|
|
221
|
-
* 3. Clamps limit to maximum allowed values
|
|
222
|
-
* 4. Adds LIMIT clause unless explicitly disabled
|
|
223
|
-
* 5. Logs the reasoning for the applied limit
|
|
224
|
-
*/
|
|
225
|
-
export const applyStandardIncludeConfig = (query, includeConfig, scopeVars, log) => {
|
|
226
|
-
const { orderBy = [] } = includeConfig
|
|
227
|
-
|
|
228
|
-
// Apply ordering
|
|
229
|
-
orderBy.forEach(field => {
|
|
230
|
-
const desc = field.startsWith('-')
|
|
231
|
-
const column = desc ? field.substring(1) : field
|
|
232
|
-
query = query.orderBy(column, desc ? 'desc' : 'asc')
|
|
42
|
+
}
|
|
43
|
+
const actualField = resolveSortField(field, vars.schemaInfo)
|
|
44
|
+
const column = references.get(field)?.column || `${tableName}.${storageAdapter.translateColumn(actualField)}`
|
|
45
|
+
return { sql: `?? IS NULL ASC, ?? ${sqlDirection}`, bindings: [column, column] }
|
|
233
46
|
})
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (limit !== null && limit
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
log.debug('Applied include limit:', {
|
|
250
|
-
requested: requestedLimit,
|
|
251
|
-
default: defaultLimit,
|
|
252
|
-
effective: effectiveLimit,
|
|
253
|
-
maxAllowed: effectiveMax,
|
|
254
|
-
note: requestedLimit !== undefined ? 'Using explicit limit' : 'Using default limit'
|
|
255
|
-
})
|
|
47
|
+
const orderSql = ordering.map(order => order.sql).join(', ')
|
|
48
|
+
const orderBindings = ordering.flatMap(order => order.bindings)
|
|
49
|
+
const requested = includeConfig.limit
|
|
50
|
+
const limit = requested === null || requested === false
|
|
51
|
+
? null
|
|
52
|
+
: Math.min(requested ?? vars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT,
|
|
53
|
+
vars.queryMaxLimit ?? DEFAULT_MAX_QUERY_LIMIT, vars.maxIncludeLimit ?? DEFAULT_MAX_INCLUDE_LIMIT)
|
|
54
|
+
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) throw new Error(`Invalid include limit for ${scopeName}: expected a non-negative integer or null`)
|
|
55
|
+
|
|
56
|
+
if (includeConfig.strategy === 'window' && limit !== null) {
|
|
57
|
+
if (capabilities?.windowFunctions === false) throw new RestApiResourceError('Per-parent include limits require window function support', { subtype: 'unsupported_operation' })
|
|
58
|
+
query.select(db.raw(`ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ${orderSql}) as ??`, [parentColumn, ...orderBindings, rowNumberColumn]))
|
|
59
|
+
query = db.select('*').from(query.as('_windowed')).where(rowNumberColumn, '<=', limit)
|
|
60
|
+
.orderBy(parentResultColumn).orderBy(rowNumberColumn)
|
|
256
61
|
} else {
|
|
257
|
-
|
|
62
|
+
query.orderByRaw(orderSql, orderBindings)
|
|
63
|
+
if (limit !== null) query.limit(limit)
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
query: applyDatabaseReadOptions(query),
|
|
67
|
+
parentColumn: parentResultColumn,
|
|
68
|
+
temporaryFields: [parentResultColumn, rowNumberColumn, ...[...references.values()].map(reference => reference.resultColumn)]
|
|
258
69
|
}
|
|
259
|
-
|
|
260
|
-
return query
|
|
261
70
|
}
|
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @import { StorageQuery, QueryFilteringState } from '../storage/storage-types.js' */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @overload
|
|
6
|
+
* @param {import('../storage/storage-types.js').QueryBuilderResult | null | undefined} value
|
|
7
|
+
* @param {StorageQuery} fallback
|
|
8
|
+
* @returns {StorageQuery}
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @overload
|
|
12
|
+
* @param {unknown} value
|
|
13
|
+
* @param {unknown} [fallback]
|
|
14
|
+
* @returns {unknown}
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} value
|
|
18
|
+
* @param {unknown} [fallback]
|
|
19
|
+
* @returns {unknown} Hook values are untrusted until the caller validates them.
|
|
20
|
+
*/
|
|
21
|
+
export function unwrapQueryBuilderState (value, fallback = null) {
|
|
2
22
|
let current = value
|
|
3
23
|
const seen = new Set()
|
|
4
24
|
|
|
@@ -7,13 +27,33 @@ export const unwrapQueryBuilderState = (value, fallback = null) => {
|
|
|
7
27
|
typeof current === 'object' &&
|
|
8
28
|
!Array.isArray(current) &&
|
|
9
29
|
Object.hasOwn(current, 'query') &&
|
|
10
|
-
current.query &&
|
|
11
|
-
current.query !== current &&
|
|
30
|
+
/** @type {{ query?: unknown }} */ (current).query &&
|
|
31
|
+
/** @type {{ query?: unknown }} */ (current).query !== current &&
|
|
12
32
|
!seen.has(current)
|
|
13
33
|
) {
|
|
14
34
|
seen.add(current)
|
|
15
|
-
current = current.query
|
|
35
|
+
current = /** @type {{ query?: unknown }} */ (current).query
|
|
16
36
|
}
|
|
17
37
|
|
|
18
38
|
return current || fallback
|
|
19
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{ knexQuery?: QueryFilteringState | null }} context
|
|
43
|
+
* @param {QueryFilteringState & { query: StorageQuery }} state
|
|
44
|
+
* @param {() => unknown | Promise<unknown>} applyFilters
|
|
45
|
+
* @returns {Promise<{ query: StorageQuery }>}
|
|
46
|
+
*/
|
|
47
|
+
// Return a wrapper: resolving an async function with a Knex builder executes it.
|
|
48
|
+
export async function withQueryFilteringContext (context, state, applyFilters) {
|
|
49
|
+
const query = state.query
|
|
50
|
+
const previousKnexQuery = context.knexQuery
|
|
51
|
+
context.knexQuery = state
|
|
52
|
+
try {
|
|
53
|
+
await applyFilters()
|
|
54
|
+
return { query: context.knexQuery?.query || query }
|
|
55
|
+
} finally {
|
|
56
|
+
if (previousKnexQuery === undefined) delete context.knexQuery
|
|
57
|
+
else context.knexQuery = previousKnexQuery
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MandatoryQueryConstraint, StorageAdapter, StorageQuery } from '../storage/storage-types.js'
|
|
2
|
+
|
|
3
|
+
export type { MandatoryQueryConstraint } from '../storage/storage-types.js'
|
|
4
|
+
export declare const queryConstraint: unique symbol
|
|
5
|
+
|
|
6
|
+
export declare function applyQueryConstraint(options: {
|
|
7
|
+
query: StorageQuery
|
|
8
|
+
context: { [queryConstraint]?: MandatoryQueryConstraint }
|
|
9
|
+
scopeName: string
|
|
10
|
+
storageAdapter: StorageAdapter
|
|
11
|
+
tableName: string
|
|
12
|
+
}): void
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @import { MandatoryQueryConstraint, StorageAdapter, StorageQuery } from '../storage/storage-types.js' */
|
|
3
|
+
|
|
4
|
+
// Mandatory selection is independent of public search fields and caller filters.
|
|
5
|
+
export const queryConstraint = Symbol('queryConstraint')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {StorageQuery} options.query
|
|
10
|
+
* @param {{ [queryConstraint]?: MandatoryQueryConstraint }} options.context
|
|
11
|
+
* @param {string} options.scopeName
|
|
12
|
+
* @param {StorageAdapter} options.storageAdapter
|
|
13
|
+
* @param {string} options.tableName
|
|
14
|
+
*/
|
|
15
|
+
export function applyQueryConstraint ({ query, context, scopeName, storageAdapter, tableName }) {
|
|
16
|
+
const constraint = context[queryConstraint]
|
|
17
|
+
if (!constraint || constraint.scopeName !== scopeName) return
|
|
18
|
+
|
|
19
|
+
if (constraint.idsQuery) {
|
|
20
|
+
query.whereIn(`${tableName}.${storageAdapter.getIdColumn()}`, constraint.idsQuery)
|
|
21
|
+
}
|
|
22
|
+
for (const [field, value] of Object.entries(constraint.values || {})) {
|
|
23
|
+
query.where({
|
|
24
|
+
[`${tableName}.${storageAdapter.translateColumn(field)}`]: storageAdapter.translateFilterValue(field, value)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,11 +1,33 @@
|
|
|
1
|
+
import { assertScalarQueryField } from '../querying-writing/field-utils.js'
|
|
1
2
|
import { normalizeStableSort, parseSortEntry } from './sort-helpers.js'
|
|
2
3
|
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { createSchemaFactory } from 'json-rest-schema'
|
|
5
|
+
import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
|
|
5
6
|
|
|
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) => {
|
|
7
29
|
const validated = Object.create(null)
|
|
8
|
-
for (const { field, definition = {}, isRelationship } of descriptors) {
|
|
30
|
+
for (const { field, definition = {}, isRelationship, queryFieldRuntime } of descriptors) {
|
|
9
31
|
if (!Object.hasOwn(cursorValues, field)) continue
|
|
10
32
|
const value = cursorValues[field]
|
|
11
33
|
// Public resource IDs may be opaque strings, even when the storage schema
|
|
@@ -17,29 +39,46 @@ export const validateCursorValues = (descriptors, cursorValues, parameter) => {
|
|
|
17
39
|
validated[field] = value
|
|
18
40
|
continue
|
|
19
41
|
}
|
|
20
|
-
const contract =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
: {})
|
|
28
|
-
}
|
|
42
|
+
const contract = getCursorContract(schema, {
|
|
43
|
+
type,
|
|
44
|
+
noTrim: true,
|
|
45
|
+
nullable: field !== 'id',
|
|
46
|
+
...(definition?.temporalPrecision !== undefined
|
|
47
|
+
? { temporalPrecision: definition.temporalPrecision }
|
|
48
|
+
: {})
|
|
29
49
|
})
|
|
30
|
-
|
|
31
|
-
|
|
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())) {
|
|
32
62
|
const path = `page.${parameter}`
|
|
33
63
|
throw new RestApiValidationError(`Invalid cursor value for sort field '${field}'.`, {
|
|
34
64
|
fields: [path],
|
|
35
65
|
violations: [{ field: path, rule: 'invalid_cursor_value', message: `Invalid ${type} value for '${field}'.` }]
|
|
36
66
|
})
|
|
37
67
|
}
|
|
38
|
-
|
|
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
|
|
39
73
|
}
|
|
40
74
|
return validated
|
|
41
75
|
}
|
|
42
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
|
+
}
|
|
81
|
+
|
|
43
82
|
export const getEffectiveSortableFields = (vars = {}) => {
|
|
44
83
|
const schema = vars.schemaInfo?.schemaStructure || {}
|
|
45
84
|
const baseSortableFields = Array.isArray(vars.sortableFields) && vars.sortableFields.length > 0
|
|
@@ -50,42 +89,97 @@ export const getEffectiveSortableFields = (vars = {}) => {
|
|
|
50
89
|
}), ...Object.entries(vars.schemaInfo?.searchSchemaStructure || {})
|
|
51
90
|
.filter(([, definition]) => definition.isRelationship && schema[definition.actualField]?.hidden !== true)
|
|
52
91
|
.map(([field]) => field)]
|
|
53
|
-
const queryFieldSortableFields = Object.entries(vars.queryFields || {})
|
|
92
|
+
const queryFieldSortableFields = Object.entries(vars.schemaInfo?.queryFields || {})
|
|
54
93
|
.filter(([, fieldDef]) => fieldDef?.sortable === true)
|
|
55
94
|
.map(([fieldName]) => fieldName)
|
|
56
95
|
|
|
57
|
-
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
|
+
})
|
|
58
102
|
}
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
sortList = [`${direction}${field}`]
|
|
72
|
-
}
|
|
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
|
|
73
115
|
}
|
|
116
|
+
let sortList = toList(sort, 'sort')
|
|
117
|
+
if (sortList.length === 0) sortList = toList(defaultSort, 'defaultSort')
|
|
74
118
|
|
|
75
|
-
|
|
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
|
|
76
134
|
}
|
|
77
135
|
|
|
78
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
|
+
}
|
|
79
147
|
query.orderByRaw(`(${queryFieldRuntime.sql}) IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, queryFieldRuntime.bindings)
|
|
80
148
|
query.orderByRaw(`(${queryFieldRuntime.sql}) ${direction}`, queryFieldRuntime.bindings)
|
|
81
149
|
}
|
|
82
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
|
+
|
|
83
181
|
export const applyQueryFieldPredicate = (builder, queryFieldRuntime, operator, value) => {
|
|
84
|
-
|
|
85
|
-
const normalizedValue = normalizeValueForDatabaseStorage(value, definition.type, {
|
|
86
|
-
temporalPrecision: definition.temporalPrecision
|
|
87
|
-
})
|
|
88
|
-
builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, normalizedValue])
|
|
182
|
+
builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, value])
|
|
89
183
|
}
|
|
90
184
|
|
|
91
185
|
export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate, includeNull = false) => {
|
|
@@ -106,6 +200,9 @@ export const applySortDescriptorPredicate = (builder, descriptor, operator, valu
|
|
|
106
200
|
}
|
|
107
201
|
return isNotNull ? builder.whereNotNull(descriptor.column) : builder.whereNull(descriptor.column)
|
|
108
202
|
}
|
|
203
|
+
if (descriptor.definition?.storage?.serialize) {
|
|
204
|
+
return builder.where(descriptor.column, operator, value)
|
|
205
|
+
}
|
|
109
206
|
if (descriptor?.queryFieldRuntime) {
|
|
110
207
|
return applyQueryFieldPredicate(builder, descriptor.queryFieldRuntime, operator, value)
|
|
111
208
|
}
|