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
|
@@ -9,6 +9,21 @@ const clone = (value) => {
|
|
|
9
9
|
|
|
10
10
|
const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
|
|
11
11
|
|
|
12
|
+
const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
|
|
13
|
+
if (explicitIdProperty) return explicitIdProperty
|
|
14
|
+
if (schema.id) return 'id'
|
|
15
|
+
|
|
16
|
+
const idFields = Object.entries(schema)
|
|
17
|
+
.filter(([, fieldDef]) => (
|
|
18
|
+
fieldDef?.type === 'id' &&
|
|
19
|
+
!fieldDef.belongsTo &&
|
|
20
|
+
!fieldDef.belongsToPolymorphic
|
|
21
|
+
))
|
|
22
|
+
.map(([fieldName]) => fieldName)
|
|
23
|
+
|
|
24
|
+
return idFields.length === 1 ? idFields[0] : 'id'
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
|
|
13
28
|
if (fieldDef.as) return fieldDef.as
|
|
14
29
|
if (fieldName.endsWith('_id')) {
|
|
@@ -305,6 +320,7 @@ export class AnyapiRegistry {
|
|
|
305
320
|
|
|
306
321
|
async #register (definition, externalTrx) {
|
|
307
322
|
const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition
|
|
323
|
+
const idProperty = resolveIdProperty(schema, definition.idProperty)
|
|
308
324
|
const trx = externalTrx || await this.knex.transaction()
|
|
309
325
|
const managed = !externalTrx
|
|
310
326
|
|
|
@@ -520,7 +536,7 @@ export class AnyapiRegistry {
|
|
|
520
536
|
await trx('any_relationship_configs').insert(relationshipInserts)
|
|
521
537
|
}
|
|
522
538
|
|
|
523
|
-
const descriptor = await this.#loadDescriptor(tenant, resource, trx)
|
|
539
|
+
const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
|
|
524
540
|
|
|
525
541
|
if (managed) {
|
|
526
542
|
await trx.commit()
|
|
@@ -535,7 +551,7 @@ export class AnyapiRegistry {
|
|
|
535
551
|
}
|
|
536
552
|
}
|
|
537
553
|
|
|
538
|
-
async #loadDescriptor (tenant, resource, trx) {
|
|
554
|
+
async #loadDescriptor (tenant, resource, trx, options = {}) {
|
|
539
555
|
const query = (trx || this.knex)('any_resource_configs')
|
|
540
556
|
.where({ tenant_id: tenant, resource })
|
|
541
557
|
.first()
|
|
@@ -568,6 +584,7 @@ export class AnyapiRegistry {
|
|
|
568
584
|
slotState,
|
|
569
585
|
fieldRows,
|
|
570
586
|
relationshipRows,
|
|
587
|
+
idProperty: options.idProperty,
|
|
571
588
|
})
|
|
572
589
|
|
|
573
590
|
const canonicalFieldMap = {}
|
|
@@ -589,7 +606,7 @@ export class AnyapiRegistry {
|
|
|
589
606
|
return descriptor
|
|
590
607
|
}
|
|
591
608
|
|
|
592
|
-
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows }) {
|
|
609
|
+
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
|
|
593
610
|
const fields = {}
|
|
594
611
|
const reverseAttributes = {}
|
|
595
612
|
const belongsTo = {}
|
|
@@ -671,6 +688,7 @@ export class AnyapiRegistry {
|
|
|
671
688
|
return {
|
|
672
689
|
tenant,
|
|
673
690
|
resource,
|
|
691
|
+
idProperty: resolveIdProperty(schema, idProperty),
|
|
674
692
|
schema,
|
|
675
693
|
relationships,
|
|
676
694
|
canonical: DEFAULT_CANONICAL_CONFIG,
|
|
@@ -18,6 +18,7 @@ export const DEFAULT_CANONICAL_CONFIG = {
|
|
|
18
18
|
scopeName: 'anyapi_records',
|
|
19
19
|
tableName: 'any_records',
|
|
20
20
|
type: 'anyapi_records',
|
|
21
|
+
logicalIdColumn: 'logical_id',
|
|
21
22
|
tenantColumn: 'tenant_id',
|
|
22
23
|
resourceColumn: 'resource',
|
|
23
24
|
relationshipColumn: 'relationship',
|
|
@@ -34,20 +35,37 @@ const ensureTable = async (knex, tableName, tableBuilder) => {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
+
const ensureColumn = async (knex, tableName, columnName, columnBuilder) => {
|
|
39
|
+
const exists = await knex.schema.hasTable(tableName)
|
|
40
|
+
if (!exists) return
|
|
41
|
+
|
|
42
|
+
const hasColumn = await knex.schema.hasColumn(tableName, columnName)
|
|
43
|
+
if (!hasColumn) {
|
|
44
|
+
await knex.schema.table(tableName, columnBuilder)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
|
|
38
49
|
const exists = await knex.schema.hasTable(tableName)
|
|
39
50
|
if (!exists) return
|
|
40
51
|
const hasIndex = await knex.schema.withSchema('main').hasIndex?.(tableName, indexName)
|
|
41
52
|
if (!hasIndex) {
|
|
42
|
-
|
|
43
|
-
table
|
|
44
|
-
|
|
53
|
+
try {
|
|
54
|
+
await knex.schema.table(tableName, (table) => {
|
|
55
|
+
table.unique(columns, indexName)
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!String(error?.message || '').includes('already exists')) {
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export const ensureAnyApiSchema = async (knex) => {
|
|
49
66
|
await ensureTable(knex, 'any_records', (table) => {
|
|
50
67
|
table.increments('id').primary()
|
|
68
|
+
table.string('logical_id')
|
|
51
69
|
table.string('tenant_id').notNullable()
|
|
52
70
|
table.string('resource').notNullable()
|
|
53
71
|
|
|
@@ -77,9 +95,30 @@ export const ensureAnyApiSchema = async (knex) => {
|
|
|
77
95
|
table.dateTime('deleted_at')
|
|
78
96
|
|
|
79
97
|
table.index(['tenant_id', 'resource'])
|
|
98
|
+
table.unique(['tenant_id', 'resource', 'logical_id'], 'any_records_tenant_resource_logical_id_unique')
|
|
80
99
|
table.index(['resource'])
|
|
81
100
|
})
|
|
82
101
|
|
|
102
|
+
await ensureColumn(knex, 'any_records', 'logical_id', (table) => {
|
|
103
|
+
table.string('logical_id')
|
|
104
|
+
})
|
|
105
|
+
await ensureUniqueIndex(
|
|
106
|
+
knex,
|
|
107
|
+
'any_records',
|
|
108
|
+
['tenant_id', 'resource', 'logical_id'],
|
|
109
|
+
'any_records_tenant_resource_logical_id_unique'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const rowsMissingLogicalId = await knex('any_records')
|
|
113
|
+
.select('id')
|
|
114
|
+
.whereNull('logical_id')
|
|
115
|
+
|
|
116
|
+
for (const row of rowsMissingLogicalId) {
|
|
117
|
+
await knex('any_records')
|
|
118
|
+
.where({ id: row.id })
|
|
119
|
+
.update({ logical_id: String(row.id) })
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
await ensureTable(knex, 'any_links', (table) => {
|
|
84
123
|
table.increments('id').primary()
|
|
85
124
|
table.string('tenant_id').notNullable()
|
|
@@ -171,6 +210,7 @@ export const TYPE_TO_POOL = new Map([
|
|
|
171
210
|
['text', 'string'],
|
|
172
211
|
['uuid', 'string'],
|
|
173
212
|
['email', 'string'],
|
|
213
|
+
['file', 'string'],
|
|
174
214
|
['number', 'number'],
|
|
175
215
|
['integer', 'number'],
|
|
176
216
|
['float', 'number'],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RestApiValidationError } from '../../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { normalizeDateValue } from '../../querying-writing/database-value-normalizers.js'
|
|
2
3
|
|
|
3
4
|
export const normalizeId = (value) => (value === null || value === undefined ? null : String(value))
|
|
4
5
|
|
|
@@ -21,8 +22,12 @@ export const findSchemaFieldByAlias = (descriptor, alias) => {
|
|
|
21
22
|
|
|
22
23
|
export const resolveFieldInfo = (descriptor, field) => {
|
|
23
24
|
if (!descriptor) return null
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const idProperty = descriptor.idProperty || 'id'
|
|
26
|
+
if (field === 'id' || field === idProperty) {
|
|
27
|
+
return {
|
|
28
|
+
column: descriptor?.canonical?.logicalIdColumn || 'logical_id',
|
|
29
|
+
definition: { type: 'id' }
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const directField = descriptor.fields?.[field]
|
|
@@ -87,6 +92,10 @@ export const coerceValueForDefinition = (value, definition, { isRelationship } =
|
|
|
87
92
|
return String(value)
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
if (['date', 'dateTime', 'time'].includes(type)) {
|
|
96
|
+
return normalizeDateValue(value, type)
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
return value
|
|
91
100
|
}
|
|
92
101
|
|
|
@@ -763,7 +763,7 @@ function deriveSqliteColumnNumbers (dataType, args = '') {
|
|
|
763
763
|
async function introspectSqliteTableSnapshot (knex, { tableName, idColumn }) {
|
|
764
764
|
const quotedTableName = quoteSqliteIdentifier(tableName)
|
|
765
765
|
const tableRows = normalizeRows(await knex.raw(
|
|
766
|
-
|
|
766
|
+
'SELECT name, sql FROM sqlite_master WHERE type = \'table\' AND name = ? LIMIT 1',
|
|
767
767
|
[tableName]
|
|
768
768
|
))
|
|
769
769
|
const firstTableRow = Array.isArray(tableRows) ? tableRows[0] : null
|
|
@@ -103,13 +103,14 @@ function buildSetColumnType (values = []) {
|
|
|
103
103
|
return `set(${normalized.map((value) => quoteJsString(value)).join(', ')})`
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function createTableBuilderContext (schemaStructure = {}, idColumn = 'id') {
|
|
106
|
+
function createTableBuilderContext (schemaStructure = {}, idColumn = 'id', storage = undefined) {
|
|
107
107
|
return {
|
|
108
108
|
schemaStructure,
|
|
109
109
|
idProperty: idColumn,
|
|
110
110
|
storageInfo: buildStorageInfo({
|
|
111
111
|
schemaStructure,
|
|
112
|
-
idProperty: idColumn
|
|
112
|
+
idProperty: idColumn,
|
|
113
|
+
storage
|
|
113
114
|
})
|
|
114
115
|
}
|
|
115
116
|
}
|
|
@@ -400,7 +401,8 @@ function buildImplicitIdDefinition (idColumn) {
|
|
|
400
401
|
function resolveTableSchemaContext (schemaLike, options = {}) {
|
|
401
402
|
const schemaStructure = schemaLike?.structure || schemaLike || {}
|
|
402
403
|
const configuredIdColumn = options.idProperty || schemaStructure.id?.storage?.column || 'id'
|
|
403
|
-
const
|
|
404
|
+
const storage = options.storage ?? schemaLike?.storage
|
|
405
|
+
const tableContext = createTableBuilderContext(schemaStructure, configuredIdColumn, storage)
|
|
404
406
|
|
|
405
407
|
let resourceIdField = null
|
|
406
408
|
for (const [fieldName, definition] of Object.entries(schemaStructure)) {
|
|
@@ -888,6 +890,7 @@ export async function createKnexTable (knex, schemaInfo, tableSchemaInstance, op
|
|
|
888
890
|
const dialect = detectKnexDialect(knex)
|
|
889
891
|
const tableSchemaContext = resolveTableSchemaContext(tableSchemaInstance, {
|
|
890
892
|
idProperty: schemaInfo.idProperty,
|
|
893
|
+
storage: schemaInfo.storage,
|
|
891
894
|
tableName
|
|
892
895
|
})
|
|
893
896
|
|
|
@@ -918,6 +921,7 @@ export async function addKnexFields (knex, tableName, schema, options = {}) {
|
|
|
918
921
|
assertNoTopLevelTableMetadata(schema, 'addKnexFields')
|
|
919
922
|
const dialect = detectKnexDialect(knex)
|
|
920
923
|
const tableSchemaContext = resolveTableSchemaContext(schema, {
|
|
924
|
+
storage: options.storage,
|
|
921
925
|
...options,
|
|
922
926
|
tableName
|
|
923
927
|
})
|
|
@@ -936,6 +940,7 @@ export async function alterKnexFields (knex, tableName, fields, options = {}) {
|
|
|
936
940
|
assertNoTopLevelTableMetadata(fields, 'alterKnexFields')
|
|
937
941
|
const dialect = detectKnexDialect(knex)
|
|
938
942
|
const tableSchemaContext = resolveTableSchemaContext(fields, {
|
|
943
|
+
storage: options.storage,
|
|
939
944
|
...options,
|
|
940
945
|
tableName
|
|
941
946
|
})
|
|
@@ -961,6 +966,7 @@ export function generateKnexMigration (tableName, schema, options = {}) {
|
|
|
961
966
|
const { autoIncrement = true, timestamps = false, dialect = '' } = options
|
|
962
967
|
const tableSchemaContext = resolveTableSchemaContext(schema, {
|
|
963
968
|
...options,
|
|
969
|
+
storage: options.storage,
|
|
964
970
|
tableName
|
|
965
971
|
})
|
|
966
972
|
|
|
@@ -1028,6 +1034,7 @@ export function generateKnexMigrationDiff (tableName, currentSnapshot, schema, o
|
|
|
1028
1034
|
const resolvedDialect = normalizeText(dialect || currentSnapshot?.dialect).toLowerCase()
|
|
1029
1035
|
const tableSchemaContext = resolveTableSchemaContext(schema, {
|
|
1030
1036
|
...options,
|
|
1037
|
+
storage: options.storage,
|
|
1031
1038
|
tableName
|
|
1032
1039
|
})
|
|
1033
1040
|
const desiredColumns = buildDesiredColumnsMap(tableSchemaContext, { autoIncrement })
|
|
@@ -208,7 +208,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
208
208
|
let foundRelationship = null
|
|
209
209
|
let relationshipType = null
|
|
210
210
|
let relationshipField = null
|
|
211
|
-
let relationshipDef = null
|
|
212
211
|
|
|
213
212
|
if (currentRelationships) {
|
|
214
213
|
for (const [relName, relDef] of Object.entries(currentRelationships)) {
|
|
@@ -222,7 +221,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
222
221
|
foundRelationship = targetScope
|
|
223
222
|
relationshipType = 'hasManyPolymorphic'
|
|
224
223
|
relationshipField = { typeField, idField, via: relDef.via }
|
|
225
|
-
relationshipDef = relDef
|
|
226
224
|
log.trace('[BUILD-JOIN] Found polymorphic hasMany relationship:', {
|
|
227
225
|
relName, targetScope, via: relDef.via, typeField, idField
|
|
228
226
|
})
|
|
@@ -238,7 +236,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
238
236
|
foreignKey: relDef.foreignKey,
|
|
239
237
|
otherKey: relDef.otherKey
|
|
240
238
|
}
|
|
241
|
-
relationshipDef = relDef
|
|
242
239
|
log.trace('[BUILD-JOIN] Found many-to-many relationship:', {
|
|
243
240
|
relName,
|
|
244
241
|
targetScope,
|
|
@@ -256,7 +253,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
256
253
|
log.error('[BUILD-JOIN] Missing foreignKey in hasMany relationship:', { relName, currentScope })
|
|
257
254
|
throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${currentScope}'`)
|
|
258
255
|
}
|
|
259
|
-
relationshipDef = relDef
|
|
260
256
|
log.trace('[BUILD-JOIN] Found hasMany relationship:', { relName, targetScope, foreignKey: relationshipField })
|
|
261
257
|
break
|
|
262
258
|
}
|
|
@@ -269,7 +265,6 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
269
265
|
foundRelationship = targetScope
|
|
270
266
|
relationshipType = 'belongsTo'
|
|
271
267
|
relationshipField = fieldName
|
|
272
|
-
relationshipDef = fieldDef
|
|
273
268
|
log.trace('[BUILD-JOIN] Found belongsTo relationship:', { fieldName, targetScope })
|
|
274
269
|
break
|
|
275
270
|
}
|
|
@@ -282,9 +277,7 @@ export const buildJoinChain = async (scopes, log, fromScopeName, targetPath, sea
|
|
|
282
277
|
)
|
|
283
278
|
}
|
|
284
279
|
|
|
285
|
-
const sourceSchema = scopes[currentScope].vars.schemaInfo.schemaInstance
|
|
286
280
|
const sourceTableName = scopes[currentScope].vars.schemaInfo.tableName
|
|
287
|
-
const targetSchemaInstance = scopes[targetScope].vars.schemaInfo.schemaInstance
|
|
288
281
|
const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
|
|
289
282
|
|
|
290
283
|
if (relationshipType === 'manyToMany') {
|
|
@@ -350,37 +350,42 @@ export const toJsonApiRecord = (scope, record, scopeName) => {
|
|
|
350
350
|
export const buildJsonApiResponse = async (scope, records, included = [], isSingle = false, scopeName, context) => {
|
|
351
351
|
const {
|
|
352
352
|
vars: {
|
|
353
|
-
schemaInfo
|
|
353
|
+
schemaInfo
|
|
354
354
|
}
|
|
355
355
|
} = scope
|
|
356
356
|
|
|
357
|
-
const
|
|
357
|
+
const {
|
|
358
|
+
schemaInstance,
|
|
359
|
+
schemaRelationships: relationships
|
|
360
|
+
} = schemaInfo
|
|
358
361
|
|
|
359
362
|
const schemaStructure = getSchemaStructure(schemaInstance)
|
|
360
363
|
|
|
361
364
|
const processedRecords = records.map(record => {
|
|
362
365
|
const { [RELATIONSHIPS_KEY]: _relationships, ...cleanRecord } = record
|
|
366
|
+
const logicalRecord = translateRecordFromStorage(cleanRecord, schemaInfo)
|
|
363
367
|
const jsonApiRecord = toJsonApiRecord(scope, cleanRecord, scopeName)
|
|
368
|
+
const resourceId = jsonApiRecord.id
|
|
364
369
|
|
|
365
370
|
if (_relationships) {
|
|
366
371
|
jsonApiRecord.relationships = _relationships
|
|
367
372
|
}
|
|
368
373
|
|
|
369
374
|
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
370
|
-
if (fieldDef.belongsTo && fieldDef.as && fieldName in
|
|
375
|
+
if (fieldDef.belongsTo && fieldDef.as && fieldName in logicalRecord) {
|
|
371
376
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
372
377
|
if (!jsonApiRecord.relationships[fieldDef.as]) {
|
|
373
|
-
if (
|
|
378
|
+
if (logicalRecord[fieldName] !== null && logicalRecord[fieldName] !== undefined) {
|
|
374
379
|
const relationshipObject = {
|
|
375
380
|
data: {
|
|
376
381
|
type: fieldDef.belongsTo,
|
|
377
|
-
id: String(
|
|
382
|
+
id: String(logicalRecord[fieldName])
|
|
378
383
|
}
|
|
379
384
|
}
|
|
380
385
|
|
|
381
386
|
relationshipObject.links = {
|
|
382
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
383
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
387
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
388
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -390,8 +395,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
390
395
|
}
|
|
391
396
|
|
|
392
397
|
relationshipObject.links = {
|
|
393
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
394
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
398
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
399
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
395
400
|
}
|
|
396
401
|
|
|
397
402
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -402,8 +407,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
402
407
|
|
|
403
408
|
Object.entries(relationships || {}).forEach(([relName, relDef]) => {
|
|
404
409
|
if (relDef.belongsToPolymorphic) {
|
|
405
|
-
const typeValue =
|
|
406
|
-
const idValue =
|
|
410
|
+
const typeValue = logicalRecord[relDef.belongsToPolymorphic.typeField]
|
|
411
|
+
const idValue = logicalRecord[relDef.belongsToPolymorphic.idField]
|
|
407
412
|
|
|
408
413
|
if (typeValue && idValue) {
|
|
409
414
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
@@ -415,8 +420,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
415
420
|
}
|
|
416
421
|
|
|
417
422
|
relationshipObject.links = {
|
|
418
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
419
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
423
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
424
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
420
425
|
}
|
|
421
426
|
|
|
422
427
|
jsonApiRecord.relationships[relName] = relationshipObject
|
|
@@ -427,8 +432,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
427
432
|
}
|
|
428
433
|
|
|
429
434
|
relationshipObject.links = {
|
|
430
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
431
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
435
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
436
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
432
437
|
}
|
|
433
438
|
|
|
434
439
|
jsonApiRecord.relationships[relName] = relationshipObject
|
|
@@ -439,7 +444,6 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
439
444
|
return jsonApiRecord
|
|
440
445
|
})
|
|
441
446
|
|
|
442
|
-
const urlPrefix = context.urlPrefix || scope.vars.transport?.mountPath || ''
|
|
443
447
|
const normalizedData = isSingle ? processedRecords[0] : processedRecords
|
|
444
448
|
|
|
445
449
|
if (normalizedData) {
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { getFieldValue } from '../storage/storage-mapping.js'
|
|
2
|
+
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
3
|
+
|
|
4
|
+
const buildJsonApiLink = (baseUrl, queryParams, page) => {
|
|
5
|
+
const queryString = serializeJsonApiQuery(queryParams, { page })
|
|
6
|
+
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Calculates pagination metadata from query results
|
|
3
11
|
*
|
|
@@ -127,7 +135,7 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
127
135
|
* Purpose:
|
|
128
136
|
* - JSON:API spec requires pagination links for easy navigation
|
|
129
137
|
* - Preserves all other query parameters (filters, sorts, includes)
|
|
130
|
-
* -
|
|
138
|
+
* - Uses the shared JSON:API query serializer for transport-supported params
|
|
131
139
|
* - Only includes prev/next when applicable
|
|
132
140
|
*
|
|
133
141
|
* Data flow:
|
|
@@ -143,52 +151,20 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
143
151
|
const { page, pageCount, pageSize } = paginationMeta
|
|
144
152
|
const links = {}
|
|
145
153
|
|
|
146
|
-
const otherParams = Object.entries(queryParams)
|
|
147
|
-
.filter(([key]) => key !== 'page')
|
|
148
|
-
.flatMap(([key, value]) => {
|
|
149
|
-
if (Array.isArray(value)) {
|
|
150
|
-
return value.length === 0
|
|
151
|
-
? []
|
|
152
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
153
|
-
}
|
|
154
|
-
if (typeof value === 'object' && value !== null) {
|
|
155
|
-
const parts = []
|
|
156
|
-
const processObject = (obj, prefix) => {
|
|
157
|
-
Object.entries(obj).forEach(([k, v]) => {
|
|
158
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
159
|
-
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
160
|
-
processObject(v, newKey)
|
|
161
|
-
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
162
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
processObject(value, key)
|
|
167
|
-
return parts
|
|
168
|
-
}
|
|
169
|
-
if (value === undefined || value === null) {
|
|
170
|
-
return []
|
|
171
|
-
}
|
|
172
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
173
|
-
})
|
|
174
|
-
.filter(Boolean)
|
|
175
|
-
.join('&')
|
|
176
|
-
|
|
177
154
|
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
178
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
179
155
|
|
|
180
|
-
links.self =
|
|
156
|
+
links.self = buildJsonApiLink(baseUrl, queryParams, { number: page, size: pageSize })
|
|
181
157
|
|
|
182
158
|
if (pageCount !== undefined) {
|
|
183
|
-
links.first =
|
|
184
|
-
links.last =
|
|
159
|
+
links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
|
|
160
|
+
links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
|
|
185
161
|
|
|
186
162
|
if (page > 1) {
|
|
187
|
-
links.prev =
|
|
163
|
+
links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
|
|
188
164
|
}
|
|
189
165
|
|
|
190
166
|
if (page < pageCount) {
|
|
191
|
-
links.next =
|
|
167
|
+
links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
|
|
192
168
|
}
|
|
193
169
|
}
|
|
194
170
|
|
|
@@ -257,11 +233,13 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
257
233
|
* 4. Cursor used in 'next' link for fetching subsequent pages
|
|
258
234
|
* 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
|
|
259
235
|
*/
|
|
260
|
-
export const createCursor = (record, sortFields = ['id']) => {
|
|
236
|
+
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
261
237
|
const parts = []
|
|
262
238
|
sortFields.forEach(field => {
|
|
263
|
-
|
|
264
|
-
|
|
239
|
+
const value = schemaInfo
|
|
240
|
+
? getFieldValue(record, schemaInfo, field)
|
|
241
|
+
: record[field]
|
|
242
|
+
if (value !== undefined) {
|
|
265
243
|
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
266
244
|
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
267
245
|
}
|
|
@@ -427,60 +405,36 @@ export const parseCursor = (cursor) => {
|
|
|
427
405
|
* 4. Added to response.links for navigation
|
|
428
406
|
* 5. Client uses next link to fetch subsequent pages
|
|
429
407
|
*/
|
|
430
|
-
export const generateCursorPaginationLinks = (
|
|
408
|
+
export const generateCursorPaginationLinks = (
|
|
409
|
+
urlPrefix,
|
|
410
|
+
scopeName,
|
|
411
|
+
queryParams,
|
|
412
|
+
records,
|
|
413
|
+
pageSize,
|
|
414
|
+
hasMore,
|
|
415
|
+
sortFields = ['id'],
|
|
416
|
+
{ schemaInfo = null } = {}
|
|
417
|
+
) => {
|
|
431
418
|
// Allow empty urlPrefix to generate relative links
|
|
432
419
|
if (!records.length) return null
|
|
433
420
|
|
|
434
421
|
const links = {}
|
|
435
422
|
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
436
|
-
|
|
437
|
-
const otherParams = Object.entries(queryParams)
|
|
438
|
-
.filter(([key]) => key !== 'page')
|
|
439
|
-
.flatMap(([key, value]) => {
|
|
440
|
-
if (Array.isArray(value)) {
|
|
441
|
-
return value.length === 0
|
|
442
|
-
? []
|
|
443
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
444
|
-
}
|
|
445
|
-
if (typeof value === 'object' && value !== null) {
|
|
446
|
-
const parts = []
|
|
447
|
-
const processObject = (obj, prefix) => {
|
|
448
|
-
Object.entries(obj).forEach(([k, v]) => {
|
|
449
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
450
|
-
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
451
|
-
processObject(v, newKey)
|
|
452
|
-
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
453
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
454
|
-
}
|
|
455
|
-
})
|
|
456
|
-
}
|
|
457
|
-
processObject(value, key)
|
|
458
|
-
return parts
|
|
459
|
-
}
|
|
460
|
-
if (value === undefined || value === null) {
|
|
461
|
-
return []
|
|
462
|
-
}
|
|
463
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
464
|
-
})
|
|
465
|
-
.filter(Boolean)
|
|
466
|
-
.join('&')
|
|
467
|
-
|
|
468
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
423
|
+
const selfPage = { size: pageSize }
|
|
469
424
|
|
|
470
425
|
if (queryParams.page?.after) {
|
|
471
|
-
|
|
426
|
+
selfPage.after = queryParams.page.after
|
|
472
427
|
} else if (queryParams.page?.before) {
|
|
473
|
-
|
|
474
|
-
} else {
|
|
475
|
-
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
|
|
428
|
+
selfPage.before = queryParams.page.before
|
|
476
429
|
}
|
|
477
430
|
|
|
478
|
-
links.
|
|
431
|
+
links.self = buildJsonApiLink(baseUrl, queryParams, selfPage)
|
|
432
|
+
links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
|
|
479
433
|
|
|
480
434
|
if (hasMore && records.length > 0) {
|
|
481
435
|
const lastRecord = records[records.length - 1]
|
|
482
|
-
const nextCursor = createCursor(lastRecord, sortFields)
|
|
483
|
-
links.next =
|
|
436
|
+
const nextCursor = createCursor(lastRecord, sortFields, { schemaInfo })
|
|
437
|
+
links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, after: nextCursor })
|
|
484
438
|
}
|
|
485
439
|
|
|
486
440
|
return links
|
|
@@ -543,7 +497,7 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
|
|
|
543
497
|
* 3. Added to response.meta.pagination
|
|
544
498
|
* 4. Clients can use cursor directly or use the next link
|
|
545
499
|
*/
|
|
546
|
-
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
|
|
500
|
+
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
547
501
|
const meta = {
|
|
548
502
|
pageSize,
|
|
549
503
|
hasMore
|
|
@@ -552,7 +506,7 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
|
|
|
552
506
|
if (hasMore && records.length > 0) {
|
|
553
507
|
const lastRecord = records[records.length - 1]
|
|
554
508
|
meta.cursor = {
|
|
555
|
-
next: createCursor(lastRecord, sortFields)
|
|
509
|
+
next: createCursor(lastRecord, sortFields, { schemaInfo })
|
|
556
510
|
}
|
|
557
511
|
}
|
|
558
512
|
|
|
@@ -191,7 +191,7 @@ export const polymorphicFiltersHook = async (hookParams, dependencies) => {
|
|
|
191
191
|
// Step 2: Build polymorphic JOINs
|
|
192
192
|
log.trace('[POLYMORPHIC-SEARCH] Building JOINs for polymorphic searches')
|
|
193
193
|
|
|
194
|
-
for (const [
|
|
194
|
+
for (const [, searchInfo] of polymorphicSearches) {
|
|
195
195
|
const { fieldDef, polymorphicField } = searchInfo
|
|
196
196
|
|
|
197
197
|
// Get the relationship definition
|
|
@@ -485,7 +485,6 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
const schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
488
|
-
const tableName = schemaInfo.tableName
|
|
489
488
|
const tableAlias = adapterUtils.defaultAliasForScope(scopeName)
|
|
490
489
|
const aliasScopeMap = new Map()
|
|
491
490
|
aliasScopeMap.set(tableAlias, scopeName)
|
|
@@ -730,7 +729,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
730
729
|
fieldDef.applyFilter.call(this, this, filterValue)
|
|
731
730
|
break
|
|
732
731
|
|
|
733
|
-
default:
|
|
732
|
+
default: {
|
|
734
733
|
const targetField = fieldDef.actualField || filterKey
|
|
735
734
|
const columnRef = resolveFieldColumn(targetField)
|
|
736
735
|
const operator = resolveSearchOperator(fieldDef)
|
|
@@ -738,6 +737,7 @@ export const crossTableFiltersHook = async (hookParams, dependencies) => {
|
|
|
738
737
|
applyWhereForOperator({ builder: this, columnRef, operator, value: normalized, knex })
|
|
739
738
|
break
|
|
740
739
|
}
|
|
740
|
+
}
|
|
741
741
|
}
|
|
742
742
|
})
|
|
743
743
|
}
|
|
@@ -891,8 +891,6 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
|
891
891
|
const scopeName = hookParams.context?.knexQuery?.scopeName
|
|
892
892
|
const filters = hookParams.context?.knexQuery?.filters
|
|
893
893
|
const query = hookParams.context?.knexQuery?.query
|
|
894
|
-
const db = hookParams.context?.knexQuery?.db || knex
|
|
895
|
-
|
|
896
894
|
if (!scopeName || !filters || !scopes[scopeName]) {
|
|
897
895
|
return
|
|
898
896
|
}
|
|
@@ -993,7 +991,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
|
993
991
|
fieldDef.applyFilter.call(this, this, filterValue)
|
|
994
992
|
break
|
|
995
993
|
|
|
996
|
-
default:
|
|
994
|
+
default: {
|
|
997
995
|
// Standard filtering
|
|
998
996
|
const actualField = fieldDef.actualField || filterKey
|
|
999
997
|
// Always qualify field names
|
|
@@ -1005,6 +1003,7 @@ export const basicFiltersHook = async (hookParams, dependencies) => {
|
|
|
1005
1003
|
applyWhereForOperator({ builder: this, columnRef: dbField, operator, value: normalized, knex })
|
|
1006
1004
|
break
|
|
1007
1005
|
}
|
|
1006
|
+
}
|
|
1008
1007
|
}
|
|
1009
1008
|
})
|
|
1010
1009
|
}
|