velocious 1.0.445 → 1.0.447
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 +1 -1
- package/build/configuration-types.js +2 -2
- package/build/database/pool/async-tracked-multi-connection.js +3 -1
- package/build/database/record/index.js +38 -38
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
- package/build/frontend-model-controller.js +44 -12
- package/build/frontend-model-resource/base-resource.js +519 -129
- package/build/frontend-models/base.js +417 -203
- package/build/frontend-models/preloader.js +7 -7
- package/build/frontend-models/query.js +18 -18
- package/build/frontend-models/use-created-event.js +1 -1
- package/build/frontend-models/use-destroyed-event.js +1 -1
- package/build/frontend-models/use-model-class-event.js +1 -1
- package/build/frontend-models/use-updated-event.js +1 -1
- package/build/frontend-models/websocket-channel.js +39 -3
- package/build/routes/resolver.js +17 -14
- package/build/src/configuration-types.d.ts +6 -6
- package/build/src/configuration-types.js +3 -3
- package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
- package/build/src/database/pool/async-tracked-multi-connection.js +5 -2
- package/build/src/database/record/index.d.ts +38 -38
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +39 -39
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +13 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +59 -2
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +74 -0
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +155 -1
- package/build/src/frontend-model-controller.d.ts +2 -1
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +38 -14
- package/build/src/frontend-model-resource/base-resource.d.ts +196 -21
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +467 -112
- package/build/src/frontend-models/base.d.ts +232 -149
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +371 -201
- package/build/src/frontend-models/preloader.d.ts +10 -10
- package/build/src/frontend-models/preloader.d.ts.map +1 -1
- package/build/src/frontend-models/preloader.js +8 -8
- package/build/src/frontend-models/query.d.ts +8 -8
- package/build/src/frontend-models/query.d.ts.map +1 -1
- package/build/src/frontend-models/query.js +19 -19
- package/build/src/frontend-models/use-created-event.d.ts +2 -2
- package/build/src/frontend-models/use-created-event.d.ts.map +1 -1
- package/build/src/frontend-models/use-created-event.js +2 -2
- package/build/src/frontend-models/use-destroyed-event.d.ts +1 -1
- package/build/src/frontend-models/use-destroyed-event.d.ts.map +1 -1
- package/build/src/frontend-models/use-destroyed-event.js +2 -2
- package/build/src/frontend-models/use-model-class-event.d.ts +1 -1
- package/build/src/frontend-models/use-model-class-event.d.ts.map +1 -1
- package/build/src/frontend-models/use-model-class-event.js +2 -2
- package/build/src/frontend-models/use-updated-event.d.ts +1 -1
- package/build/src/frontend-models/use-updated-event.d.ts.map +1 -1
- package/build/src/frontend-models/use-updated-event.js +2 -2
- package/build/src/frontend-models/websocket-channel.d.ts +8 -0
- package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
- package/build/src/frontend-models/websocket-channel.js +35 -4
- package/build/src/routes/resolver.d.ts.map +1 -1
- package/build/src/routes/resolver.js +7 -4
- package/build/src/utils/model-scope.d.ts +4 -4
- package/build/src/utils/model-scope.d.ts.map +1 -1
- package/build/src/utils/model-scope.js +3 -3
- package/build/src/utils/ransack.d.ts +1 -1
- package/build/src/utils/ransack.d.ts.map +1 -1
- package/build/src/utils/ransack.js +2 -2
- package/build/utils/model-scope.js +2 -2
- package/build/utils/ransack.js +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +2 -2
- package/src/database/pool/async-tracked-multi-connection.js +3 -1
- package/src/database/record/index.js +38 -38
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
- package/src/frontend-model-controller.js +44 -12
- package/src/frontend-model-resource/base-resource.js +519 -129
- package/src/frontend-models/base.js +417 -203
- package/src/frontend-models/preloader.js +7 -7
- package/src/frontend-models/query.js +18 -18
- package/src/frontend-models/use-created-event.js +1 -1
- package/src/frontend-models/use-destroyed-event.js +1 -1
- package/src/frontend-models/use-model-class-event.js +1 -1
- package/src/frontend-models/use-updated-event.js +1 -1
- package/src/frontend-models/websocket-channel.js +39 -3
- package/src/routes/resolver.js +17 -14
- package/src/utils/model-scope.js +2 -2
- package/src/utils/ransack.js +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import AuthorizationBaseResource from "../authorization/base-resource.js"
|
|
4
4
|
import * as inflection from "inflection"
|
|
5
|
+
import isPlainObject from "../utils/plain-object.js"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* FrontendModelResourceControllerArgs type.
|
|
@@ -25,6 +26,15 @@ import * as inflection from "inflection"
|
|
|
25
26
|
* @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | import("../configuration-types.js").FrontendModelResourceConfiguration} [resourceConfiguration] - Optional normalized resource configuration.
|
|
26
27
|
*/
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolved frontend-model resource registration.
|
|
31
|
+
* @typedef {object} FrontendModelResolvedResourceConfiguration
|
|
32
|
+
* @property {import("../configuration-types.js").BackendProjectConfiguration} backendProject - Backend project owning the resource.
|
|
33
|
+
* @property {string} modelName - Frontend model name.
|
|
34
|
+
* @property {import("../configuration-types.js").FrontendModelResourceClassType} resourceClass - Resource class.
|
|
35
|
+
* @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} resourceConfiguration - Normalized resource configuration.
|
|
36
|
+
*/
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* Base class for backend frontend-model resources.
|
|
30
40
|
* @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
|
|
@@ -320,16 +330,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
320
330
|
/**
|
|
321
331
|
* Runs create.
|
|
322
332
|
* @param {Record<string, ?>} attributes - Create attributes.
|
|
323
|
-
* @param {{controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
|
|
333
|
+
* @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
|
|
324
334
|
* @returns {Promise<import("../database/record/index.js").default>} - Created model.
|
|
325
335
|
*/
|
|
326
336
|
async create(attributes, options = {}) {
|
|
337
|
+
const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
|
|
327
338
|
const permit = parsePermittedParams(this.permittedParams({action: "create", ability: this.ability, locals: this.locals, params: attributes}))
|
|
328
|
-
const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, attributes, this, permit.attributes)
|
|
339
|
+
const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, attachmentSplit.attributes, this, permit.attributes)
|
|
329
340
|
const ModelClass = this.modelClass()
|
|
330
341
|
const model = new ModelClass()
|
|
331
342
|
|
|
332
|
-
return await this._saveWithNestedAttributes({filtered, model, options, permit})
|
|
343
|
+
return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
|
|
333
344
|
}
|
|
334
345
|
|
|
335
346
|
/**
|
|
@@ -345,24 +356,31 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
345
356
|
* Runs update.
|
|
346
357
|
* @param {import("../database/record/index.js").default} model - Existing model.
|
|
347
358
|
* @param {Record<string, ?>} attributes - Update attributes.
|
|
348
|
-
* @param {{controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
|
|
359
|
+
* @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
|
|
349
360
|
* @returns {Promise<import("../database/record/index.js").default>} - Updated model.
|
|
350
361
|
*/
|
|
351
362
|
async update(model, attributes, options = {}) {
|
|
363
|
+
const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
|
|
352
364
|
const permit = parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: attributes}))
|
|
353
|
-
const filtered = filterWritableFrontendModelAttributes(model, attributes, this, permit.attributes)
|
|
365
|
+
const filtered = filterWritableFrontendModelAttributes(model, attachmentSplit.attributes, this, permit.attributes)
|
|
354
366
|
|
|
355
|
-
return await this._saveWithNestedAttributes({filtered, model, options, permit})
|
|
367
|
+
return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
|
|
356
368
|
}
|
|
357
369
|
|
|
358
370
|
/**
|
|
359
371
|
* Saves a model and applies nested attributes in one transaction.
|
|
360
|
-
* @param {{filtered: Record<string, ?>, model: import("../database/record/index.js").default, options: {controller?: ?, nestedAttributes?: Record<string, ?> | null}, permit: {attributes: string[], nested: Record<string, ?>}}} args - Save arguments.
|
|
372
|
+
* @param {{filtered: Record<string, ?>, model: import("../database/record/index.js").default, options: {attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}, permit: {attributes: string[], nested: Record<string, ?>}}} args - Save arguments.
|
|
361
373
|
* @returns {Promise<import("../database/record/index.js").default>} - Saved model.
|
|
362
374
|
*/
|
|
363
375
|
async _saveWithNestedAttributes({filtered, model, options, permit}) {
|
|
364
376
|
await this.modelClass().transaction(async () => {
|
|
365
377
|
await this._assignWithVirtualSetters(model, filtered)
|
|
378
|
+
this._assignAttachments(model, options.attachments ?? null, permit.attributes)
|
|
379
|
+
|
|
380
|
+
if (options.nestedAttributes) {
|
|
381
|
+
await this._applyBelongsToNestedAttributes(model, options.nestedAttributes, options.controller || null, permit)
|
|
382
|
+
}
|
|
383
|
+
|
|
366
384
|
await model.save()
|
|
367
385
|
|
|
368
386
|
if (options.nestedAttributes) {
|
|
@@ -415,6 +433,85 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
415
433
|
}
|
|
416
434
|
}
|
|
417
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Splits attachment-named attributes into the attachment payload while preserving legacy callers
|
|
438
|
+
* that submitted attachments as normal frontend-model attributes.
|
|
439
|
+
* @param {Record<string, ?>} attributes - Incoming mutation attributes.
|
|
440
|
+
* @param {Record<string, ?> | null} attachments - Explicit attachment payload.
|
|
441
|
+
* @returns {{attributes: Record<string, ?>, attachments: Record<string, ?> | null}} Attributes with attachment keys removed and merged attachment payload.
|
|
442
|
+
*/
|
|
443
|
+
_extractAttachmentAttributes(attributes, attachments) {
|
|
444
|
+
const attachmentDefinitions = this.modelClass().getAttachmentsMap?.() || {}
|
|
445
|
+
const attachmentNames = new Set(Object.keys(attachmentDefinitions))
|
|
446
|
+
|
|
447
|
+
if (attachmentNames.size === 0) return {attributes, attachments}
|
|
448
|
+
|
|
449
|
+
if (attachments !== null && !isPlainObject(attachments)) {
|
|
450
|
+
throw new Error("Expected attachments to be an object.")
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** @type {Record<string, ?>} */
|
|
454
|
+
const regularAttributes = {}
|
|
455
|
+
/** @type {Record<string, ?> | null} */
|
|
456
|
+
let mergedAttachments = attachments ? {...attachments} : null
|
|
457
|
+
|
|
458
|
+
for (const [attributeName, value] of Object.entries(attributes)) {
|
|
459
|
+
if (!attachmentNames.has(attributeName)) {
|
|
460
|
+
regularAttributes[attributeName] = value
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!mergedAttachments) mergedAttachments = {}
|
|
465
|
+
if (Object.prototype.hasOwnProperty.call(mergedAttachments, attributeName)) {
|
|
466
|
+
throw new Error(`Attachment '${attributeName}' was submitted in both attributes and attachments.`)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
mergedAttachments[attributeName] = value
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return {attributes: regularAttributes, attachments: mergedAttachments}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Queues attachment payloads on a model after validating permits and attachment definitions.
|
|
477
|
+
* @param {import("../database/record/index.js").default} model - Model receiving attachments.
|
|
478
|
+
* @param {Record<string, ?> | null} attachments - Attachments keyed by attachment name.
|
|
479
|
+
* @param {string[]} permittedAttributeNames - Attribute/attachment names permitted by the resource.
|
|
480
|
+
* @returns {void}
|
|
481
|
+
*/
|
|
482
|
+
_assignAttachments(model, attachments, permittedAttributeNames) {
|
|
483
|
+
if (!attachments) return
|
|
484
|
+
if (!isPlainObject(attachments)) throw new Error("Expected attachments to be an object.")
|
|
485
|
+
|
|
486
|
+
const permitSet = new Set(permittedAttributeNames)
|
|
487
|
+
const modelClass = model.getModelClass()
|
|
488
|
+
const attachmentDefinitions = modelClass.getAttachmentsMap?.() || {}
|
|
489
|
+
/** @type {string[]} */
|
|
490
|
+
const notPermittedAttachments = []
|
|
491
|
+
/** @type {string[]} */
|
|
492
|
+
const invalidAttachments = []
|
|
493
|
+
|
|
494
|
+
for (const [attachmentName, value] of Object.entries(attachments)) {
|
|
495
|
+
if (!permitSet.has(attachmentName)) {
|
|
496
|
+
notPermittedAttachments.push(attachmentName)
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
if (!attachmentDefinitions[attachmentName]) {
|
|
500
|
+
invalidAttachments.push(attachmentName)
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
model.getAttachmentByName(attachmentName).queueAttach(value)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (notPermittedAttachments.length > 0) {
|
|
508
|
+
throw new Error(`Frontend model attachment names not permitted by permittedParams(): ${notPermittedAttachments.join(", ")}`)
|
|
509
|
+
}
|
|
510
|
+
if (invalidAttachments.length > 0) {
|
|
511
|
+
throw new Error(`Invalid frontend model attachment names: ${invalidAttachments.join(", ")}`)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
418
515
|
/**
|
|
419
516
|
* Sets a translated attribute on a model via the translations relationship.
|
|
420
517
|
* @param {import("../database/record/index.js").default} model - Model instance.
|
|
@@ -487,6 +584,278 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
487
584
|
return await this.typedControllerInstance().serializeFrontendModel(model)
|
|
488
585
|
}
|
|
489
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Resolves common metadata for one nested-attributes relationship.
|
|
589
|
+
* @param {object} args - Nested relationship inputs.
|
|
590
|
+
* @param {import("../database/record/index.js").default} args.parent - Parent model instance.
|
|
591
|
+
* @param {string} args.relationshipName - Relationship receiving nested attributes.
|
|
592
|
+
* @param {?} args.rawEntries - Raw nested entries from the request payload.
|
|
593
|
+
* @param {{attributes: string[], nested: Record<string, ?>}} args.childPermit - Parsed child permit.
|
|
594
|
+
* @param {?} args.controller - Controller instance for child resource lookup.
|
|
595
|
+
* @returns {{ability: import("../authorization/ability.js").default | undefined, childResource: FrontendModelBaseResource, childResourceConfig: FrontendModelResolvedResourceConfiguration, childWritableAttributes: string[], destroyPermitted: boolean, entries: Array<Record<string, ?>>, relationship: import("../database/record/relationships/base.js").default, targetModelClass: typeof import("../database/record/index.js").default}} Nested relationship context.
|
|
596
|
+
*/
|
|
597
|
+
_nestedRelationshipContext({parent, relationshipName, rawEntries, childPermit, controller}) {
|
|
598
|
+
const parentModelClass = parent.getModelClass()
|
|
599
|
+
const modelAcceptance = parentModelClass.acceptedNestedAttributesFor?.(relationshipName)
|
|
600
|
+
|
|
601
|
+
if (!modelAcceptance) {
|
|
602
|
+
throw new Error(`Model ${parentModelClass.name} does not accept nested attributes for '${relationshipName}'. Declare it via ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}').`)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const relationship = parentModelClass.getRelationshipByName(relationshipName)
|
|
606
|
+
const relationshipType = relationship.getType()
|
|
607
|
+
const rawNormalizedEntries = this._nestedRelationshipEntries({rawEntries, relationshipName, relationshipType})
|
|
608
|
+
const destroyPermitted = childPermit.attributes.includes("_destroy")
|
|
609
|
+
|
|
610
|
+
if (destroyPermitted && !modelAcceptance.allowDestroy) {
|
|
611
|
+
throw new Error(`Resource permits _destroy on nestedAttributes['${relationshipName}'] but the model ${parentModelClass.name} does not allow destroy for that relationship. Set {allowDestroy: true} on ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}', ...).`)
|
|
612
|
+
}
|
|
613
|
+
if (typeof modelAcceptance.limit === "number" && rawNormalizedEntries.length > modelAcceptance.limit) {
|
|
614
|
+
throw new Error(`nestedAttributes['${relationshipName}'] exceeds model-declared limit of ${modelAcceptance.limit}.`)
|
|
615
|
+
}
|
|
616
|
+
if (relationshipType !== "hasMany" && rawNormalizedEntries.length > 1) {
|
|
617
|
+
throw new Error(`nestedAttributes['${relationshipName}'] accepts one entry for ${relationshipType} relationships.`)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const targetModelClass = relationship.getTargetModelClass()
|
|
621
|
+
|
|
622
|
+
if (!targetModelClass) {
|
|
623
|
+
throw new Error(`No target model class resolved for relationship '${relationshipName}' on ${parentModelClass.name}.`)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const childResourceConfig = controller?.frontendModelResourceConfigurationForModelClass?.(targetModelClass)
|
|
627
|
+
|
|
628
|
+
if (!childResourceConfig) {
|
|
629
|
+
throw new Error(`No frontend-model resource registered for child model '${targetModelClass.getModelName?.() || targetModelClass.name}' under relationship '${relationshipName}'.`)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const childResource = new childResourceConfig.resourceClass({
|
|
633
|
+
ability: this.ability,
|
|
634
|
+
controller,
|
|
635
|
+
context: this.context || {},
|
|
636
|
+
locals: this.locals || {},
|
|
637
|
+
modelClass: targetModelClass,
|
|
638
|
+
modelName: childResourceConfig.modelName,
|
|
639
|
+
params: controller?.frontendModelParams?.() || {},
|
|
640
|
+
resourceConfiguration: childResourceConfig.resourceConfiguration
|
|
641
|
+
})
|
|
642
|
+
const childWritableAttributes = childPermit.attributes.filter((name) => name !== "_destroy")
|
|
643
|
+
const entries = rawNormalizedEntries
|
|
644
|
+
.map((entry) => this._normalizeNestedRelationshipEntry({childPermit, entry, relationshipName, targetModelClass}))
|
|
645
|
+
.filter((entry) => {
|
|
646
|
+
if (typeof modelAcceptance.rejectIf !== "function") return true
|
|
647
|
+
|
|
648
|
+
return !modelAcceptance.rejectIf(isPlainObject(entry.attributes) ? entry.attributes : {})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
ability: controller?.currentAbility?.() || this.ability,
|
|
653
|
+
childResource,
|
|
654
|
+
childResourceConfig,
|
|
655
|
+
childWritableAttributes,
|
|
656
|
+
destroyPermitted,
|
|
657
|
+
entries,
|
|
658
|
+
relationship,
|
|
659
|
+
targetModelClass
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Normalizes nested entries for collection and singular relationships.
|
|
665
|
+
* @param {object} args - Nested entries inputs.
|
|
666
|
+
* @param {?} args.rawEntries - Raw nested entries value.
|
|
667
|
+
* @param {string} args.relationshipName - Relationship name.
|
|
668
|
+
* @param {string} args.relationshipType - Relationship type.
|
|
669
|
+
* @returns {Array<Record<string, ?>>} Normalized nested entry objects.
|
|
670
|
+
*/
|
|
671
|
+
_nestedRelationshipEntries({rawEntries, relationshipName, relationshipType}) {
|
|
672
|
+
if (relationshipType === "hasMany") {
|
|
673
|
+
if (!Array.isArray(rawEntries)) {
|
|
674
|
+
throw new Error(`Expected array for nestedAttributes['${relationshipName}'] but got: ${typeof rawEntries}`)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return rawEntries.map((entry) => {
|
|
678
|
+
if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
|
|
679
|
+
|
|
680
|
+
return entry
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (rawEntries == null) return []
|
|
685
|
+
if (Array.isArray(rawEntries)) {
|
|
686
|
+
return rawEntries.map((entry) => {
|
|
687
|
+
if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
|
|
688
|
+
|
|
689
|
+
return entry
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
if (!isPlainObject(rawEntries)) {
|
|
693
|
+
throw new Error(`Expected object for nestedAttributes['${relationshipName}'] but got: ${typeof rawEntries}`)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return [rawEntries]
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Normalizes one nested entry from either internal transport shape
|
|
701
|
+
* (`{attributes, attachments, nestedAttributes}`) or direct Rails-style
|
|
702
|
+
* fields (`{name, file, commentsAttributes}`).
|
|
703
|
+
* @param {object} args - Normalization inputs.
|
|
704
|
+
* @param {{attributes: string[], nested: Record<string, ?>}} args.childPermit - Parsed child permit spec.
|
|
705
|
+
* @param {Record<string, ?>} args.entry - Raw nested entry.
|
|
706
|
+
* @param {string} args.relationshipName - Relationship name for error messages.
|
|
707
|
+
* @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
|
|
708
|
+
* @returns {Record<string, ?>} Normalized nested entry.
|
|
709
|
+
*/
|
|
710
|
+
_normalizeNestedRelationshipEntry({childPermit, entry, relationshipName, targetModelClass}) {
|
|
711
|
+
/** @type {Record<string, ?>} */
|
|
712
|
+
const attributes = {}
|
|
713
|
+
/** @type {Record<string, ?>} */
|
|
714
|
+
const attachments = {}
|
|
715
|
+
/** @type {Record<string, ?>} */
|
|
716
|
+
const nestedAttributes = {}
|
|
717
|
+
/** @type {Record<string, ?>} */
|
|
718
|
+
const normalized = {}
|
|
719
|
+
const attachmentDefinitions = targetModelClass.getAttachmentsMap?.() || {}
|
|
720
|
+
|
|
721
|
+
for (const [attributeName, value] of Object.entries(entry)) {
|
|
722
|
+
if (attributeName === "id" || attributeName === "_destroy") {
|
|
723
|
+
normalized[attributeName] = value
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (attributeName === "attributes") {
|
|
728
|
+
if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry attributes must be an object.`)
|
|
729
|
+
Object.assign(attributes, value)
|
|
730
|
+
continue
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (attributeName === "attachments") {
|
|
734
|
+
if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry attachments must be an object.`)
|
|
735
|
+
Object.assign(attachments, value)
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (attributeName === "nestedAttributes") {
|
|
740
|
+
if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry nestedAttributes must be an object.`)
|
|
741
|
+
Object.assign(nestedAttributes, value)
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (attributeName.endsWith("Attributes")) {
|
|
746
|
+
const nestedRelationshipName = attributeName.slice(0, -"Attributes".length)
|
|
747
|
+
|
|
748
|
+
if (!nestedRelationshipName) throw new Error(`Invalid nested attributes key: ${attributeName}`)
|
|
749
|
+
if (!childPermit.nested[nestedRelationshipName]) {
|
|
750
|
+
throw new Error(`Nested attributes for '${nestedRelationshipName}' are not permitted under '${relationshipName}'. Include {${attributeName}: [...]} in that nested permit.`)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
nestedAttributes[nestedRelationshipName] = value
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (attachmentDefinitions[attributeName]) {
|
|
758
|
+
attachments[attributeName] = value
|
|
759
|
+
} else {
|
|
760
|
+
attributes[attributeName] = value
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (Object.keys(attributes).length > 0) normalized.attributes = attributes
|
|
765
|
+
if (Object.keys(attachments).length > 0) normalized.attachments = attachments
|
|
766
|
+
if (Object.keys(nestedAttributes).length > 0) normalized.nestedAttributes = nestedAttributes
|
|
767
|
+
|
|
768
|
+
return normalized
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Applies belongs-to nested attributes before the parent save so the parent FK can be set.
|
|
773
|
+
* @param {import("../database/record/index.js").default} parent - Parent model instance.
|
|
774
|
+
* @param {Record<string, ?>} nestedAttributes - Nested-attribute payload keyed by relationship name.
|
|
775
|
+
* @param {?} controller - Controller instance for resource resolution and authorization.
|
|
776
|
+
* @param {{attributes: string[], nested: Record<string, ?>} | null} [parentPermit] - Parsed parent permit spec.
|
|
777
|
+
* @returns {Promise<void>}
|
|
778
|
+
*/
|
|
779
|
+
async _applyBelongsToNestedAttributes(parent, nestedAttributes, controller, parentPermit = null) {
|
|
780
|
+
const resolvedParent = parentPermit
|
|
781
|
+
|| parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: {}}))
|
|
782
|
+
|
|
783
|
+
for (const relationshipName of Object.keys(nestedAttributes)) {
|
|
784
|
+
const childPermit = resolvedParent.nested[relationshipName]
|
|
785
|
+
|
|
786
|
+
if (!childPermit) continue
|
|
787
|
+
|
|
788
|
+
const context = this._nestedRelationshipContext({
|
|
789
|
+
childPermit,
|
|
790
|
+
controller,
|
|
791
|
+
parent,
|
|
792
|
+
rawEntries: nestedAttributes[relationshipName],
|
|
793
|
+
relationshipName
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
if (context.relationship.getType() !== "belongsTo") continue
|
|
797
|
+
|
|
798
|
+
const foreignKey = this._foreignKeyAttributeForModel(context.relationship, parent.getModelClass())
|
|
799
|
+
|
|
800
|
+
for (const entry of context.entries) {
|
|
801
|
+
if (entry._destroy) {
|
|
802
|
+
if (!context.destroyPermitted) {
|
|
803
|
+
throw new Error(`nestedAttributes['${relationshipName}'] entry requested _destroy but "_destroy" is not in the permit for this relationship.`)
|
|
804
|
+
}
|
|
805
|
+
if (!entry.id) throw new Error(`nestedAttributes['${relationshipName}'] _destroy entry is missing an id.`)
|
|
806
|
+
|
|
807
|
+
const existing = await this._findNestedRecord({
|
|
808
|
+
ability: context.ability,
|
|
809
|
+
action: "destroy",
|
|
810
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
811
|
+
id: entry.id,
|
|
812
|
+
relationshipName,
|
|
813
|
+
targetModelClass: context.targetModelClass
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
await context.childResource.destroy(existing)
|
|
817
|
+
parent.setAttribute(foreignKey, null)
|
|
818
|
+
continue
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const child = entry.id
|
|
822
|
+
? await this._findNestedRecord({
|
|
823
|
+
ability: context.ability,
|
|
824
|
+
action: "update",
|
|
825
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
826
|
+
id: entry.id,
|
|
827
|
+
relationshipName,
|
|
828
|
+
targetModelClass: context.targetModelClass
|
|
829
|
+
})
|
|
830
|
+
: new context.targetModelClass()
|
|
831
|
+
|
|
832
|
+
await context.childResource._assignNestedEntryToChild({
|
|
833
|
+
child,
|
|
834
|
+
childWritableAttributes: context.childWritableAttributes,
|
|
835
|
+
entry
|
|
836
|
+
})
|
|
837
|
+
await context.childResource._applyBelongsToNestedAttributes(child, entry.nestedAttributes || {}, controller, childPermit)
|
|
838
|
+
await child.save()
|
|
839
|
+
|
|
840
|
+
if (!entry.id) {
|
|
841
|
+
await this._authorizeCreatedChild({
|
|
842
|
+
ability: context.ability,
|
|
843
|
+
child,
|
|
844
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
845
|
+
relationshipName,
|
|
846
|
+
targetModelClass: context.targetModelClass
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (entry.nestedAttributes) {
|
|
851
|
+
await context.childResource._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
parent.setAttribute(foreignKey, child.id())
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
490
859
|
/**
|
|
491
860
|
* Applies a `nestedAttributes` payload to a freshly-saved parent model,
|
|
492
861
|
* cascading create/update/destroy writes across the declared relationships.
|
|
@@ -516,76 +885,29 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
516
885
|
throw new Error(`Nested attributes for '${relationshipName}' are not permitted by ${this.constructor.name}.permittedParams(). Include {${relationshipName}Attributes: [...]} in the returned permit.`)
|
|
517
886
|
}
|
|
518
887
|
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
if (!Array.isArray(entries)) {
|
|
522
|
-
throw new Error(`Expected array for nestedAttributes['${relationshipName}'] but got: ${typeof entries}`)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const parentModelClass = /**
|
|
526
|
-
* Narrows the runtime value to the documented type.
|
|
527
|
-
@type {?} */ (parent.getModelClass())
|
|
528
|
-
const modelAcceptance = parentModelClass.acceptedNestedAttributesFor?.(relationshipName)
|
|
529
|
-
|
|
530
|
-
if (!modelAcceptance) {
|
|
531
|
-
throw new Error(`Model ${parentModelClass.name} does not accept nested attributes for '${relationshipName}'. Declare it via ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}').`)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const destroyPermitted = childPermit.attributes.includes("_destroy")
|
|
535
|
-
|
|
536
|
-
if (destroyPermitted && !modelAcceptance.allowDestroy) {
|
|
537
|
-
throw new Error(`Resource permits _destroy on nestedAttributes['${relationshipName}'] but the model ${parentModelClass.name} does not allow destroy for that relationship. Set {allowDestroy: true} on ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}', ...).`)
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (typeof modelAcceptance.limit === "number" && entries.length > modelAcceptance.limit) {
|
|
541
|
-
throw new Error(`nestedAttributes['${relationshipName}'] exceeds model-declared limit of ${modelAcceptance.limit}.`)
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
const parentRelationship = parent.getRelationshipByName(relationshipName)
|
|
545
|
-
const relationshipDefinitions = parentModelClass.relationships?.() || {}
|
|
546
|
-
const definition = relationshipDefinitions[relationshipName]
|
|
547
|
-
|
|
548
|
-
if (!definition || definition.type !== "hasMany") {
|
|
549
|
-
throw new Error(`Nested attributes for '${relationshipName}' require a hasMany relationship. v1 does not support '${definition?.type}'.`)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const targetModelClass = /**
|
|
553
|
-
* Narrows the runtime value to the documented type.
|
|
554
|
-
@type {?} */ (parent.getModelClass()).relationshipModelClass?.(relationshipName)
|
|
555
|
-
|
|
556
|
-
if (!targetModelClass) {
|
|
557
|
-
throw new Error(`No target model class resolved for relationship '${relationshipName}' on ${parent.getModelClass().name}.`)
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const childResourceConfig = controller?.frontendModelResourceConfigurationForModelClass?.(targetModelClass)
|
|
561
|
-
|
|
562
|
-
if (!childResourceConfig) {
|
|
563
|
-
throw new Error(`No frontend-model resource registered for child model '${targetModelClass.getModelName?.() || targetModelClass.name}' under relationship '${relationshipName}'.`)
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const childResource = new childResourceConfig.resourceClass({
|
|
567
|
-
ability: this.ability,
|
|
888
|
+
const context = this._nestedRelationshipContext({
|
|
889
|
+
childPermit,
|
|
568
890
|
controller,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
modelName: childResourceConfig.modelName,
|
|
573
|
-
params: controller?.frontendModelParams?.() || {},
|
|
574
|
-
resourceConfiguration: childResourceConfig.resourceConfiguration
|
|
891
|
+
parent,
|
|
892
|
+
rawEntries: nestedAttributes[relationshipName],
|
|
893
|
+
relationshipName
|
|
575
894
|
})
|
|
576
895
|
|
|
577
|
-
|
|
578
|
-
|
|
896
|
+
if (context.relationship.getType() === "belongsTo") continue
|
|
897
|
+
|
|
898
|
+
const parentLinkAttributes = this._parentLinkAttributesForNestedChild({
|
|
899
|
+
parent,
|
|
900
|
+
relationship: context.relationship,
|
|
901
|
+
targetModelClass: context.targetModelClass
|
|
902
|
+
})
|
|
579
903
|
|
|
580
904
|
const destroyEntries = []
|
|
581
905
|
const updateEntries = []
|
|
582
906
|
const createEntries = []
|
|
583
907
|
|
|
584
|
-
for (const entry of entries) {
|
|
585
|
-
if (typeof modelAcceptance.rejectIf === "function" && modelAcceptance.rejectIf(entry?.attributes || {})) continue
|
|
586
|
-
|
|
908
|
+
for (const entry of context.entries) {
|
|
587
909
|
if (entry?._destroy) {
|
|
588
|
-
if (!destroyPermitted) {
|
|
910
|
+
if (!context.destroyPermitted) {
|
|
589
911
|
throw new Error(`nestedAttributes['${relationshipName}'] entry requested _destroy but "_destroy" is not in the permit for this relationship.`)
|
|
590
912
|
}
|
|
591
913
|
if (!entry.id) {
|
|
@@ -599,90 +921,173 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
599
921
|
}
|
|
600
922
|
}
|
|
601
923
|
|
|
602
|
-
// The permit's attribute list governs what child fields can be written.
|
|
603
|
-
// Exclude `_destroy` from the writable set since it's a control flag,
|
|
604
|
-
// not an attribute on the record.
|
|
605
|
-
const childWritableAttributes = /**
|
|
606
|
-
* Narrows the runtime value to the documented type.
|
|
607
|
-
@type {string[]} */ (childPermit.attributes).filter((name) => name !== "_destroy")
|
|
608
|
-
|
|
609
924
|
for (const entry of destroyEntries) {
|
|
610
925
|
const existing = await this._findScopedChild({
|
|
611
|
-
ability,
|
|
926
|
+
ability: context.ability,
|
|
612
927
|
action: "destroy",
|
|
613
|
-
childResourceConfiguration: childResourceConfig.resourceConfiguration,
|
|
614
|
-
foreignKey,
|
|
928
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
615
929
|
id: entry.id,
|
|
616
930
|
parent,
|
|
931
|
+
parentLinkAttributes,
|
|
617
932
|
relationshipName,
|
|
618
|
-
targetModelClass
|
|
933
|
+
targetModelClass: context.targetModelClass
|
|
619
934
|
})
|
|
620
935
|
|
|
621
|
-
await childResource.destroy(existing)
|
|
936
|
+
await context.childResource.destroy(existing)
|
|
622
937
|
}
|
|
623
938
|
|
|
624
939
|
for (const entry of updateEntries) {
|
|
625
940
|
const existing = await this._findScopedChild({
|
|
626
|
-
ability,
|
|
941
|
+
ability: context.ability,
|
|
627
942
|
action: "update",
|
|
628
|
-
childResourceConfiguration: childResourceConfig.resourceConfiguration,
|
|
629
|
-
foreignKey,
|
|
943
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
630
944
|
id: entry.id,
|
|
631
945
|
parent,
|
|
946
|
+
parentLinkAttributes,
|
|
632
947
|
relationshipName,
|
|
633
|
-
targetModelClass
|
|
948
|
+
targetModelClass: context.targetModelClass
|
|
634
949
|
})
|
|
635
950
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
951
|
+
await context.childResource._assignNestedEntryToChild({
|
|
952
|
+
child: existing,
|
|
953
|
+
childWritableAttributes: context.childWritableAttributes,
|
|
954
|
+
entry
|
|
955
|
+
})
|
|
956
|
+
await context.childResource._applyBelongsToNestedAttributes(existing, entry.nestedAttributes || {}, controller, childPermit)
|
|
957
|
+
await existing.save()
|
|
643
958
|
|
|
644
959
|
if (entry.nestedAttributes) {
|
|
645
|
-
await
|
|
646
|
-
* Narrows the runtime value to the documented type.
|
|
647
|
-
@type {?} */ (childResource)._applyNestedAttributes(existing, entry.nestedAttributes, controller, childPermit)
|
|
960
|
+
await context.childResource._applyNestedAttributes(existing, entry.nestedAttributes, controller, childPermit)
|
|
648
961
|
}
|
|
649
962
|
}
|
|
650
963
|
|
|
651
964
|
for (const entry of createEntries) {
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
const child = parentRelationship.build({...childAttributes, [foreignKey]: parent.id()})
|
|
965
|
+
const child = new context.targetModelClass()
|
|
655
966
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
967
|
+
child.assign(parentLinkAttributes)
|
|
968
|
+
await context.childResource._assignNestedEntryToChild({
|
|
969
|
+
child,
|
|
970
|
+
childWritableAttributes: context.childWritableAttributes,
|
|
971
|
+
entry
|
|
972
|
+
})
|
|
973
|
+
await context.childResource._applyBelongsToNestedAttributes(child, entry.nestedAttributes || {}, controller, childPermit)
|
|
661
974
|
await child.save()
|
|
662
975
|
|
|
663
976
|
await this._authorizeCreatedChild({
|
|
664
|
-
ability,
|
|
977
|
+
ability: context.ability,
|
|
665
978
|
child,
|
|
666
|
-
childResourceConfiguration: childResourceConfig.resourceConfiguration,
|
|
979
|
+
childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
|
|
667
980
|
relationshipName,
|
|
668
|
-
targetModelClass
|
|
981
|
+
targetModelClass: context.targetModelClass
|
|
669
982
|
})
|
|
670
983
|
|
|
671
984
|
if (entry.nestedAttributes) {
|
|
672
|
-
await
|
|
673
|
-
* Narrows the runtime value to the documented type.
|
|
674
|
-
@type {?} */ (childResource)._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
|
|
985
|
+
await context.childResource._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
|
|
675
986
|
}
|
|
676
987
|
}
|
|
677
988
|
}
|
|
678
989
|
}
|
|
679
990
|
|
|
991
|
+
/**
|
|
992
|
+
* Assigns one nested entry's attributes and attachments to a child model.
|
|
993
|
+
* @param {object} args - Assignment inputs.
|
|
994
|
+
* @param {import("../database/record/index.js").default} args.child - Child model receiving data.
|
|
995
|
+
* @param {string[]} args.childWritableAttributes - Permitted child attribute and attachment names.
|
|
996
|
+
* @param {Record<string, ?>} args.entry - Nested entry payload.
|
|
997
|
+
* @returns {Promise<void>}
|
|
998
|
+
*/
|
|
999
|
+
async _assignNestedEntryToChild({child, childWritableAttributes, entry}) {
|
|
1000
|
+
if (entry.attributes !== undefined) {
|
|
1001
|
+
if (!isPlainObject(entry.attributes)) throw new Error("Expected nested entry attributes to be an object.")
|
|
1002
|
+
|
|
1003
|
+
const filtered = filterWritableFrontendModelAttributes(child, entry.attributes, this, childWritableAttributes)
|
|
1004
|
+
await this._assignWithVirtualSetters(child, filtered)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (entry.attachments !== undefined && !isPlainObject(entry.attachments)) {
|
|
1008
|
+
throw new Error("Expected nested entry attachments to be an object.")
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
this._assignAttachments(child, entry.attachments ?? null, childWritableAttributes)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Converts a relationship's foreign-key column/name to the target model's attribute name.
|
|
1016
|
+
* @param {import("../database/record/relationships/base.js").default} relationship - Relationship metadata.
|
|
1017
|
+
* @param {typeof import("../database/record/index.js").default} modelClass - Model class containing the FK.
|
|
1018
|
+
* @returns {string} Foreign-key attribute name.
|
|
1019
|
+
*/
|
|
1020
|
+
_foreignKeyAttributeForModel(relationship, modelClass) {
|
|
1021
|
+
const foreignKey = relationship.getForeignKey()
|
|
1022
|
+
|
|
1023
|
+
return modelClass.getColumnNameToAttributeNameMap()[foreignKey] || foreignKey
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Returns the FK attributes that bind a nested child to its parent.
|
|
1028
|
+
* @param {object} args - Parent-link inputs.
|
|
1029
|
+
* @param {import("../database/record/index.js").default} args.parent - Parent model instance.
|
|
1030
|
+
* @param {import("../database/record/relationships/base.js").default} args.relationship - Relationship metadata.
|
|
1031
|
+
* @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
|
|
1032
|
+
* @returns {Record<string, string | number>} Attributes that scope the child to the parent.
|
|
1033
|
+
*/
|
|
1034
|
+
_parentLinkAttributesForNestedChild({parent, relationship, targetModelClass}) {
|
|
1035
|
+
const foreignKey = this._foreignKeyAttributeForModel(relationship, targetModelClass)
|
|
1036
|
+
/** @type {Record<string, string | number>} */
|
|
1037
|
+
const attributes = {[foreignKey]: /** @type {string | number} */ (parent.id())}
|
|
1038
|
+
|
|
1039
|
+
if (relationship.getPolymorphic()) {
|
|
1040
|
+
const typeAttribute = this._polymorphicTypeAttributeForModel(relationship, targetModelClass)
|
|
1041
|
+
|
|
1042
|
+
attributes[typeAttribute] = parent.getModelClass().getModelName()
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return attributes
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Converts a relationship's polymorphic type column/name to a child attribute name.
|
|
1050
|
+
* @param {import("../database/record/relationships/base.js").default} relationship - Relationship metadata.
|
|
1051
|
+
* @param {typeof import("../database/record/index.js").default} modelClass - Model class containing the type column.
|
|
1052
|
+
* @returns {string} Polymorphic type attribute name.
|
|
1053
|
+
*/
|
|
1054
|
+
_polymorphicTypeAttributeForModel(relationship, modelClass) {
|
|
1055
|
+
const typeColumn = relationship.getPolymorphicTypeColumn()
|
|
1056
|
+
|
|
1057
|
+
return modelClass.getColumnNameToAttributeNameMap()[typeColumn] || typeColumn
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Finds an authorized nested record by id without parent scoping.
|
|
1062
|
+
* @param {object} args - Lookup inputs.
|
|
1063
|
+
* @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
|
|
1064
|
+
* @param {"update" | "destroy"} args.action - Frontend action.
|
|
1065
|
+
* @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
|
|
1066
|
+
* @param {string | number} args.id - Child id from the payload.
|
|
1067
|
+
* @param {string} args.relationshipName - Parent's relationship name for error messages.
|
|
1068
|
+
* @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
|
|
1069
|
+
* @returns {Promise<import("../database/record/index.js").default>} Authorized child model.
|
|
1070
|
+
*/
|
|
1071
|
+
async _findNestedRecord({ability, action, childResourceConfiguration, id, relationshipName, targetModelClass}) {
|
|
1072
|
+
const primaryKey = targetModelClass.primaryKey()
|
|
1073
|
+
const query = ability
|
|
1074
|
+
? targetModelClass.accessibleFor(this._resolveChildAbilityAction(childResourceConfiguration, action), ability)
|
|
1075
|
+
: targetModelClass.where({})
|
|
1076
|
+
const existing = await query.findBy({[primaryKey]: id})
|
|
1077
|
+
|
|
1078
|
+
if (!existing) {
|
|
1079
|
+
throw new Error(`Cannot ${action} nested ${relationshipName}[id=${id}]: record not found or not authorized.`)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return existing
|
|
1083
|
+
}
|
|
1084
|
+
|
|
680
1085
|
/**
|
|
681
1086
|
* Resolves the ability action for a child resource using the child's own
|
|
682
1087
|
* `abilities` mapping — never the parent controller's. This preserves
|
|
683
1088
|
* custom mappings like `{update: "manage"}` and catches unmapped actions
|
|
684
1089
|
* instead of silently defaulting to the raw action name.
|
|
685
|
-
* @param {import("../configuration-types.js").
|
|
1090
|
+
* @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} childResourceConfiguration - Child resource configuration.
|
|
686
1091
|
* @param {"create" | "update" | "destroy"} action - Frontend action.
|
|
687
1092
|
* @returns {string} - Ability action for the child resource.
|
|
688
1093
|
*/
|
|
@@ -713,17 +1118,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
713
1118
|
* @param {object} args - Arguments.
|
|
714
1119
|
* @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
|
|
715
1120
|
* @param {"update" | "destroy"} args.action - Frontend action.
|
|
716
|
-
* @param {import("../configuration-types.js").
|
|
717
|
-
* @param {string} args.foreignKey - Foreign-key attribute on the child pointing to the parent.
|
|
1121
|
+
* @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
|
|
718
1122
|
* @param {string | number} args.id - Child id from the payload.
|
|
719
1123
|
* @param {import("../database/record/index.js").default} args.parent - Parent model instance.
|
|
1124
|
+
* @param {Record<string, string | number>} args.parentLinkAttributes - Attributes that scope the child to the parent.
|
|
720
1125
|
* @param {string} args.relationshipName - Parent's relationship name (for error messages).
|
|
721
1126
|
* @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
|
|
722
1127
|
* @returns {Promise<import("../database/record/index.js").default>} - Authorized, parent-linked child model.
|
|
723
1128
|
*/
|
|
724
|
-
async _findScopedChild({ability, action, childResourceConfiguration,
|
|
1129
|
+
async _findScopedChild({ability, action, childResourceConfiguration, id, parent, parentLinkAttributes, relationshipName, targetModelClass}) {
|
|
725
1130
|
const primaryKey = targetModelClass.primaryKey()
|
|
726
|
-
const lookup = {[primaryKey]: id,
|
|
1131
|
+
const lookup = {[primaryKey]: id, ...parentLinkAttributes}
|
|
727
1132
|
const query = ability
|
|
728
1133
|
? /**
|
|
729
1134
|
* Narrows the runtime value to the documented type.
|
|
@@ -748,7 +1153,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
748
1153
|
* @param {object} args - Arguments.
|
|
749
1154
|
* @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
|
|
750
1155
|
* @param {import("../database/record/index.js").default} args.child - Child model instance just created.
|
|
751
|
-
* @param {import("../configuration-types.js").
|
|
1156
|
+
* @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
|
|
752
1157
|
* @param {string} args.relationshipName - Parent's relationship name (for error messages).
|
|
753
1158
|
* @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
|
|
754
1159
|
* @returns {Promise<void>}
|
|
@@ -770,21 +1175,6 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
|
|
|
770
1175
|
}
|
|
771
1176
|
}
|
|
772
1177
|
|
|
773
|
-
/**
|
|
774
|
-
* Best-effort foreign-key inference for relationships that don't declare it.
|
|
775
|
-
* @param {import("../database/record/index.js").default} parent - Parent model.
|
|
776
|
-
* @param {{foreignKey?: string}} definition - Relationship definition.
|
|
777
|
-
* @returns {string} - Foreign-key attribute name.
|
|
778
|
-
*/
|
|
779
|
-
_inferForeignKey(parent, definition) {
|
|
780
|
-
if (definition.foreignKey) return definition.foreignKey
|
|
781
|
-
|
|
782
|
-
const parentModelName = parent.getModelClass().name || ""
|
|
783
|
-
const underscored = parentModelName.replace(/([A-Z])/g, (match, letter, index) => (index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`))
|
|
784
|
-
|
|
785
|
-
return `${inflection.camelize(underscored, true)}Id`
|
|
786
|
-
}
|
|
787
|
-
|
|
788
1178
|
/**
|
|
789
1179
|
* After nested writes, preload every relationship declared in the
|
|
790
1180
|
* parent's permit so the post-save serialize step emits them and the
|