json-rest-api 1.0.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +56 -6
- package/tests/test-development-guide.md +5 -5
|
@@ -75,9 +75,12 @@
|
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
77
|
import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
78
|
-
import { getForeignKeyFields } from '../querying-writing/field-utils.js'
|
|
79
78
|
import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
|
|
80
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
buildWindowedIncludeSubquery,
|
|
81
|
+
applyStandardIncludeConfig,
|
|
82
|
+
buildOrderByClause
|
|
83
|
+
} from './knex-window-queries.js'
|
|
81
84
|
import { unwrapQueryBuilderState } from './query-builder-utils.js'
|
|
82
85
|
import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
|
|
83
86
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
@@ -150,7 +153,8 @@ const applyScopeFiltersToIncludeQuery = async ({
|
|
|
150
153
|
scopeName,
|
|
151
154
|
requestContext,
|
|
152
155
|
db,
|
|
153
|
-
tableName
|
|
156
|
+
tableName,
|
|
157
|
+
queryPurpose = 'include'
|
|
154
158
|
}) => {
|
|
155
159
|
const scopeObject = scopes[scopeName]
|
|
156
160
|
if (!scopeObject?.applyQueryFilters) {
|
|
@@ -166,6 +170,7 @@ const applyScopeFiltersToIncludeQuery = async ({
|
|
|
166
170
|
tableName: tableName || scopeObject.vars.schemaInfo.tableName,
|
|
167
171
|
db,
|
|
168
172
|
schemaInfo: scopeObject.vars.schemaInfo,
|
|
173
|
+
queryPurpose,
|
|
169
174
|
storageAdapter
|
|
170
175
|
}, buildScopedIncludeContext({
|
|
171
176
|
requestContext,
|
|
@@ -491,8 +496,6 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
491
496
|
// Get target table info
|
|
492
497
|
const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
|
|
493
498
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
494
|
-
const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
495
|
-
|
|
496
499
|
// Build field selection for sparse fieldsets
|
|
497
500
|
const targetScopeObject = scopes[targetScope]
|
|
498
501
|
const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
@@ -548,9 +551,9 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
548
551
|
|
|
549
552
|
// Create lookup map
|
|
550
553
|
const targetById = {}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
+
targetRecords.forEach(record => {
|
|
555
|
+
targetById[getFieldValueForScope(scopes, targetScope, record, 'id')] = record
|
|
556
|
+
})
|
|
554
557
|
|
|
555
558
|
// Set relationships on original records
|
|
556
559
|
const targetRecordsToProcess = []
|
|
@@ -695,7 +698,7 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
695
698
|
const otherKey = relDef.otherKey
|
|
696
699
|
|
|
697
700
|
if (!foreignKey || !otherKey) {
|
|
698
|
-
throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${
|
|
701
|
+
throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${includeName}' for scope '${scopeName}'`)
|
|
699
702
|
}
|
|
700
703
|
|
|
701
704
|
// For manyToMany, the target scope is the relationship name itself
|
|
@@ -737,12 +740,11 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
737
740
|
})
|
|
738
741
|
|
|
739
742
|
// Step 3: Build field selection for sparse fieldsets
|
|
740
|
-
const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
741
743
|
const targetScopeObject = scopes[targetScope]
|
|
742
744
|
const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
743
745
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
744
|
-
|
|
745
|
-
|
|
746
|
+
const fieldSelectionInfo = fields?.[targetScope]
|
|
747
|
+
? await buildFieldSelection(targetScopeObject, {
|
|
746
748
|
context: {
|
|
747
749
|
...(requestContext || {}),
|
|
748
750
|
scopeName: targetScope,
|
|
@@ -958,7 +960,7 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
958
960
|
const foreignKey = relDef.foreignKey
|
|
959
961
|
|
|
960
962
|
if (!foreignKey) {
|
|
961
|
-
throw new Error(`Missing foreignKey in hasMany relationship '${
|
|
963
|
+
throw new Error(`Missing foreignKey in hasMany relationship '${includeName}' for scope '${scopeName}'`)
|
|
962
964
|
}
|
|
963
965
|
|
|
964
966
|
log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
|
|
@@ -967,7 +969,6 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
967
969
|
})
|
|
968
970
|
|
|
969
971
|
// Build field selection for sparse fieldsets
|
|
970
|
-
const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
971
972
|
const targetScopeObject = scopes[targetScope]
|
|
972
973
|
const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
973
974
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
@@ -986,46 +987,38 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
986
987
|
|
|
987
988
|
// Check if we should use window functions
|
|
988
989
|
if (relDef.include?.strategy === 'window') {
|
|
989
|
-
|
|
990
|
-
// Try to build window function query
|
|
991
|
-
if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
|
|
992
|
-
throw new Error('Query projection fields require the standard include query path.')
|
|
993
|
-
}
|
|
990
|
+
let windowedQuery
|
|
994
991
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
992
|
+
if (fieldSelectionInfo?.queryFieldsToSelect?.length) {
|
|
993
|
+
log.debug('[INCLUDE] Query projection fields require the standard include query path')
|
|
994
|
+
} else {
|
|
995
|
+
windowedQuery = buildWindowedIncludeSubquery(
|
|
998
996
|
knex,
|
|
999
997
|
targetTable,
|
|
1000
998
|
foreignKey,
|
|
1001
999
|
mainIds,
|
|
1002
|
-
|
|
1000
|
+
fieldSelectionInfo?.fieldsToSelect || null,
|
|
1003
1001
|
relDef.include || {},
|
|
1004
1002
|
capabilities,
|
|
1005
|
-
targetScopeObject.vars
|
|
1003
|
+
targetScopeObject.vars
|
|
1006
1004
|
)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (windowedQuery) {
|
|
1007
1008
|
const scopedQueryState = await applyScopeFiltersToIncludeQuery({
|
|
1008
|
-
query,
|
|
1009
|
+
query: windowedQuery.subquery,
|
|
1009
1010
|
scopes,
|
|
1010
1011
|
scopeName: targetScope,
|
|
1011
1012
|
requestContext,
|
|
1012
1013
|
db: knex,
|
|
1013
1014
|
tableName: targetTable
|
|
1014
1015
|
})
|
|
1015
|
-
query =
|
|
1016
|
+
query = knex
|
|
1017
|
+
.select('*')
|
|
1018
|
+
.from(scopedQueryState.query.as('_windowed'))
|
|
1019
|
+
.where(ROW_NUMBER_KEY, '<=', windowedQuery.effectiveLimit)
|
|
1016
1020
|
usingWindowFunction = true
|
|
1017
1021
|
log.debug('[INCLUDE] Using window function strategy with limits')
|
|
1018
|
-
} catch (error) {
|
|
1019
|
-
// If window functions not supported, this will throw a clear error
|
|
1020
|
-
if (error.details?.requiredFeature === 'window_functions') {
|
|
1021
|
-
throw error // Re-throw the descriptive error
|
|
1022
|
-
}
|
|
1023
|
-
// For other errors, fall back to standard query
|
|
1024
|
-
log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
|
|
1025
|
-
error: error.message,
|
|
1026
|
-
stack: error.stack
|
|
1027
|
-
})
|
|
1028
|
-
usingWindowFunction = false
|
|
1029
1022
|
}
|
|
1030
1023
|
}
|
|
1031
1024
|
|
|
@@ -1199,10 +1192,10 @@ export const loadHasOne = async (scope, deps) => {
|
|
|
1199
1192
|
const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
|
|
1200
1193
|
const { context: { scopes, log, knex, capabilities, requestContext } } = deps
|
|
1201
1194
|
|
|
1202
|
-
|
|
1195
|
+
const childLog = log?.child ? log.child({ method: 'loadHasOne' }) : console
|
|
1203
1196
|
|
|
1204
1197
|
if (!records || records.length === 0) {
|
|
1205
|
-
|
|
1198
|
+
childLog.trace('[INCLUDE] No records to load hasOne for')
|
|
1206
1199
|
return
|
|
1207
1200
|
}
|
|
1208
1201
|
|
|
@@ -1210,7 +1203,7 @@ export const loadHasOne = async (scope, deps) => {
|
|
|
1210
1203
|
const mainIds = [...new Set(records.map(record => getFieldValueForScope(scopes, scopeName, record, 'id')).filter(Boolean))]
|
|
1211
1204
|
|
|
1212
1205
|
if (mainIds.length === 0) {
|
|
1213
|
-
|
|
1206
|
+
childLog.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
|
|
1214
1207
|
return
|
|
1215
1208
|
}
|
|
1216
1209
|
|
|
@@ -1222,12 +1215,11 @@ export const loadHasOne = async (scope, deps) => {
|
|
|
1222
1215
|
throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`)
|
|
1223
1216
|
}
|
|
1224
1217
|
|
|
1225
|
-
|
|
1218
|
+
childLog.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
|
|
1226
1219
|
whereIn: mainIds
|
|
1227
1220
|
})
|
|
1228
1221
|
|
|
1229
1222
|
// Build query for hasOne - expects single result per parent
|
|
1230
|
-
const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
1231
1223
|
const targetScopeObject = scopes[targetScope]
|
|
1232
1224
|
const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
1233
1225
|
|
|
@@ -1629,7 +1621,6 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1629
1621
|
})
|
|
1630
1622
|
|
|
1631
1623
|
// Build field selection for sparse fieldsets
|
|
1632
|
-
const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
1633
1624
|
const targetScopeObject = scopes[targetScope]
|
|
1634
1625
|
const storageAdapter = targetScopeObject.vars.storageAdapter
|
|
1635
1626
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
@@ -2121,7 +2112,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2121
2112
|
scopeName: relDef.target,
|
|
2122
2113
|
requestContext,
|
|
2123
2114
|
db: knex,
|
|
2124
|
-
tableName: targetTable
|
|
2115
|
+
tableName: targetTable,
|
|
2116
|
+
queryPurpose: 'relationship-identifiers'
|
|
2125
2117
|
})
|
|
2126
2118
|
query = scopedQueryState.query
|
|
2127
2119
|
|
|
@@ -2166,7 +2158,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2166
2158
|
scopeName: targetScopeName,
|
|
2167
2159
|
requestContext,
|
|
2168
2160
|
db: knex,
|
|
2169
|
-
tableName: targetTable
|
|
2161
|
+
tableName: targetTable,
|
|
2162
|
+
queryPurpose: 'relationship-identifiers'
|
|
2170
2163
|
})
|
|
2171
2164
|
query = scopedQueryState.query
|
|
2172
2165
|
|
|
@@ -2205,7 +2198,8 @@ export const loadRelationshipIdentifiers = async (records, scopeName, scopes, kn
|
|
|
2205
2198
|
scopeName: targetScope,
|
|
2206
2199
|
requestContext,
|
|
2207
2200
|
db: knex,
|
|
2208
|
-
tableName: targetTable
|
|
2201
|
+
tableName: targetTable,
|
|
2202
|
+
queryPurpose: 'relationship-identifiers'
|
|
2209
2203
|
})
|
|
2210
2204
|
query = scopedQueryState.query
|
|
2211
2205
|
|
|
@@ -2,82 +2,21 @@ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
|
2
2
|
import { ROW_NUMBER_KEY, DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_MAX_INCLUDE_LIMIT } from '../querying-writing/knex-constants.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Builds
|
|
5
|
+
* Builds the inner window-function query used to limit included children per
|
|
6
|
+
* parent. Mandatory target filters must be applied to this inner query before
|
|
7
|
+
* the caller wraps it and filters by ROW_NUMBER.
|
|
6
8
|
*
|
|
7
9
|
* @param {Object} knex - Knex instance
|
|
8
10
|
* @param {string} tableName - Target table name
|
|
9
11
|
* @param {string} foreignKey - Foreign key field
|
|
10
12
|
* @param {Array} parentIds - Parent record IDs
|
|
11
|
-
* @param {Array} fieldsToSelect - Fields to select
|
|
12
|
-
* @param {Object} includeConfig - Include
|
|
13
|
+
* @param {Array|string} fieldsToSelect - Fields to select, or '*'
|
|
14
|
+
* @param {Object} includeConfig - Include limit and ordering
|
|
13
15
|
* @param {Object} capabilities - Database capabilities
|
|
14
|
-
* @param {Object} scopeVars -
|
|
15
|
-
* @returns {Object}
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* // Input: Load max 3 comments per article, ordered by newest first
|
|
19
|
-
* const query = buildWindowedIncludeQuery(
|
|
20
|
-
* knex,
|
|
21
|
-
* 'comments',
|
|
22
|
-
* 'article_id',
|
|
23
|
-
* [1, 2, 3], // Article IDs
|
|
24
|
-
* ['id', 'text', 'created_at'],
|
|
25
|
-
* { limit: 3, orderBy: ['-created_at'] },
|
|
26
|
-
* { windowFunctions: true },
|
|
27
|
-
* { queryDefaultLimit: 10 }
|
|
28
|
-
* );
|
|
29
|
-
*
|
|
30
|
-
* // Generated SQL:
|
|
31
|
-
* // WITH _windowed AS (
|
|
32
|
-
* // SELECT id, text, created_at, article_id,
|
|
33
|
-
* // ROW_NUMBER() OVER (
|
|
34
|
-
* // PARTITION BY article_id
|
|
35
|
-
* // ORDER BY created_at DESC
|
|
36
|
-
* // ) as __$jsonrestapi_rn$__
|
|
37
|
-
* // FROM comments
|
|
38
|
-
* // WHERE article_id IN (1, 2, 3)
|
|
39
|
-
* // )
|
|
40
|
-
* // SELECT * FROM _windowed
|
|
41
|
-
* // WHERE __$jsonrestapi_rn$__ <= 3
|
|
42
|
-
*
|
|
43
|
-
* // Result: Each article gets max 3 comments, not 3 total
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* // Input: Database doesn't support window functions
|
|
47
|
-
* const query = buildWindowedIncludeQuery(
|
|
48
|
-
* knex,
|
|
49
|
-
* 'comments',
|
|
50
|
-
* 'article_id',
|
|
51
|
-
* [1, 2, 3],
|
|
52
|
-
* '*',
|
|
53
|
-
* { limit: 5 },
|
|
54
|
-
* { windowFunctions: false, dbInfo: { client: 'mysql', version: '5.7' } }
|
|
55
|
-
* );
|
|
56
|
-
*
|
|
57
|
-
* // Throws RestApiResourceError:
|
|
58
|
-
* // "Include limits require window function support. Your database (mysql 5.7)
|
|
59
|
-
* // does not support this feature. Window functions are supported in:
|
|
60
|
-
* // PostgreSQL 8.4+, MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+, SQL Server 2005+"
|
|
61
|
-
*
|
|
62
|
-
* @description
|
|
63
|
-
* Used by:
|
|
64
|
-
* - loadHasMany when strategy: 'window' and database supports window functions
|
|
65
|
-
* - Enables per-parent limits for one-to-many relationships
|
|
66
|
-
*
|
|
67
|
-
* Purpose:
|
|
68
|
-
* - Solves the "N+1 limit" problem where you want X records per parent
|
|
69
|
-
* - Without window functions, LIMIT 10 gives 10 total across all parents
|
|
70
|
-
* - With window functions, each parent gets up to 10 related records
|
|
71
|
-
* - Critical for consistent API responses with includes
|
|
72
|
-
*
|
|
73
|
-
* Data flow:
|
|
74
|
-
* 1. Creates subquery with ROW_NUMBER() partitioned by foreign key
|
|
75
|
-
* 2. Numbers rows within each partition based on orderBy
|
|
76
|
-
* 3. Outer query filters to keep only rows within limit
|
|
77
|
-
* 4. Returns query ready for execution
|
|
78
|
-
* 5. Caller removes the ROW_NUMBER column from results
|
|
16
|
+
* @param {Object} scopeVars - Resource query limits
|
|
17
|
+
* @returns {{ subquery: Object, effectiveLimit: number }} Inner query and validated limit
|
|
79
18
|
*/
|
|
80
|
-
export const
|
|
19
|
+
export const buildWindowedIncludeSubquery = (
|
|
81
20
|
knex,
|
|
82
21
|
tableName,
|
|
83
22
|
foreignKey,
|
|
@@ -137,13 +76,7 @@ export const buildWindowedIncludeQuery = (
|
|
|
137
76
|
subquery.select(fieldsWithFK)
|
|
138
77
|
}
|
|
139
78
|
|
|
140
|
-
|
|
141
|
-
const query = knex
|
|
142
|
-
.select('*')
|
|
143
|
-
.from(subquery.as('_windowed'))
|
|
144
|
-
.where(ROW_NUMBER_KEY, '<=', effectiveLimit)
|
|
145
|
-
|
|
146
|
-
return query
|
|
79
|
+
return { subquery, effectiveLimit }
|
|
147
80
|
}
|
|
148
81
|
|
|
149
82
|
/**
|
|
@@ -175,7 +108,7 @@ export const buildWindowedIncludeQuery = (
|
|
|
175
108
|
*
|
|
176
109
|
* @description
|
|
177
110
|
* Used by:
|
|
178
|
-
* -
|
|
111
|
+
* - buildWindowedIncludeSubquery for PARTITION BY ordering
|
|
179
112
|
* - applyStandardIncludeConfig for regular ORDER BY
|
|
180
113
|
* - Query builders that need consistent sort syntax
|
|
181
114
|
*
|
|
@@ -11,13 +11,12 @@
|
|
|
11
11
|
* Priority order:
|
|
12
12
|
* 1. context.urlPrefixOverride - Explicit override (from hooks or middleware)
|
|
13
13
|
* 2. context.urlPrefix - Pre-calculated URL prefix
|
|
14
|
-
* 3.
|
|
15
|
-
* 4. scope.vars.transport?.mountPath - Fallback to mount path
|
|
14
|
+
* 3. scope.vars.transport?.publicBaseUrl - Explicit public URL prefix
|
|
15
|
+
* 4. scope.vars.transport?.mountPath - Fallback to relative mount path
|
|
16
16
|
* 5. '' - Empty string as final fallback
|
|
17
17
|
*
|
|
18
18
|
* @param {Object} context - Request context (may have urlPrefixOverride or urlPrefix)
|
|
19
19
|
* @param {Object} scope - Scope/resource object with transport vars
|
|
20
|
-
* @param {Object} req - Optional Express request object for auto-calculation
|
|
21
20
|
* @returns {string} The final URL prefix to use
|
|
22
21
|
*
|
|
23
22
|
* @example
|
|
@@ -25,10 +24,10 @@
|
|
|
25
24
|
* const urlPrefix = getUrlPrefix(context, scope);
|
|
26
25
|
*
|
|
27
26
|
* @example
|
|
28
|
-
* // With
|
|
29
|
-
* const urlPrefix = getUrlPrefix({}, api
|
|
27
|
+
* // With explicit public base URL
|
|
28
|
+
* const urlPrefix = getUrlPrefix({}, { vars: { transport: { publicBaseUrl: 'https://api.example.com/api' } } });
|
|
30
29
|
*/
|
|
31
|
-
export function getUrlPrefix (context, scope
|
|
30
|
+
export function getUrlPrefix (context, scope) {
|
|
32
31
|
// Priority 1: Explicit override
|
|
33
32
|
if (context?.urlPrefixOverride) {
|
|
34
33
|
return context.urlPrefixOverride
|
|
@@ -39,21 +38,10 @@ export function getUrlPrefix (context, scope, req = null) {
|
|
|
39
38
|
return context.urlPrefix
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
// Priority 3:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
req.protocol ||
|
|
47
|
-
'http'
|
|
48
|
-
const host = req.get?.('x-forwarded-host') ||
|
|
49
|
-
req.headers?.['x-forwarded-host'] ||
|
|
50
|
-
req.get?.('host') ||
|
|
51
|
-
req.headers?.host
|
|
52
|
-
const mountPath = scope?.vars?.transport?.mountPath || ''
|
|
53
|
-
|
|
54
|
-
if (host) {
|
|
55
|
-
return `${protocol}://${host}${mountPath}`
|
|
56
|
-
}
|
|
41
|
+
// Priority 3: Explicit public URL prefix configured by the transport
|
|
42
|
+
const publicBaseUrl = scope?.vars?.transport?.publicBaseUrl
|
|
43
|
+
if (publicBaseUrl) {
|
|
44
|
+
return String(publicBaseUrl).replace(/\/+$/, '')
|
|
57
45
|
}
|
|
58
46
|
|
|
59
47
|
// Priority 4: Fallback to mount path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSchema } from 'json-rest-schema'
|
|
2
2
|
import { ensureSearchFieldsAreIndexed, generateSearchSchemaFromSchema, sortFieldsByDependencies } from './schema-helpers.js'
|
|
3
|
-
import { buildStorageInfo } from '../storage/storage-mapping.js'
|
|
3
|
+
import { buildStorageInfo, normalizeStorageConfig } from '../storage/storage-mapping.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Compiles and enriches schemas for a resource scope
|
|
@@ -155,7 +155,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
// Default enrichment: Add type to belongsTo fields
|
|
158
|
-
for (const [
|
|
158
|
+
for (const [, fieldDef] of Object.entries(enrichedFields)) {
|
|
159
159
|
if (fieldDef.belongsTo && !fieldDef.type) {
|
|
160
160
|
fieldDef.type = 'id'
|
|
161
161
|
}
|
|
@@ -174,7 +174,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
174
174
|
|
|
175
175
|
if (isProbablyPivot) {
|
|
176
176
|
// Make all belongsTo fields searchable for pivot operations
|
|
177
|
-
for (const [
|
|
177
|
+
for (const [, fieldDef] of belongsToFields) {
|
|
178
178
|
if (!fieldDef.search) {
|
|
179
179
|
fieldDef.search = true
|
|
180
180
|
}
|
|
@@ -206,6 +206,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
206
206
|
scope.scopeOptions.searchSchema
|
|
207
207
|
)
|
|
208
208
|
|
|
209
|
+
let searchSchemaObject
|
|
209
210
|
if (rawSearchFields) {
|
|
210
211
|
// Mark all search fields as indexed for database optimization.
|
|
211
212
|
// This ensures that any field used for filtering will have a database index created,
|
|
@@ -224,9 +225,9 @@ export async function compileSchemas (scope, deps) {
|
|
|
224
225
|
await runHooks('searchSchema:enrich', searchSchemaContext)
|
|
225
226
|
|
|
226
227
|
// Create searchSchema object
|
|
227
|
-
|
|
228
|
+
searchSchemaObject = createSchema(rawSearchFields)
|
|
228
229
|
} else {
|
|
229
|
-
|
|
230
|
+
searchSchemaObject = createSchema({})
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
// Build schemaRelationships including polymorphic fields from schema
|
|
@@ -317,9 +318,11 @@ export async function compileSchemas (scope, deps) {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
const idProperty = scope.scopeOptions.idProperty || scope.vars.idProperty || 'id'
|
|
321
|
+
const storage = normalizeStorageConfig(scope.scopeOptions.storage)
|
|
320
322
|
const storageInfo = buildStorageInfo({
|
|
321
323
|
schemaStructure: schemaObject.structure,
|
|
322
|
-
idProperty
|
|
324
|
+
idProperty,
|
|
325
|
+
storage
|
|
323
326
|
})
|
|
324
327
|
|
|
325
328
|
// Cache everything
|
|
@@ -335,6 +338,7 @@ export async function compileSchemas (scope, deps) {
|
|
|
335
338
|
schemaRelationships,
|
|
336
339
|
tableName: scope.scopeOptions.tableName || scopeName,
|
|
337
340
|
idProperty,
|
|
341
|
+
storage,
|
|
338
342
|
storageInfo,
|
|
339
343
|
indexes: Array.isArray(scope.scopeOptions.indexes) ? [...scope.scopeOptions.indexes] : [],
|
|
340
344
|
foreignKeys: Array.isArray(scope.scopeOptions.foreignKeys) ? [...scope.scopeOptions.foreignKeys] : [],
|
|
@@ -163,3 +163,54 @@ export function parseJsonApiQuery (queryString) {
|
|
|
163
163
|
|
|
164
164
|
return result
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
const hasSerializableValue = (value) => (
|
|
168
|
+
value !== undefined &&
|
|
169
|
+
value !== null &&
|
|
170
|
+
!(Array.isArray(value) && value.length === 0)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const stringifyQueryValue = (value) => {
|
|
174
|
+
if (value instanceof Date) return value.toISOString()
|
|
175
|
+
if (Array.isArray(value)) return value.map(stringifyQueryValue).join(',')
|
|
176
|
+
return String(value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const appendCommaList = (params, key, value) => {
|
|
180
|
+
if (!hasSerializableValue(value)) return
|
|
181
|
+
const serialized = Array.isArray(value)
|
|
182
|
+
? value.map(stringifyQueryValue).filter((entry) => entry.length > 0).join(',')
|
|
183
|
+
: stringifyQueryValue(value)
|
|
184
|
+
|
|
185
|
+
if (serialized) {
|
|
186
|
+
params.set(key, serialized)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const appendScopedParams = (params, publicName, values) => {
|
|
191
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return
|
|
192
|
+
|
|
193
|
+
for (const [key, value] of Object.entries(values)) {
|
|
194
|
+
if (!key || !hasSerializableValue(value)) continue
|
|
195
|
+
params.set(`${publicName}[${key}]`, stringifyQueryValue(value))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Serializes internal REST API query parameters to public JSON:API query string form.
|
|
201
|
+
*
|
|
202
|
+
* This is intentionally the inverse of parseJsonApiQuery for the supported transport
|
|
203
|
+
* contract: include, sort, filter[...], fields[...], and page[...].
|
|
204
|
+
*/
|
|
205
|
+
export function serializeJsonApiQuery (queryParams = {}, { page } = {}) {
|
|
206
|
+
const params = new URLSearchParams()
|
|
207
|
+
const effectivePage = page === undefined ? queryParams.page : page
|
|
208
|
+
|
|
209
|
+
appendCommaList(params, 'include', queryParams.include)
|
|
210
|
+
appendCommaList(params, 'sort', queryParams.sort)
|
|
211
|
+
appendScopedParams(params, 'filter', queryParams.filters || queryParams.filter)
|
|
212
|
+
appendScopedParams(params, 'fields', queryParams.fields)
|
|
213
|
+
appendScopedParams(params, 'page', effectivePage)
|
|
214
|
+
|
|
215
|
+
return params.toString()
|
|
216
|
+
}
|
|
@@ -63,7 +63,7 @@ const parseVersion = (versionString) => {
|
|
|
63
63
|
*
|
|
64
64
|
* @description
|
|
65
65
|
* Used by:
|
|
66
|
-
* -
|
|
66
|
+
* - buildWindowedIncludeSubquery to validate before using window functions
|
|
67
67
|
* - rest-api-knex-plugin to determine query strategies
|
|
68
68
|
*
|
|
69
69
|
* Purpose:
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* 4. Handles MySQL datetime format (no T separator)
|
|
49
49
|
* 5. Forces UTC interpretation to prevent timezone issues
|
|
50
50
|
*/
|
|
51
|
-
function normalizeDateValue (value, type) {
|
|
51
|
+
export function normalizeDateValue (value, type) {
|
|
52
52
|
// Handle null/undefined
|
|
53
53
|
if (value === null || value === undefined) {
|
|
54
54
|
return null
|
|
@@ -81,25 +81,34 @@ function normalizeDateValue (value, type) {
|
|
|
81
81
|
|
|
82
82
|
// Handle string values
|
|
83
83
|
if (typeof value === 'string') {
|
|
84
|
+
const trimmed = value.trim()
|
|
85
|
+
|
|
86
|
+
if (/^-?\d+$/.test(trimmed)) {
|
|
87
|
+
const numericDate = new Date(Number(trimmed))
|
|
88
|
+
if (!isNaN(numericDate.getTime())) {
|
|
89
|
+
return numericDate
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
// Detect MySQL datetime format: 'YYYY-MM-DD HH:MM:SS'
|
|
85
94
|
// These have no T separator and no timezone indicator
|
|
86
95
|
const isMySQLDateTime = type === 'dateTime' &&
|
|
87
|
-
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(
|
|
88
|
-
!
|
|
89
|
-
!
|
|
90
|
-
!
|
|
91
|
-
!
|
|
96
|
+
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(trimmed) &&
|
|
97
|
+
!trimmed.includes('T') &&
|
|
98
|
+
!trimmed.includes('Z') &&
|
|
99
|
+
!trimmed.includes('+') &&
|
|
100
|
+
!trimmed.includes('-', 10) // Don't match date separators
|
|
92
101
|
|
|
93
102
|
if (isMySQLDateTime) {
|
|
94
103
|
// Convert to ISO format and force UTC interpretation
|
|
95
104
|
// '2024-01-15 10:30:00' becomes '2024-01-15T10:30:00Z'
|
|
96
|
-
return new Date(
|
|
105
|
+
return new Date(trimmed.replace(' ', 'T') + 'Z')
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
// For date-only fields, ensure parsing at UTC midnight
|
|
100
109
|
// This prevents timezone shifts when parsing dates
|
|
101
|
-
if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(
|
|
102
|
-
return new Date(
|
|
110
|
+
if (type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
111
|
+
return new Date(trimmed + 'T00:00:00Z')
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
|
|
@@ -150,7 +150,8 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
150
150
|
const scopeSchemaInfo = scope?.vars?.schemaInfo || {}
|
|
151
151
|
const {
|
|
152
152
|
schemaInstance,
|
|
153
|
-
computed: computedFields = {}
|
|
153
|
+
computed: computedFields = {},
|
|
154
|
+
idProperty = 'id'
|
|
154
155
|
} = scopeSchemaInfo
|
|
155
156
|
let schemaStructure = scopeSchemaInfo.schemaStructure
|
|
156
157
|
const queryFields = scope?.vars?.queryFields || {}
|
|
@@ -196,6 +197,8 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
196
197
|
// Sparse fieldsets requested - only select specified fields
|
|
197
198
|
// Example: ?fields[products]=name,price,profit_margin
|
|
198
199
|
requested.forEach(field => {
|
|
200
|
+
if (field === 'id' || field === idProperty) return
|
|
201
|
+
|
|
199
202
|
if (queryFieldNames.has(field)) {
|
|
200
203
|
if (queryFields[field]?.hidden === true) return
|
|
201
204
|
queryFieldsToSelect.add(field)
|
|
@@ -257,11 +260,12 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
257
260
|
}
|
|
258
261
|
})
|
|
259
262
|
}
|
|
260
|
-
|
|
261
263
|
} else {
|
|
262
264
|
// No sparse fieldsets - return all visible fields
|
|
263
265
|
// This is the default behavior when no ?fields parameter is provided
|
|
264
266
|
Object.entries(schemaStructure).forEach(([field, fieldDef]) => {
|
|
267
|
+
if (field === 'id' || field === idProperty) return
|
|
268
|
+
|
|
265
269
|
// Skip virtual fields - they don't exist in database
|
|
266
270
|
if (fieldDef.virtual === true) return
|
|
267
271
|
|
|
@@ -283,7 +287,7 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
283
287
|
// So we need to include their dependencies even if normallyHidden
|
|
284
288
|
// Example: profit_margin depends on 'cost' which is normallyHidden
|
|
285
289
|
// We fetch 'cost' for calculation but don't return it in response
|
|
286
|
-
for (const [
|
|
290
|
+
for (const [, fieldDef] of Object.entries(computedFields)) {
|
|
287
291
|
if (fieldDef.dependencies) {
|
|
288
292
|
for (const dep of fieldDef.dependencies) {
|
|
289
293
|
const depFieldDef = schemaStructure[dep]
|