feat: allow passing raw attribute objects into set/add association me… by raza-khan0108 · Pull Request #18152 · sequelize/sequelize · GitHub
Skip to content

feat: allow passing raw attribute objects into set/add association me…#18152

Open
raza-khan0108 wants to merge 1 commit intosequelize:mainfrom
raza-khan0108:feat/association-raw-array-support
Open

feat: allow passing raw attribute objects into set/add association me…#18152
raza-khan0108 wants to merge 1 commit intosequelize:mainfrom
raza-khan0108:feat/association-raw-array-support

Conversation

@raza-khan0108
Copy link
Copy Markdown

feat: allow passing raw attribute objects into set/add association methods (#10397)

Pull Request Checklist

  • Have you added new tests to prevent regressions?
  • If a documentation update is necessary, have you opened a PR to the documentation repository?
  • Did you update the typescript typings accordingly (if applicable)?
  • Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
  • Does the name of your PR follow our conventions?

Description of Changes

Closes #10397

Previously, set and add association mixins on HasMany and BelongsToMany only 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 call createXxx() 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 new createMany mixin — Sequelize handles the loop and bulk insertion automatically.

Changes

HasMany

  • set(sourceInstance, targets, options?) — now also accepts CreationAttributes<T> objects in the iterable. Plain objects are detected via isPlainObject, bulk-created with the FK and scope pre-applied via bulkCreate, 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 FK UPDATE is skipped for them (only issued for pre-existing persisted instances).
  • createMany(sourceInstance, records, options?) — new method. Accepts CreationAttributes<T>[], applies scope + FK to each record, and calls target.bulkCreate(...). Returns Promise<T[]>.

BelongsToMany

  • set() and add() — same raw-object support. Plain objects are bulkCreated on the target model; resulting instances go through #updateAssociations to wire up the junction table.
  • createMany(sourceInstance, records, options?) — new method. Bulk-creates target rows, then calls this.add(sourceInstance, newInstances) to create the junction table rows. Returns Promise<T[]>.

TypeScript

New exported types:

Type Description
HasManyCreateAssociationsMixin<T, ExcludedAttrs?> Mixin type for createMany on HasMany
HasManyCreateAssociationsMixinOptions<T> Options type (extends BulkCreateOptions)
BelongsToManyCreateAssociationsMixin<T> Mixin type for createMany on BelongsToMany
BelongsToManyCreateAssociationsMixinOptions<T> Options type (extends BulkCreateOptions)

Updated type signatures to accept CreationAttributes<T> in the input union:

  • HasManySetAssociationsMixin
  • HasManyAddAssociationsMixin
  • BelongsToManySetAssociationsMixin
  • BelongsToManyAddAssociationsMixin

Usage

// Instead of:
for (const img of req.body.ProductImages) {
  await product.createProductImage(img);
}

// Now you can do:
await product.setProductImages(req.body.ProductImages);  // replaces all
await product.addProductImages(req.body.ProductImages);  // appends
const images = await product.createProductImages(req.body.ProductImages); // bulk create + associate

Works the same for BelongsToMany:

await article.setAuthors([{ name: 'Alice' }, { name: 'Bob' }]);
await article.addAuthors([{ name: 'Carol' }]);
const authors = await article.createAuthors([{ name: 'Dave' }, { name: 'Eve' }]);

Mixed arrays (instances + PKs + raw objects) are also supported:

await product.setProductImages([existingImage, existingImage.id, { image: 'new.jpg' }]);

Tests

Integration tests added to:

  • packages/core/test/integration/associations/has-many-mixins.test.ts
  • packages/core/test/integration/associations/belongs-to-many-mixins.test.ts

Covering:

  • setAssociations with raw attribute objects
  • setAssociations with mixed instances + raw objects
  • addAssociations with raw attribute objects
  • createManyAssociations — bulk create and verify FK association
  • createManyAssociations — empty array early exit

List of Breaking Changes

None. All changes are fully backwards compatible:

  • Existing calls using instances or primary keys work exactly as before.
  • New plain-object detection only triggers when isPlainObject(item) && !(item instanceof TargetModel) — i.e. strictly for raw attribute objects.

…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HasMany and BelongsToMany set / add to accept CreationAttributes<T> and bulk-create them before associating.
  • Add new createMany association 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);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
...this.scope,
[this.foreignKey]: sourceInstance.get(this.sourceKey),
})) as CreationAttributes<T>[];
createdInstances = await this.target.bulkCreate(valuesWithScope, options);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
);

Copilot uses AI. Check for mistakes.
Comment on lines +678 to +704
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);
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
}

createdInstances = await this.target.bulkCreate(rawObjects, options);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 228 to 233
'addMultiple',
'remove',
'removeMultiple',
'create',
'createMany',
],
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 492 to 497
'addMultiple',
'remove',
'removeMultiple',
'create',
'createMany',
],
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 655 to +691
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);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 754 to 785
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) {
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +774 to +781
if (rawObjects.length > 0) {
if (this.scope) {
for (const raw of rawObjects) {
Object.assign(raw, this.scope);
}
}

createdInstances = await this.target.bulkCreate(rawObjects, options);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +986 to +993
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),
];
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow passing raw arrays into set and add instance methods

2 participants