feat: allow passing raw attribute objects into set/add association me…#18152
feat: allow passing raw attribute objects into set/add association me…#18152raza-khan0108 wants to merge 1 commit intosequelize:mainfrom
Conversation
…thods (sequelize#10397) - HasMany.set() and HasMany.add() now accept plain attribute objects alongside persisted instances and primary keys. Plain objects are detected via isPlainObject and bulk-created with the FK/scope pre-applied before the standard association flow. - HasMany.add() skips the FK UPDATE for newly created instances (already have FK set). - New HasMany.createMany() method for bulk-creating multiple associated instances. - BelongsToMany.set() and BelongsToMany.add() have the same raw-object support. - New BelongsToMany.createMany() bulk-creates targets and wires junction table rows. - Exported new mixin types: HasManyCreateAssociationsMixin, HasManyCreateAssociationsMixinOptions, BelongsToManyCreateAssociationsMixin, BelongsToManyCreateAssociationsMixinOptions. - Updated HasManySetAssociationsMixin, HasManyAddAssociationsMixin, BelongsToManySetAssociationsMixin, BelongsToManyAddAssociationsMixin type signatures to include CreationAttributes<T> in their input union. - Integration tests added for both HasMany and BelongsToMany covering raw object support in set/add and the new createMany mixin.
There was a problem hiding this comment.
Pull request overview
Adds support in core association mixins (hasMany, belongsToMany) for passing raw attribute objects to set / add, and introduces a new bulk-creation association mixin (createMany) to reduce boilerplate when working with request-body payloads.
Changes:
- Extend
HasManyandBelongsToManyset/addto acceptCreationAttributes<T>and bulk-create them before associating. - Add new
createManyassociation method + new exported TypeScript mixin types for both association kinds. - Add integration tests covering raw objects, mixed inputs, and empty-array behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| packages/core/src/associations/has-many.ts | Adds raw-object handling to set/add, adds createMany, and updates TS mixin types. |
| packages/core/src/associations/belongs-to-many.ts | Adds raw-object handling to set/add, adds createMany, and updates TS mixin types. |
| packages/core/test/integration/associations/has-many-mixins.test.ts | New integration coverage for raw-object inputs + createMany for hasMany. |
| packages/core/test/integration/associations/belongs-to-many-mixins.test.ts | New integration coverage for raw-object inputs + createMany for belongsToMany. |
| ...this.scope, | ||
| [this.foreignKey]: sourceInstance.get(this.sourceKey), | ||
| })) as CreationAttributes<T>[]; | ||
| createdInstances = await this.target.bulkCreate(valuesWithScope, options); |
There was a problem hiding this comment.
The new bulkCreate path passes the same options object through, but if callers supply options.fields (valid for these mixins), bulkCreate will only insert the listed fields. In that case the association FK and scope attributes you add to the values may be silently omitted from the INSERT, causing constraint failures (FK is NOT NULL) or creating rows that are not actually associated. Consider cloning options for bulkCreate and ensuring fields always includes this.foreignKey and the scope keys when raw objects are being created.
| createdInstances = await this.target.bulkCreate(valuesWithScope, options); | |
| const bulkCreateOptions: BulkCreateOptions<Attributes<T>> | undefined = options | |
| ? { ...options } | |
| : undefined; | |
| if (bulkCreateOptions?.fields) { | |
| const fieldSet = new Set<AttributeNames<T>>(bulkCreateOptions.fields as AttributeNames<T>[]); | |
| fieldSet.add(this.foreignKey as AttributeNames<T>); | |
| for (const scopeKey of Object.keys(this.scope)) { | |
| fieldSet.add(scopeKey as AttributeNames<T>); | |
| } | |
| bulkCreateOptions.fields = Array.from(fieldSet); | |
| } | |
| createdInstances = await this.target.bulkCreate(valuesWithScope, bulkCreateOptions); |
| ...this.scope, | ||
| [this.foreignKey]: sourceInstance.get(this.sourceKey), | ||
| })) as CreationAttributes<T>[]; | ||
| createdInstances = await this.target.bulkCreate(valuesWithScope, options); |
There was a problem hiding this comment.
Same issue in add: raw objects are created via bulkCreate(valuesWithScope, options), but if options.fields is provided and does not include the association foreign key / scope keys, those values will not be inserted. This is especially problematic here because newly created instances then skip the FK update path, leaving them unassociated. Ensure the bulkCreate call always includes this.foreignKey (and scope keys) in its fields option when present.
| createdInstances = await this.target.bulkCreate(valuesWithScope, options); | |
| const bulkCreateOptions: BulkCreateOptions<Attributes<T>> | undefined = options | |
| ? { ...options } | |
| : undefined; | |
| if (bulkCreateOptions?.fields) { | |
| const requiredFields = [ | |
| this.foreignKey, | |
| ...Object.keys(this.scope ?? {}), | |
| ]; | |
| const fieldSet = new Set(bulkCreateOptions.fields as Array<AttributeNames<T>>); | |
| for (const field of requiredFields) { | |
| if (!fieldSet.has(field as AttributeNames<T>)) { | |
| fieldSet.add(field as AttributeNames<T>); | |
| } | |
| } | |
| bulkCreateOptions.fields = Array.from(fieldSet) as Array<AttributeNames<T>>; | |
| } | |
| createdInstances = await this.target.bulkCreate( | |
| valuesWithScope, | |
| bulkCreateOptions as BulkCreateOptions<Attributes<T>> | undefined, | |
| ); |
| async createMany( | ||
| sourceInstance: S, | ||
| // @ts-expect-error -- {} is not always assignable to 'values', but Target.bulkCreate will enforce this, not us. | ||
| records: CreationAttributes<T>[], | ||
| options?: HasManyCreateAssociationsMixinOptions<T>, | ||
| ): Promise<T[]> { | ||
| if (!Array.isArray(records) || records.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| const valuesWithScope = records.map(record => { | ||
| const values = { ...record }; | ||
| if (this.scope) { | ||
| for (const attribute of Object.keys(this.scope)) { | ||
| // @ts-expect-error -- TODO: fix the typing of {@link AssociationScope} | ||
| values[attribute] = this.scope[attribute]; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| ...values, | ||
| [this.foreignKey]: sourceInstance.get(this.sourceKey), | ||
| }; | ||
| }) as CreationAttributes<T>[]; | ||
|
|
||
| return this.target.bulkCreate(valuesWithScope, options); | ||
| } |
There was a problem hiding this comment.
createMany does not mirror the single-create behavior of auto-extending options.fields to include the association foreign key (and scope keys). If a caller passes fields to createMany, the FK / scope values you add can be omitted from the INSERT, producing unassociated rows or constraint errors. Align this with create by ensuring options.fields contains the FK and scope keys when provided.
| } | ||
| } | ||
|
|
||
| createdInstances = await this.target.bulkCreate(rawObjects, options); |
There was a problem hiding this comment.
When raw attribute objects are bulkCreated here, association scope values are applied via Object.assign, but if the caller provides options.fields (allowed by the mixin options), those scope attributes may be omitted from the INSERT unless they are explicitly added to fields. The existing single-create method handles this by extending options.fields with Object.keys(this.scope); this raw-object bulkCreate path should do the same (ideally on a cloned options object to avoid mutating the caller's options).
| createdInstances = await this.target.bulkCreate(rawObjects, options); | |
| const bulkCreateOptions: BulkCreateOptions<Attributes<TargetModel>> | undefined = | |
| this.scope && options && options.fields | |
| ? { | |
| ...options, | |
| fields: [...options.fields, ...Object.keys(this.scope)], | |
| } | |
| : options; | |
| createdInstances = await this.target.bulkCreate(rawObjects, bulkCreateOptions); |
| 'addMultiple', | ||
| 'remove', | ||
| 'removeMultiple', | ||
| 'create', | ||
| 'createMany', | ||
| ], |
There was a problem hiding this comment.
createMany was added to the list of mixed-in methods, but this.accessors does not define a createMany entry (it currently only defines create for the singular). As a result, mixinMethods will read association.accessors.createMany as undefined and define the method under the wrong name ("undefined"), so instance.create<Plural>() won't exist at runtime. Add a createMany accessor (e.g. mapping to create${plural}) and ensure it matches the intended public mixin name.
| 'addMultiple', | ||
| 'remove', | ||
| 'removeMultiple', | ||
| 'create', | ||
| 'createMany', | ||
| ], |
There was a problem hiding this comment.
createMany was added to the list of mixed-in methods, but this.accessors does not define a createMany entry (it currently only defines create for the singular). This will cause mixinMethods to define a method with the wrong name ("undefined") instead of the intended create<Plural>() mixin. Add a createMany accessor (e.g. mapping to create${plural}) consistent with the public API and typings.
| async set( | ||
| sourceInstance: SourceModel, | ||
| newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>, | ||
| newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]> | CreationAttributes<TargetModel>>, | ||
| options: BelongsToManySetAssociationsMixinOptions<TargetModel> = {}, | ||
| ): Promise<void> { | ||
| const sourceKey = this.sourceKey; | ||
| const targetKey = this.targetKey; | ||
| const foreignKey = this.foreignKey; | ||
| const otherKey = this.otherKey; | ||
|
|
||
| const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys); | ||
| // Separate raw attribute objects from instances/PKs and create them first | ||
| const inputArray = isIterable(newInstancesOrPrimaryKeys) && isObject(newInstancesOrPrimaryKeys) | ||
| ? [...(newInstancesOrPrimaryKeys as Iterable<any>)] | ||
| : [newInstancesOrPrimaryKeys]; | ||
| const rawObjects: CreationAttributes<TargetModel>[] = []; | ||
| const persistedOrPk: Array<TargetModel | Exclude<TargetModel[TargetKey], any[]>> = []; | ||
| for (const item of inputArray) { | ||
| if (isPlainObject(item) && !(item instanceof this.target)) { | ||
| rawObjects.push(item as CreationAttributes<TargetModel>); | ||
| } else { | ||
| persistedOrPk.push(item as TargetModel | Exclude<TargetModel[TargetKey], any[]>); | ||
| } | ||
| } | ||
|
|
||
| let createdInstances: TargetModel[] = []; | ||
| if (rawObjects.length > 0) { | ||
| if (this.scope) { | ||
| for (const raw of rawObjects) { | ||
| Object.assign(raw, this.scope); | ||
| } | ||
| } | ||
|
|
||
| createdInstances = await this.target.bulkCreate(rawObjects, options); | ||
| } | ||
|
|
||
| const mergedInput = [...createdInstances, ...persistedOrPk]; | ||
| const newInstances = this.toInstanceArray(mergedInput); |
There was a problem hiding this comment.
set historically supported being called with null / undefined (the public mixin type allows null and the old implementation delegated to toInstanceArray, which treats nullish input as empty). The new normalization wraps null/undefined in an array, which will attempt to associate a target with a nullish PK instead of clearing associations. Add an early nullish check (treat as empty input) before splitting raw objects vs instances/PKs.
| async add( | ||
| sourceInstance: SourceModel, | ||
| newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]>>, | ||
| newInstancesOrPrimaryKeys: AllowIterable<TargetModel | Exclude<TargetModel[TargetKey], any[]> | CreationAttributes<TargetModel>>, | ||
| options?: BelongsToManyAddAssociationsMixinOptions<TargetModel>, | ||
| ): Promise<void> { | ||
| const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys); | ||
| // Separate raw attribute objects from instances/PKs and create them first | ||
| const inputArray = isIterable(newInstancesOrPrimaryKeys) && isObject(newInstancesOrPrimaryKeys) | ||
| ? [...(newInstancesOrPrimaryKeys as Iterable<any>)] | ||
| : [newInstancesOrPrimaryKeys]; | ||
| const rawObjects: CreationAttributes<TargetModel>[] = []; | ||
| const persistedOrPk: Array<TargetModel | Exclude<TargetModel[TargetKey], any[]>> = []; | ||
| for (const item of inputArray) { | ||
| if (isPlainObject(item) && !(item instanceof this.target)) { | ||
| rawObjects.push(item as CreationAttributes<TargetModel>); | ||
| } else { | ||
| persistedOrPk.push(item as TargetModel | Exclude<TargetModel[TargetKey], any[]>); | ||
| } | ||
| } | ||
|
|
||
| let createdInstances: TargetModel[] = []; | ||
| if (rawObjects.length > 0) { | ||
| if (this.scope) { | ||
| for (const raw of rawObjects) { | ||
| Object.assign(raw, this.scope); | ||
| } | ||
| } | ||
|
|
||
| createdInstances = await this.target.bulkCreate(rawObjects, options); | ||
| } | ||
|
|
||
| const newInstances = this.toInstanceArray([...createdInstances, ...persistedOrPk]); | ||
| if (newInstances.length === 0) { |
There was a problem hiding this comment.
add can be invoked with no arguments through the generated mixin (the public BelongsToManyAddAssociationsMixin parameter is optional), which results in newInstancesOrPrimaryKeys being undefined. The new normalization wraps undefined in an array and will attempt to build an instance with an undefined PK, potentially creating invalid through-table rows or queries. Handle null/undefined as an empty input and return early (matching the previous behavior of toInstanceArray).
| if (rawObjects.length > 0) { | ||
| if (this.scope) { | ||
| for (const raw of rawObjects) { | ||
| Object.assign(raw, this.scope); | ||
| } | ||
| } | ||
|
|
||
| createdInstances = await this.target.bulkCreate(rawObjects, options); |
There was a problem hiding this comment.
Same as set: this raw-object bulkCreate path applies this.scope to the values but does not ensure options.fields includes the scope keys. If fields is provided without them, the created targets won't get the scope values and may not be returned by future association queries. Extend fields with the scope keys (once) before calling bulkCreate, preferably using a cloned options object.
| if (rawObjects.length > 0) { | |
| if (this.scope) { | |
| for (const raw of rawObjects) { | |
| Object.assign(raw, this.scope); | |
| } | |
| } | |
| createdInstances = await this.target.bulkCreate(rawObjects, options); | |
| let bulkCreateOptions: BulkCreateOptions<Attributes<TargetModel>> | undefined = options; | |
| if (rawObjects.length > 0) { | |
| if (this.scope) { | |
| for (const raw of rawObjects) { | |
| Object.assign(raw, this.scope); | |
| } | |
| if (options?.fields) { | |
| const scopeKeys = Object.keys(this.scope); | |
| const combinedFields = new Set<string | keyof Attributes<TargetModel>>([ | |
| ...options.fields, | |
| ...scopeKeys, | |
| ]); | |
| bulkCreateOptions = { | |
| ...options, | |
| fields: Array.from(combinedFields), | |
| }; | |
| } | |
| } | |
| createdInstances = await this.target.bulkCreate(rawObjects, bulkCreateOptions); |
| for (const record of records) { | ||
| Object.assign(record, this.scope); | ||
| if ((options as { fields?: string[] })?.fields) { | ||
| (options as { fields?: string[] }).fields = [ | ||
| ...((options as { fields?: string[] }).fields ?? []), | ||
| ...Object.keys(this.scope), | ||
| ]; | ||
| } |
There was a problem hiding this comment.
In createMany, when this.scope is set and options.fields is provided, the code appends the scope keys to options.fields inside the per-record loop. This can repeatedly grow options.fields (with duplicates) proportional to records.length, causing unnecessary overhead in bulkCreate for large batches. Update options.fields once outside the loop and dedupe/union the keys so the list stays stable.

feat: allow passing raw attribute objects into set/add association methods (#10397)
Pull Request Checklist
Description of Changes
Closes #10397
Previously,
setandaddassociation mixins onHasManyandBelongsToManyonly accepted persisted model instances or primary key values. If you had a raw array of attribute objects (e.g. from a POST body), you had to loop manually and callcreateXxx()on each item.This PR allows raw attribute objects (plain JS objects that are not model instances) to be passed directly into
set,add, and a newcreateManymixin — Sequelize handles the loop and bulk insertion automatically.Changes
HasManyset(sourceInstance, targets, options?)— now also acceptsCreationAttributes<T>objects in the iterable. Plain objects are detected viaisPlainObject, bulk-created with the FK and scope pre-applied viabulkCreate, then merged with any persisted instances/PKs before the standard diff-and-update flow.add(sourceInstance, targets, options?)— same raw-object detection. Newly created instances already have their FK set, so the subsequent FKUPDATEis skipped for them (only issued for pre-existing persisted instances).createMany(sourceInstance, records, options?)— new method. AcceptsCreationAttributes<T>[], applies scope + FK to each record, and callstarget.bulkCreate(...). ReturnsPromise<T[]>.BelongsToManyset()andadd()— same raw-object support. Plain objects arebulkCreated on the target model; resulting instances go through#updateAssociationsto wire up the junction table.createMany(sourceInstance, records, options?)— new method. Bulk-creates target rows, then callsthis.add(sourceInstance, newInstances)to create the junction table rows. ReturnsPromise<T[]>.TypeScript
New exported types:
HasManyCreateAssociationsMixin<T, ExcludedAttrs?>createManyon HasManyHasManyCreateAssociationsMixinOptions<T>BulkCreateOptions)BelongsToManyCreateAssociationsMixin<T>createManyon BelongsToManyBelongsToManyCreateAssociationsMixinOptions<T>BulkCreateOptions)Updated type signatures to accept
CreationAttributes<T>in the input union:HasManySetAssociationsMixinHasManyAddAssociationsMixinBelongsToManySetAssociationsMixinBelongsToManyAddAssociationsMixinUsage
Works the same for
BelongsToMany:Mixed arrays (instances + PKs + raw objects) are also supported:
Tests
Integration tests added to:
packages/core/test/integration/associations/has-many-mixins.test.tspackages/core/test/integration/associations/belongs-to-many-mixins.test.tsCovering:
setAssociationswith raw attribute objectssetAssociationswith mixed instances + raw objectsaddAssociationswith raw attribute objectscreateManyAssociations— bulk create and verify FK associationcreateManyAssociations— empty array early exitList of Breaking Changes
None. All changes are fully backwards compatible:
isPlainObject(item) && !(item instanceof TargetModel)— i.e. strictly for raw attribute objects.