fix: avoid wrong primary keys from bulkCreate with updateOnDuplicate/ignoreDuplicates by spokodev · Pull Request #18296 · sequelize/sequelize · GitHub
Skip to content

fix: avoid wrong primary keys from bulkCreate with updateOnDuplicate/ignoreDuplicates - #18296

Open
spokodev wants to merge 2 commits into
sequelize:mainfrom
spokodev:fix/bulkcreate-upsert-pk
Open

fix: avoid wrong primary keys from bulkCreate with updateOnDuplicate/ignoreDuplicates#18296
spokodev wants to merge 2 commits into
sequelize:mainfrom
spokodev:fix/bulkcreate-upsert-pk

Conversation

@spokodev

@spokodev spokodev commented Jul 30, 2026

Copy link
Copy Markdown

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

Addresses #18281.

On MySQL and MariaDB, bulkCreate() synthesises the primary keys of the returned instances from
insertId + affectedRows (packages/mysql/src/query.js, packages/mariadb/src/query.js). That
arithmetic assumes exactly one inserted row per submitted record.

updateOnDuplicate and ignoreDuplicates break the assumption: MySQL reports affectedRows as 2 per
updated row and 0 per ignored row, and LAST_INSERT_ID() is only the first generated id, so the
synthesised id range desynchronises from the instance array. Returned instances (which come back with
isNewRecord === false) then carry primary keys belonging to other rows, including rows outside
the batch. A later instance.destroy() / instance.update() silently hits the wrong row (the issue
documents a destroy() deleting a different record and an update() overwriting a pre-existing row
outside the batch).

Fix: when updateOnDuplicate or ignoreDuplicates is set, formatResults no longer fabricates
the contiguous id range; it returns the raw insertId instead, so the wrong-key path in model.js
(if (Array.isArray(results))) is skipped and no instance is given a fabricated key. Returning no
synthesised key is correct here: with the weighted affectedRows the real per-row keys cannot be
derived from the result header without a re-SELECT, and a missing key is safe where a wrong one
corrupts data. The plain bulkCreate path (no updateOnDuplicate/ignoreDuplicates) is unchanged:
every row goes through AUTO_INCREMENT, so the range stays contiguous and index-aligned.

Tests (packages/core/test/unit/dialects/mysql/query.test.js, no DB): using a mock ResultSetHeader,
both MySqlQuery and MariaDbQuery still synthesise the contiguous range for a plain bulkCreate, and
return the raw insertId (no fabricated range) under updateOnDuplicate / ignoreDuplicates. Failing
before the change, passing after; the full core unit suite stays green (2269 passing).

Related items from the issue (not in this PR)

The issue also lists two companion items. I kept this PR focused on the data-integrity bug and am
happy to fold either in here or handle as a follow-up, whichever you prefer:

  1. Capability-based carve-out. The block in packages/core/src/model.js that skips replacing a
    truthy primary key keys off the dialect allowlist ['mysql', 'mariadb']. Since
    supports.returnValues defaults to false, db2/ibmi/snowflake inherit it and oracle sets it
    explicitly, yet they are not in the list. Deriving the predicate from
    !this.sequelize.dialect.supports.returnValues covers all of them and is a no-op for mysql/mariadb
    (already returnValues: false):

    -        ['mysql', 'mariadb'].includes(dialect))
    +        !this.sequelize.dialect.supports.returnValues)
  2. updateOnDuplicate + primary key footgun. Listing the primary key in updateOnDuplicate but
    not in fields emits `id`=VALUES(`id`) and reassigns a matched row's primary key. Worth a
    validation or documentation note; separate from this fix.

Caveat on scope

The contiguous-range assumption in the plain path is only verified at auto_increment_increment = 1;
the existing comment in packages/mariadb/src/query.js notes it does not hold under Galera or a non-1
increment. This PR does not change that path, so it neither fixes nor worsens that case.

List of Breaking Changes

bulkCreate(..., { updateOnDuplicate }) / { ignoreDuplicates } on MySQL/MariaDB previously returned
instances with (often wrong) primary keys; they now come back without a synthesised primary key. This
removes silent wrong-row writes at the cost of not returning generated keys for these two options:
callers who need the keys should re-query by a unique column.

Summary by CodeRabbit

  • Bug Fixes
    • Improved bulk-create result handling for MySQL and MariaDB when duplicate-update or duplicate-ignore options are enabled.
    • Avoided generating synthetic auto-increment ID ranges in these scenarios; the database-provided insert ID is now returned instead.
    • Ensures consistent behavior for both regular numeric and large integer insert IDs.
  • Tests
    • Added unit coverage to validate primary-key/result formatting behavior across both MySQL/MariaDB dialects.

@spokodev
spokodev requested a review from a team as a code owner July 30, 2026 19:30
@spokodev
spokodev requested review from WikiRik and sdepold July 30, 2026 19:30
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/core/test/unit/dialects/mysql/query.test.js (1)

65-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the required dialect teaser in the nested suite description.

Replace the raw dialect name with Support.getTestDialectTeaser(...) for this dialect-specific test group.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/unit/dialects/mysql/query.test.js` around lines 65 - 66,
Update the nested describe block in the dialects test loop to use
Support.getTestDialectTeaser(...) with the current dialect value instead of the
raw name, while preserving the existing dialect-specific test grouping.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/test/unit/dialects/mysql/query.test.js`:
- Line 65: Update the destructuring pattern in the dialects loop so insertId
appears before Query, satisfying the required key ordering while preserving all
existing bindings and behavior.
- Around line 47-56: Update the test lifecycle around the model setup in the
before hook: store the dedicated Sequelize instance, create and sync it in an
async beforeEach using force: true, and add an async afterEach that awaits
closing that instance. Keep the formatResultsBulkCreate model definition
associated with the isolated instance.

In `@packages/mysql/src/query.js`:
- Around line 114-121: Move the new formatResults guard from the JavaScript
implementations into their TypeScript counterparts, preserving the
auto-increment primary-key check and updateOnDuplicate/ignoreDuplicates
exclusions. Apply this migration in packages/mysql/src/query.js (lines 114-121)
and packages/mariadb/src/query.js (lines 109-116), removing the added logic from
both JavaScript files.

---

Nitpick comments:
In `@packages/core/test/unit/dialects/mysql/query.test.js`:
- Around line 65-66: Update the nested describe block in the dialects test loop
to use Support.getTestDialectTeaser(...) with the current dialect value instead
of the raw name, while preserving the existing dialect-specific test grouping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f04f122e-34c3-4195-ac67-3d0663d6fec5

📥 Commits

Reviewing files that changed from the base of the PR and between 7e1deec and aaa8b85.

📒 Files selected for processing (3)
  • packages/core/test/unit/dialects/mysql/query.test.js
  • packages/mariadb/src/query.js
  • packages/mysql/src/query.js

Comment on lines +47 to +56
before(() => {
model = Support.createSequelizeInstance().define(
'formatResultsBulkCreate',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: DataTypes.STRING,
},
{ timestamps: false },
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use isolated test lifecycle hooks for the dedicated Sequelize instance.

Store the instance, create/sync it in beforeEach with { force: true }, and await its closure in afterEach. The current before hook neither syncs nor closes the instance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/unit/dialects/mysql/query.test.js` around lines 47 - 56,
Update the test lifecycle around the model setup in the before hook: store the
dedicated Sequelize instance, create and sync it in an async beforeEach using
force: true, and add an async afterEach that awaits closing that instance. Keep
the formatResultsBulkCreate model definition associated with the isolated
instance.

Source: Coding guidelines

Comment thread packages/core/test/unit/dialects/mysql/query.test.js Outdated
Comment on lines +114 to +121
modelDefinition?.autoIncrementAttributeName === this.model.primaryKeyAttribute &&
// `updateOnDuplicate`/`ignoreDuplicates` break the `insertId` + `affectedRows`
// arithmetic below: MySQL counts an updated row as 2 affected and an ignored one
// as 0, so the synthesised id range desynchronises from the inserted rows and
// returned instances get primary keys belonging to other rows. Fall back to the
// raw insertId in that case rather than fabricating a wrong range. See #18281.
!this.options.updateOnDuplicate &&
!this.options.ignoreDuplicates

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move the new dialect logic into TypeScript source. Both changes add implementation code under packages/**/src/**/*.js, contrary to the repository rule.

  • packages/mysql/src/query.js#L114-L121: migrate this new formatResults guard to the TypeScript implementation.
  • packages/mariadb/src/query.js#L109-L116: migrate the matching guard to the TypeScript implementation.
📍 Affects 2 files
  • packages/mysql/src/query.js#L114-L121 (this comment)
  • packages/mariadb/src/query.js#L109-L116
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mysql/src/query.js` around lines 114 - 121, Move the new
formatResults guard from the JavaScript implementations into their TypeScript
counterparts, preserving the auto-increment primary-key check and
updateOnDuplicate/ignoreDuplicates exclusions. Apply this migration in
packages/mysql/src/query.js (lines 114-121) and packages/mariadb/src/query.js
(lines 109-116), removing the added logic from both JavaScript files.

Source: Coding guidelines

@spokodev
spokodev force-pushed the fix/bulkcreate-upsert-pk branch from aaa8b85 to 8e3fc1a Compare July 30, 2026 19:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/test/unit/dialects/mysql/query.test.js`:
- Around line 65-66: Update the nested suite declaration inside the dialects
loop to use Support.getTestDialectTeaser() instead of passing name directly to
describe. Preserve the existing dialect-specific test structure and callback
contents.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 077b8dc9-88d1-4528-8b56-ab9d833c3d67

📥 Commits

Reviewing files that changed from the base of the PR and between aaa8b85 and 8e3fc1a.

📒 Files selected for processing (3)
  • packages/core/test/unit/dialects/mysql/query.test.js
  • packages/mariadb/src/query.js
  • packages/mysql/src/query.js

Comment on lines +65 to +66
for (const { id, insertId, name, Query } of dialects) {
describe(name, () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the dialect teaser for the nested suites. describe(name, ...) bypasses the required dialect-specific description helper.

Proposed fix
-      describe(name, () => {
+      describe(Support.getTestDialectTeaser(name), () => {

As per coding guidelines, “Use Support.getTestDialectTeaser() for dialect-specific test descriptions.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/unit/dialects/mysql/query.test.js` around lines 65 - 66,
Update the nested suite declaration inside the dialects loop to use
Support.getTestDialectTeaser() instead of passing name directly to describe.
Preserve the existing dialect-specific test structure and callback contents.

Source: Coding guidelines

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.

1 participant