json-rest-api 1.0.29 → 2.1.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 +733 -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 +103 -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 +3838 -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 +37 -0
- package/index.js +3 -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 +70 -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 +586 -418
- 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 +120 -56
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- 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 +290 -2475
- 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/anyapi.d.ts +30 -0
- 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 +65 -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,162 +1,91 @@
|
|
|
1
|
-
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
1
|
+
import { RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
2
2
|
import { validateRelationshipDataCardinality } from '../querying-writing/relationship-contracts.js'
|
|
3
|
+
import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
|
|
4
|
+
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
5
|
+
|
|
6
|
+
export const lockRelationshipParent = async ({ context, helpers, scopeName }) => {
|
|
7
|
+
if (!context.transaction) return
|
|
8
|
+
const adapter = helpers.getStorageAdapter(scopeName)
|
|
9
|
+
const idColumn = adapter.getIdColumn()
|
|
10
|
+
const parent = () => adapter.buildBaseQuery({ transaction: context.transaction })
|
|
11
|
+
.where(idColumn, adapter.translateFilterValue('id', context.id))
|
|
12
|
+
// A row version change also rejects stale PostgreSQL repeatable-read writers.
|
|
13
|
+
const matched = await parent().update({ [idColumn]: context.transaction.ref(idColumn) })
|
|
14
|
+
// MySQL can report changed rows rather than matched rows when FOUND_ROWS is disabled.
|
|
15
|
+
if (!matched && !await parent().select(idColumn).forUpdate().first()) {
|
|
16
|
+
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const lockRelationshipTargets = async (api, transaction, identifiers) => {
|
|
21
|
+
if (!transaction) return identifiers
|
|
22
|
+
const targets = []
|
|
23
|
+
const resolved = []
|
|
24
|
+
const resources = new Map()
|
|
25
|
+
for (const { type, id } of identifiers) {
|
|
26
|
+
if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set() })
|
|
27
|
+
const { adapter, seen } = resources.get(type)
|
|
28
|
+
const value = adapter.translateFilterValue('id', id)
|
|
29
|
+
if (seen.has(String(value))) continue
|
|
30
|
+
seen.add(String(value))
|
|
31
|
+
targets.push({ type, id, value, adapter })
|
|
32
|
+
}
|
|
33
|
+
for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
|
|
34
|
+
const batch = targets.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
|
|
35
|
+
const byType = new Map()
|
|
36
|
+
for (const target of batch) {
|
|
37
|
+
if (!byType.has(target.type)) byType.set(target.type, [])
|
|
38
|
+
byType.get(target.type).push(target)
|
|
39
|
+
}
|
|
40
|
+
const found = new Map()
|
|
41
|
+
for (const [type, group] of byType) {
|
|
42
|
+
const { adapter } = group[0]
|
|
43
|
+
const idColumn = adapter.getIdColumn()
|
|
44
|
+
const rows = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
|
|
45
|
+
.whereIn(idColumn, group.map(target => target.value))
|
|
46
|
+
.orderBy(`${adapter.getTableName()}.${idColumn}`).forUpdate().select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
|
|
47
|
+
found.set(type, new Set(rows.map(row => String(row[idColumn]))))
|
|
48
|
+
}
|
|
49
|
+
for (const { type, id, value, adapter } of batch) {
|
|
50
|
+
let storedId = String(value)
|
|
51
|
+
// Let the database resolve alternate spellings under its column collation.
|
|
52
|
+
if (!found.get(type).has(storedId)) {
|
|
53
|
+
const record = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
|
|
54
|
+
.where(adapter.getIdColumn(), value).forUpdate().first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
|
|
55
|
+
if (!record) {
|
|
56
|
+
throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
|
|
57
|
+
subtype: 'not_found', resourceType: type, resourceId: id
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
storedId = String(record[adapter.getIdColumn()])
|
|
61
|
+
}
|
|
62
|
+
const identities = resources.get(type).resolved
|
|
63
|
+
if (!identities.has(storedId)) {
|
|
64
|
+
identities.add(storedId)
|
|
65
|
+
resolved.push({ type, id })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Return each database identity once, retaining its first submitted spelling.
|
|
70
|
+
return resolved
|
|
71
|
+
}
|
|
3
72
|
|
|
4
73
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* attributes: { title: 'My Article' },
|
|
17
|
-
* relationships: {
|
|
18
|
-
* author: {
|
|
19
|
-
* data: { type: 'users', id: '123' }
|
|
20
|
-
* },
|
|
21
|
-
* category: {
|
|
22
|
-
* data: { type: 'categories', id: '5' }
|
|
23
|
-
* }
|
|
24
|
-
* }
|
|
25
|
-
* }
|
|
26
|
-
* };
|
|
27
|
-
*
|
|
28
|
-
* // Schema has:
|
|
29
|
-
* // author_id: { belongsTo: 'users', as: 'author' }
|
|
30
|
-
* // category_id: { belongsTo: 'categories', as: 'category' }
|
|
31
|
-
*
|
|
32
|
-
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
33
|
-
*
|
|
34
|
-
* // Output: Foreign keys extracted
|
|
35
|
-
* // {
|
|
36
|
-
* // belongsToUpdates: {
|
|
37
|
-
* // author_id: '123',
|
|
38
|
-
* // category_id: '5'
|
|
39
|
-
* // },
|
|
40
|
-
* // manyToManyRelationships: []
|
|
41
|
-
* // }
|
|
42
|
-
*
|
|
43
|
-
* @example
|
|
44
|
-
* // Input: Polymorphic relationship
|
|
45
|
-
* const inputRecord = {
|
|
46
|
-
* data: {
|
|
47
|
-
* type: 'comments',
|
|
48
|
-
* relationships: {
|
|
49
|
-
* commentable: {
|
|
50
|
-
* data: { type: 'posts', id: '456' }
|
|
51
|
-
* }
|
|
52
|
-
* }
|
|
53
|
-
* }
|
|
54
|
-
* };
|
|
55
|
-
*
|
|
56
|
-
* // Schema relationships has:
|
|
57
|
-
* // commentable: {
|
|
58
|
-
* // belongsToPolymorphic: {
|
|
59
|
-
* // types: ['posts', 'videos'],
|
|
60
|
-
* // typeField: 'commentable_type',
|
|
61
|
-
* // idField: 'commentable_id'
|
|
62
|
-
* // }
|
|
63
|
-
* // }
|
|
64
|
-
*
|
|
65
|
-
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
66
|
-
*
|
|
67
|
-
* // Output: Both type and id fields set
|
|
68
|
-
* // {
|
|
69
|
-
* // belongsToUpdates: {
|
|
70
|
-
* // commentable_type: 'posts',
|
|
71
|
-
* // commentable_id: '456'
|
|
72
|
-
* // },
|
|
73
|
-
* // manyToManyRelationships: []
|
|
74
|
-
* // }
|
|
75
|
-
*
|
|
76
|
-
* @example
|
|
77
|
-
* // Input: Many-to-many relationship
|
|
78
|
-
* const inputRecord = {
|
|
79
|
-
* data: {
|
|
80
|
-
* type: 'articles',
|
|
81
|
-
* relationships: {
|
|
82
|
-
* tags: {
|
|
83
|
-
* data: [
|
|
84
|
-
* { type: 'tags', id: '10' },
|
|
85
|
-
* { type: 'tags', id: '20' }
|
|
86
|
-
* ]
|
|
87
|
-
* }
|
|
88
|
-
* }
|
|
89
|
-
* }
|
|
90
|
-
* };
|
|
91
|
-
*
|
|
92
|
-
* // Schema relationships has:
|
|
93
|
-
* // tags: {
|
|
94
|
-
* // type: 'manyToMany',
|
|
95
|
-
* // through: 'article_tags',
|
|
96
|
-
* // foreignKey: 'article_id',
|
|
97
|
-
* // otherKey: 'tag_id'
|
|
98
|
-
* // }
|
|
99
|
-
*
|
|
100
|
-
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
101
|
-
*
|
|
102
|
-
* // Output: Many-to-many data collected
|
|
103
|
-
* // {
|
|
104
|
-
* // belongsToUpdates: {},
|
|
105
|
-
* // manyToManyRelationships: [{
|
|
106
|
-
* // relName: 'tags',
|
|
107
|
-
* // relDef: {
|
|
108
|
-
* // through: 'article_tags',
|
|
109
|
-
* // foreignKey: 'article_id',
|
|
110
|
-
* // otherKey: 'tag_id'
|
|
111
|
-
* // },
|
|
112
|
-
* // relData: [
|
|
113
|
-
* // { type: 'tags', id: '10' },
|
|
114
|
-
* // { type: 'tags', id: '20' }
|
|
115
|
-
* // ]
|
|
116
|
-
* // }]
|
|
117
|
-
* // }
|
|
118
|
-
*
|
|
74
|
+
* Classify normalized linkage for the resource write lifecycle.
|
|
75
|
+
* Validates cardinality and polymorphic target types, then separates owner
|
|
76
|
+
* columns, referenced targets, pivot writes and reverse child writes. This
|
|
77
|
+
* helper prepares work; it does not write storage or establish authorization.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} scope - Resource with compiled schemaInfo in vars
|
|
80
|
+
* @param {Object} deps
|
|
81
|
+
* @param {Object} deps.context
|
|
82
|
+
* @param {Object} deps.context.inputRecord - Normalized JSON:API write document
|
|
83
|
+
* @returns {import('../../rest-api-plugin-methods/lifecycle-types.js').RelationshipPlan} belongsToUpdates map plus belongsToTargets,
|
|
84
|
+
* manyToManyRelationships and reverseRelationships arrays
|
|
119
85
|
* @example
|
|
120
|
-
* //
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* relationships: {
|
|
124
|
-
* author: { data: null }, // Clear belongsTo
|
|
125
|
-
* tags: { data: [] } // Clear many-to-many
|
|
126
|
-
* }
|
|
127
|
-
* }
|
|
128
|
-
* };
|
|
129
|
-
*
|
|
130
|
-
* const result = processRelationships(scope, { context: { inputRecord } });
|
|
131
|
-
*
|
|
132
|
-
* // Output: Null for belongsTo, empty array for many-to-many
|
|
133
|
-
* // {
|
|
134
|
-
* // belongsToUpdates: { author_id: null },
|
|
135
|
-
* // manyToManyRelationships: [{
|
|
136
|
-
* // relName: 'tags',
|
|
137
|
-
* // relDef: { ... },
|
|
138
|
-
* // relData: [] // Will delete all pivot records
|
|
139
|
-
* // }]
|
|
140
|
-
* // }
|
|
141
|
-
*
|
|
142
|
-
* @description
|
|
143
|
-
* Used by:
|
|
144
|
-
* - rest-api-knex-plugin's dataPut and dataPatch methods
|
|
145
|
-
* - Called before database writes to prepare relationship updates
|
|
146
|
-
*
|
|
147
|
-
* Purpose:
|
|
148
|
-
* - Converts JSON:API relationship format to database operations
|
|
149
|
-
* - Handles all relationship types: belongsTo, polymorphic, many-to-many
|
|
150
|
-
* - Validates polymorphic types against allowed values
|
|
151
|
-
* - Separates concerns: foreign keys vs pivot table operations
|
|
152
|
-
*
|
|
153
|
-
* Data flow:
|
|
154
|
-
* 1. Receives JSON:API document with relationships section
|
|
155
|
-
* 2. Analyzes schema to determine relationship types
|
|
156
|
-
* 3. For belongsTo: extracts foreign key values
|
|
157
|
-
* 4. For polymorphic: extracts both type and id fields
|
|
158
|
-
* 5. For many-to-many: collects data for pivot operations
|
|
159
|
-
* 6. Returns structured data for database updates
|
|
86
|
+
* // An explicit author clear produces belongsToUpdates.author_id = null.
|
|
87
|
+
* // An explicit empty tags array produces a pivot operation with relData: [].
|
|
88
|
+
* // Omitting relationships produces empty work collections, not implicit clears.
|
|
160
89
|
*/
|
|
161
90
|
export const processRelationships = (scope, deps) => {
|
|
162
91
|
// Extract values from scope
|
|
@@ -170,10 +99,12 @@ export const processRelationships = (scope, deps) => {
|
|
|
170
99
|
const { context } = deps
|
|
171
100
|
const { inputRecord } = context
|
|
172
101
|
const belongsToUpdates = {}
|
|
102
|
+
const belongsToTargets = []
|
|
173
103
|
const manyToManyRelationships = []
|
|
104
|
+
const reverseRelationships = []
|
|
174
105
|
|
|
175
106
|
if (!inputRecord.data.relationships) {
|
|
176
|
-
return { belongsToUpdates, manyToManyRelationships }
|
|
107
|
+
return { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships }
|
|
177
108
|
}
|
|
178
109
|
|
|
179
110
|
for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
|
|
@@ -201,6 +132,7 @@ export const processRelationships = (scope, deps) => {
|
|
|
201
132
|
belongsToUpdates[fieldName] = null
|
|
202
133
|
} else if (relData.data?.id) {
|
|
203
134
|
belongsToUpdates[fieldName] = relData.data.id
|
|
135
|
+
belongsToTargets.push(relData.data)
|
|
204
136
|
}
|
|
205
137
|
}
|
|
206
138
|
}
|
|
@@ -232,6 +164,7 @@ export const processRelationships = (scope, deps) => {
|
|
|
232
164
|
|
|
233
165
|
belongsToUpdates[typeField] = type
|
|
234
166
|
belongsToUpdates[idField] = id
|
|
167
|
+
belongsToTargets.push(relData.data)
|
|
235
168
|
}
|
|
236
169
|
}
|
|
237
170
|
|
|
@@ -240,6 +173,7 @@ export const processRelationships = (scope, deps) => {
|
|
|
240
173
|
manyToManyRelationships.push({
|
|
241
174
|
relName,
|
|
242
175
|
relDef: {
|
|
176
|
+
target: relDef.target,
|
|
243
177
|
through: relDef.through,
|
|
244
178
|
foreignKey: relDef.foreignKey,
|
|
245
179
|
otherKey: relDef.otherKey
|
|
@@ -247,8 +181,10 @@ export const processRelationships = (scope, deps) => {
|
|
|
247
181
|
relData: relData.data
|
|
248
182
|
})
|
|
249
183
|
}
|
|
250
|
-
|
|
184
|
+
if ((relDef?.type === 'hasOne' || relDef?.type === 'hasMany') && relData.data !== undefined) {
|
|
185
|
+
reverseRelationships.push({ relName, relDef, relData: relData.data })
|
|
186
|
+
}
|
|
251
187
|
}
|
|
252
188
|
|
|
253
|
-
return { belongsToUpdates, manyToManyRelationships }
|
|
189
|
+
return { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships }
|
|
254
190
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { RestApiResourceError, RestApiValidationError, RestApiVersionConflictError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
+
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
4
|
+
import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
|
|
5
|
+
|
|
6
|
+
export function hasVersionedInverse (api, relDef) {
|
|
7
|
+
const schemaInfo = api.resources[relDef.target]?.vars.schemaInfo
|
|
8
|
+
return Boolean(schemaInfo?.versionField && Object.values(schemaInfo.schemaRelationships).some(inverse =>
|
|
9
|
+
inverse.through === relDef.through && inverse.foreignKey === relDef.otherKey && inverse.otherKey === relDef.foreignKey))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertVersionedRelationshipTransaction (api, relDef, transaction) {
|
|
13
|
+
if (hasVersionedInverse(api, relDef) && (!transaction?.isTransaction || transaction.isCompleted())) {
|
|
14
|
+
throw new RestApiValidationError('Versioned relationship writes require an active transaction', { fields: ['transaction'] })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function invalidateManyToManyVersions (api, relDef, ids, transaction) {
|
|
19
|
+
if (!hasVersionedInverse(api, relDef) || !ids.length) return
|
|
20
|
+
const adapter = api.knex.helpers.getStorageAdapter(relDef.target)
|
|
21
|
+
const idColumn = adapter.getIdColumn()
|
|
22
|
+
const versionColumn = adapter.translateColumn(api.resources[relDef.target].vars.schemaInfo.versionField)
|
|
23
|
+
const values = [...new Set(ids.map(id => adapter.translateFilterValue('id', id)))]
|
|
24
|
+
for (let offset = 0; offset < values.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
|
|
25
|
+
const batch = values.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
|
|
26
|
+
const cases = []
|
|
27
|
+
const bindings = [idColumn]
|
|
28
|
+
for (const id of batch) {
|
|
29
|
+
cases.push('WHEN ? THEN ?')
|
|
30
|
+
bindings.push(id, randomUUID())
|
|
31
|
+
}
|
|
32
|
+
bindings.push(versionColumn)
|
|
33
|
+
const revisions = transaction.raw(`CASE ?? ${cases.join(' ')} ELSE ?? END`, bindings)
|
|
34
|
+
await adapter.buildBaseQuery({ transaction }).whereIn(idColumn, batch).update({ [versionColumn]: revisions })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readInverseReferences (state, context) {
|
|
39
|
+
const { adapter, fields } = state
|
|
40
|
+
const selectedFields = []
|
|
41
|
+
for (const field of fields) {
|
|
42
|
+
const column = adapter.translateColumn(field)
|
|
43
|
+
selectedFields.push([field, databaseIdentityExpression(context.transaction, column)])
|
|
44
|
+
}
|
|
45
|
+
const selection = Object.fromEntries(selectedFields)
|
|
46
|
+
return applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction: context.transaction })
|
|
47
|
+
.where(adapter.getIdColumn(), adapter.translateFilterValue('id', context.id)).forUpdate().first(selection))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Capture actual stored references after authorization; hooks/setters may change the input.
|
|
51
|
+
export async function captureInverseVersions ({ api, helpers, context, scopeName, isCreate = false }) {
|
|
52
|
+
const references = []
|
|
53
|
+
for (const [parentType, parent] of Object.entries(api.resources)) {
|
|
54
|
+
const schemaInfo = parent.vars.schemaInfo
|
|
55
|
+
if (!schemaInfo?.versionField) continue
|
|
56
|
+
for (const relationship of Object.values(schemaInfo.schemaRelationships)) {
|
|
57
|
+
if (!api.anyapi && relationship.type === 'manyToMany' && relationship.through === scopeName) {
|
|
58
|
+
references.push({ parentType, idField: relationship.foreignKey, valueField: relationship.otherKey })
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (!['hasMany', 'hasOne'].includes(relationship.type) || relationship.target !== scopeName) continue
|
|
62
|
+
if (relationship.via) {
|
|
63
|
+
const inverse = context.schemaInfo.schemaRelationships[relationship.via]?.belongsToPolymorphic
|
|
64
|
+
if (inverse?.types.includes(parentType)) references.push({ parentType, idField: inverse.idField, typeField: inverse.typeField })
|
|
65
|
+
} else if (relationship.foreignKey) references.push({ parentType, idField: relationship.foreignKey })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!references.length) return undefined
|
|
69
|
+
const fields = new Set()
|
|
70
|
+
for (const { idField, typeField, valueField } of references) {
|
|
71
|
+
if (idField) fields.add(idField)
|
|
72
|
+
if (typeField) fields.add(typeField)
|
|
73
|
+
if (valueField) fields.add(valueField)
|
|
74
|
+
}
|
|
75
|
+
const state = { references, fields: [...fields], adapter: helpers.getStorageAdapter(scopeName) }
|
|
76
|
+
state.before = isCreate ? undefined : await readInverseReferences(state, context)
|
|
77
|
+
return state
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function invalidateInverseVersions ({ state, context, helpers, api, isDelete = false }) {
|
|
81
|
+
if (!state) return
|
|
82
|
+
const after = isDelete ? undefined : await readInverseReferences(state, context)
|
|
83
|
+
const targets = new Map()
|
|
84
|
+
for (const { parentType, idField, typeField, valueField } of state.references) {
|
|
85
|
+
const readParentId = record => {
|
|
86
|
+
if (!record) return null
|
|
87
|
+
if (typeField && record[typeField] !== parentType) return null
|
|
88
|
+
return record[idField]
|
|
89
|
+
}
|
|
90
|
+
const oldId = readParentId(state.before)
|
|
91
|
+
const newId = readParentId(after)
|
|
92
|
+
const valueUnchanged = !valueField || state.before?.[valueField] === after?.[valueField]
|
|
93
|
+
if (oldId === newId && valueUnchanged) continue
|
|
94
|
+
for (const id of [oldId, newId]) {
|
|
95
|
+
if (id !== null && id !== undefined) targets.set(JSON.stringify([parentType, id]), { parentType, id })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Update parents in a stable order so concurrent writes acquire locks in the same order.
|
|
99
|
+
const orderedTargets = [...targets].sort(([left], [right]) => left.localeCompare(right))
|
|
100
|
+
for (const [, { parentType, id }] of orderedTargets) {
|
|
101
|
+
const adapter = helpers.getStorageAdapter(parentType)
|
|
102
|
+
const field = api.resources[parentType].vars.schemaInfo.versionField
|
|
103
|
+
// An old dangling reference or a parent deleted in this transaction needs no revision.
|
|
104
|
+
await adapter.buildBaseQuery({ transaction: context.transaction })
|
|
105
|
+
.where(adapter.getIdColumn(), adapter.translateFilterValue('id', id))
|
|
106
|
+
.update({ [adapter.translateColumn(field)]: randomUUID() })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Called after resource authorization, within the transaction containing the write.
|
|
111
|
+
export async function advanceResourceVersion ({ scopeName, context, helpers, expectedVersion, nextVersion = randomUUID() }) {
|
|
112
|
+
const field = context.schemaInfo.versionField
|
|
113
|
+
if (expectedVersion !== undefined && (!field || typeof expectedVersion !== 'string' || !expectedVersion.length || expectedVersion.length > 128)) {
|
|
114
|
+
throw new RestApiValidationError('expectedVersion requires a versioned resource and a non-empty token of at most 128 characters', { fields: ['expectedVersion'] })
|
|
115
|
+
}
|
|
116
|
+
if (!field) return undefined
|
|
117
|
+
if (!context.transaction) throw new Error('Versioned writes require a transaction')
|
|
118
|
+
|
|
119
|
+
const adapter = helpers.getStorageAdapter(scopeName)
|
|
120
|
+
const column = adapter.translateColumn(field)
|
|
121
|
+
const query = adapter.buildBaseQuery({ transaction: context.transaction })
|
|
122
|
+
.where(adapter.getIdColumn(), adapter.translateFilterValue('id', context.id))
|
|
123
|
+
if (expectedVersion !== undefined) {
|
|
124
|
+
// Revision tokens use byte equality, independent of column collation.
|
|
125
|
+
const client = context.transaction.client.config.client
|
|
126
|
+
if (client === 'pg') {
|
|
127
|
+
query.whereRaw("convert_to(??, 'UTF8') = convert_to(?, 'UTF8')", [column, expectedVersion])
|
|
128
|
+
} else if (client === 'mysql2' || client === 'mysql') {
|
|
129
|
+
query.whereRaw('CAST(?? AS BINARY) = CAST(? AS BINARY)', [column, expectedVersion])
|
|
130
|
+
} else if (client === 'better-sqlite3' || client === 'sqlite3') {
|
|
131
|
+
query.whereRaw('CAST(?? AS BLOB) = CAST(? AS BLOB)', [column, expectedVersion])
|
|
132
|
+
} else {
|
|
133
|
+
throw new Error(`Conditional revisions are not implemented for database client '${client}'`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const matched = await query.update({ [column]: nextVersion })
|
|
137
|
+
if (!matched) {
|
|
138
|
+
throw new RestApiVersionConflictError({ resourceType: scopeName, resourceId: context.id })
|
|
139
|
+
}
|
|
140
|
+
return nextVersion
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function initializeResourceVersion ({ context, expectedVersion }) {
|
|
144
|
+
const field = context.schemaInfo.versionField
|
|
145
|
+
if (expectedVersion !== undefined && (!field || typeof expectedVersion !== 'string' || !expectedVersion.length || expectedVersion.length > 128 || context.method === 'post')) {
|
|
146
|
+
throw new RestApiValidationError('expectedVersion requires an existing versioned resource and a non-empty token of at most 128 characters', { fields: ['expectedVersion'] })
|
|
147
|
+
}
|
|
148
|
+
if (!field) return undefined
|
|
149
|
+
const attributes = context.inputRecord?.data?.attributes
|
|
150
|
+
if (context.method !== 'delete' && attributes && Object.hasOwn(attributes, field)) {
|
|
151
|
+
throw new RestApiValidationError('The version field is managed by the library; use expectedVersion', { fields: [`data.attributes.${field}`] })
|
|
152
|
+
}
|
|
153
|
+
const state = { field, expectedVersion, nextVersion: randomUUID() }
|
|
154
|
+
const data = context.inputRecord?.data
|
|
155
|
+
if (context.method !== 'delete' && data && typeof data === 'object' && !Array.isArray(data) &&
|
|
156
|
+
(attributes === undefined || (attributes && typeof attributes === 'object' && !Array.isArray(attributes)))) {
|
|
157
|
+
context.inputRecord = { ...context.inputRecord, data: { ...data, attributes: { ...attributes, [field]: state.nextVersion } } }
|
|
158
|
+
}
|
|
159
|
+
return state
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function applyResourceVersion ({ state, context, helpers, scopeName, isCreate = false }) {
|
|
163
|
+
if (!state) return
|
|
164
|
+
if (isCreate) {
|
|
165
|
+
if (state.expectedVersion !== undefined) {
|
|
166
|
+
throw new RestApiResourceError(`Resource not found: ${scopeName}/${context.id}`, { subtype: 'not_found' })
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
await advanceResourceVersion({ scopeName, context, helpers, expectedVersion: state.expectedVersion, nextVersion: state.nextVersion })
|
|
170
|
+
}
|
|
171
|
+
if (context.method !== 'delete') context.inputRecord.data.attributes[state.field] = state.nextVersion
|
|
172
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
3
|
+
import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
|
|
4
|
+
import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
5
|
+
import { lockRelationshipTargets } from './relationship-processor.js'
|
|
6
|
+
|
|
7
|
+
// Read membership in bounded batches; each actual child write still goes through PATCH.
|
|
8
|
+
export async function updateReverseRelationship ({ api, helpers, context, scopeName, relDef, relData, operation = 'replace' }) {
|
|
9
|
+
const targetType = relDef.target
|
|
10
|
+
const targetScope = api.resources[targetType]
|
|
11
|
+
if (!targetScope) {
|
|
12
|
+
throw new RestApiResourceError(`Related resource type '${targetType}' not found`, { subtype: 'related_type_not_found' })
|
|
13
|
+
}
|
|
14
|
+
const schemaInfo = targetScope.vars.schemaInfo
|
|
15
|
+
let inverseName
|
|
16
|
+
let parentValues
|
|
17
|
+
if (relDef.via) {
|
|
18
|
+
const inverse = schemaInfo.schemaRelationships[relDef.via]?.belongsToPolymorphic
|
|
19
|
+
if (!inverse || !inverse.types.includes(scopeName)) {
|
|
20
|
+
throw new RestApiResourceError(`Invalid reverse polymorphic relationship '${relDef.via}' on '${targetType}'`, { subtype: 'invalid_via_relationship' })
|
|
21
|
+
}
|
|
22
|
+
inverseName = relDef.via
|
|
23
|
+
parentValues = { [inverse.typeField]: scopeName, [inverse.idField]: context.id }
|
|
24
|
+
} else {
|
|
25
|
+
const field = schemaInfo.schemaStructure[relDef.foreignKey]
|
|
26
|
+
if (!field) {
|
|
27
|
+
throw new RestApiResourceError(`Foreign key '${relDef.foreignKey}' not found on '${targetType}'`, { subtype: 'invalid_foreign_key' })
|
|
28
|
+
}
|
|
29
|
+
inverseName = field.belongsTo === scopeName ? field.as : undefined
|
|
30
|
+
parentValues = { [relDef.foreignKey]: context.id }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const adapter = helpers.getStorageAdapter(targetType)
|
|
34
|
+
const query = adapter.buildBaseQuery({ transaction: context.transaction })
|
|
35
|
+
for (const [field, value] of Object.entries(parentValues)) {
|
|
36
|
+
query.where(adapter.translateColumn(field), adapter.translateFilterValue(field, value))
|
|
37
|
+
}
|
|
38
|
+
if (context.transaction) query.forUpdate()
|
|
39
|
+
const idColumn = adapter.getIdColumn()
|
|
40
|
+
const selection = { [idColumn]: databaseIdentityExpression(context.db || api.knex.instance, idColumn) }
|
|
41
|
+
let identifiers = []
|
|
42
|
+
if (Array.isArray(relData)) {
|
|
43
|
+
identifiers = relData
|
|
44
|
+
} else if (relData !== null) {
|
|
45
|
+
identifiers = [relData]
|
|
46
|
+
}
|
|
47
|
+
const targets = operation === 'remove' ? identifiers : await lockRelationshipTargets(api, context.transaction, identifiers)
|
|
48
|
+
const requested = new Map()
|
|
49
|
+
for (const { id } of targets) {
|
|
50
|
+
const value = adapter.translateFilterValue('id', id)
|
|
51
|
+
if (!requested.has(String(value))) requested.set(String(value), { id: String(id), value })
|
|
52
|
+
}
|
|
53
|
+
const requestedIds = [...requested.values()]
|
|
54
|
+
|
|
55
|
+
const patchChild = async (id, linked) => {
|
|
56
|
+
const data = { type: targetType, id }
|
|
57
|
+
if (inverseName) {
|
|
58
|
+
data.relationships = { [inverseName]: { data: linked ? { type: scopeName, id: String(context.id) } : null } }
|
|
59
|
+
} else {
|
|
60
|
+
data.attributes = Object.fromEntries(Object.entries(parentValues).map(([field, value]) => [field, linked ? value : null]))
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await targetScope.patch({
|
|
64
|
+
id, document: { data }, transaction: context.transaction, format: 'jsonapi', returning: 'none'
|
|
65
|
+
}, { ...context })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!linked && operation === 'replace' && error.code === 'REST_API_RESOURCE' && ['not_found', 'forbidden'].includes(error.subtype)) {
|
|
68
|
+
const rejection = new RestApiResourceError('Cannot replace a relationship with inaccessible existing members', { subtype: 'forbidden' })
|
|
69
|
+
rejection.cause = error
|
|
70
|
+
throw rejection
|
|
71
|
+
}
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (operation === 'replace') {
|
|
77
|
+
// Exclude the whole keep-list once; independent exclusions would remove retained children.
|
|
78
|
+
const removals = query.clone().whereNot(builder => whereInIdentifiers(builder, idColumn, requestedIds.map(({ value }) => value)))
|
|
79
|
+
let afterId
|
|
80
|
+
while (true) {
|
|
81
|
+
const page = removals.clone().orderBy(`${adapter.getTableName()}.${idColumn}`).limit(RELATIONSHIP_WRITE_BATCH_SIZE + 1).select(selection)
|
|
82
|
+
if (afterId !== undefined) page.where(idColumn, '>', afterId)
|
|
83
|
+
const rows = await applyDatabaseReadOptions(page)
|
|
84
|
+
for (const row of rows) await patchChild(String(adapter.getFieldValue(row, 'id')), false)
|
|
85
|
+
if (rows.length <= RELATIONSHIP_WRITE_BATCH_SIZE) break
|
|
86
|
+
afterId = rows.at(-1)[idColumn]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Free all removed hasOne slots before assigning replacements.
|
|
91
|
+
for (let offset = 0; offset < requestedIds.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
|
|
92
|
+
const batch = requestedIds.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
|
|
93
|
+
const rows = await applyDatabaseReadOptions(query.clone().whereIn(idColumn, batch.map(({ value }) => value)).select(selection))
|
|
94
|
+
const existing = new Map(rows.map(row => [String(row[idColumn]), row]))
|
|
95
|
+
for (const { id, value } of batch) {
|
|
96
|
+
// Resolve alternate ID spellings using the database's equality rules.
|
|
97
|
+
let record = existing.get(String(value))
|
|
98
|
+
if (!record && existing.size) {
|
|
99
|
+
const lookup = query.clone().where(idColumn, value).first(selection)
|
|
100
|
+
record = await applyDatabaseReadOptions(lookup)
|
|
101
|
+
}
|
|
102
|
+
if (operation === 'remove') {
|
|
103
|
+
if (record) {
|
|
104
|
+
await patchChild(id, false)
|
|
105
|
+
existing.delete(String(record[idColumn]))
|
|
106
|
+
}
|
|
107
|
+
} else if (!record) await patchChild(id, true)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|