Files
alphane.ca/api/tests/factories/helpers/nested-save-and-associate-if-new.ts
2026-06-19 22:20:43 -07:00

163 lines
6.7 KiB
TypeScript

import { Model } from "@sequelize/core"
/**
* Recursively saves a Sequelize model instance along with its associated models if they are new (unsaved).
*
* It handles `BelongsTo` associations and ensures associated model is saved _before_ the current model,
* then propagates the foreign key relationship before saving the current model.
*
* It handles `HasMany` associations and ensures associated models are saved _after_ the current model,
* propagating the foreign key relationship before saving.
*
* It handles `HasOne` associations and ensures the associated model is saved _after_ the current model,
* propagating the foreign key relationship before saving.
*
* @template M - A Sequelize model type extending `Model`.
* @param {M} modelInstance - The Sequelize model instance to be saved along with its associated models.
* @returns {Promise<M>} - Returns the saved model instance.
*
* @example
* // Given a non-persisted TravelSegment model instance, being passed via a HasMany association to a TravelAuthorization model.
* // The TravelAuthorization is saved first, then the TravelSegment is updated with the travelAuthorizationId before it is saved.
*
* const travelSegment = travelSegmentFactory.build({
* departureOn: faker.date.past(),
* })
* const travelAuthorization = await travelAuthorizationFactory
* .associations({
* travelSegments: [travelSegment],
* })
* .transient({
* include: ["user", "travelSegments"],
* })
* .create({
* status: TravelAuthorization.Statuses.SUBMITTED,
* })
*
* @example
* // Given a persisted TravelAuthorization passed to a TravelSegment model instance via a BelongsTo association.
* // The TravelSegment is updated with the travelAuthorizationId before it is saved.
* const travelAuthorization = await travelAuthorizationFactory
* .transient({ roundTrip: true })
* .create()
* const whitehorse = await locationFactory.create({ city: "Whitehorse", province: "YT" })
* const vancouver = await locationFactory.create({ city: "Vancouver", province: "BC" })
* const travelSegment1 = await travelSegmentFactory
* .associations({
* travelAuthorization,
* departureLocation: whitehorse,
* arrivalLocation: vancouver,
* })
* .create({
* segmentNumber: 1,
* departureOn: new Date("2022-06-05"),
* departureTime: Stop.BEGINNING_OF_DAY,
* modeOfTransport: Stop.TravelMethods.AIRCRAFT,
* accommodationType: Stop.AccommodationTypes.HOTEL,
* })
*
* @example
* // Given a generic TravelAuthorization creation.
* // The factory will create the purpose and user associations if they are not provided.
* // Then the onCreated hook will call nestedSaveAndAssociateIfNew to save
* // the purpose and user associations as they are new.
* // Then those associations will propagate their foreign keys to the travelAuthorization before it is saved.
* const travelAuthorization = await travelAuthorizationFactory.create()
*/
export async function nestedSaveAndAssociateIfNew<M extends Model>(modelInstance: M): Promise<M> {
const modelClass = modelInstance.constructor as typeof Model
const associations = modelClass.associations
const belongsToAssociationNames: string[] = []
const hasManyAssociationNames: string[] = []
const hasOneAssociationNames: string[] = []
for (const [associationName, { associationType }] of Object.entries(associations)) {
if (associationType === "BelongsTo") {
belongsToAssociationNames.push(associationName)
} else if (associationType === "HasMany") {
hasManyAssociationNames.push(associationName)
} else if (associationType === "HasOne") {
hasOneAssociationNames.push(associationName)
} else {
// no-op - maybe I should warn?
}
}
for (const associationName of belongsToAssociationNames) {
const associationDefinition = associations[associationName]
const foreignKeyName = associationDefinition.foreignKey
let foreignKeyValue = modelInstance.get(foreignKeyName)
if (foreignKeyValue !== undefined) continue
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstance = modelInstance[associationAlias] as Model | undefined
if (associatedInstance === undefined) continue
// @ts-expect-error - TS doesn't know that targetKey is a property of associationDefinition
const { targetKey }: { targetKey: keyof typeof associatedInstance } = associationDefinition
if (associatedInstance.isNewRecord !== true) {
foreignKeyValue = associatedInstance.get(targetKey)
modelInstance.set(foreignKeyName, foreignKeyValue)
continue
}
const updatedAssocationInstance = await nestedSaveAndAssociateIfNew(associatedInstance)
foreignKeyValue = updatedAssocationInstance.get(targetKey)
modelInstance.set(foreignKeyName, foreignKeyValue)
}
if (modelInstance.isNewRecord === true) {
await modelInstance.save()
}
for (const associationName of hasManyAssociationNames) {
const associationDefinition = associations[associationName]
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstances = modelInstance[associationAlias] as Model[] | undefined
if (associatedInstances === undefined) continue
for (const associatedInstance of associatedInstances) {
if (associatedInstance.isNewRecord !== true) continue
// @ts-expect-error - TS doesn't know that sourceKey is a property of associationDefinition
const {
sourceKey,
foreignKey,
}: { sourceKey: keyof M; foreignKey: keyof typeof associatedInstance } = associationDefinition
const sourceKeyValue = modelInstance.get(sourceKey)
associatedInstance.set(foreignKey, sourceKeyValue)
await nestedSaveAndAssociateIfNew(associatedInstance)
}
}
for (const associationName of hasOneAssociationNames) {
const associationDefinition = associations[associationName]
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstance = modelInstance[associationAlias] as Model | undefined
if (associatedInstance === undefined) continue
if (associatedInstance.isNewRecord !== true) continue
// @ts-expect-error - TS doesn't know that sourceKey is a property of associationDefinition
const {
sourceKey,
foreignKey,
}: { sourceKey: keyof M; foreignKey: keyof typeof associatedInstance } = associationDefinition
const sourceKeyValue = modelInstance.get(sourceKey)
associatedInstance.set(foreignKey, sourceKeyValue)
await nestedSaveAndAssociateIfNew(associatedInstance)
}
return modelInstance
}
export default nestedSaveAndAssociateIfNew