Skip to content

fix(upsert): prevent input entity mutation during upsert result merge#12155

Open
mbursali93 wants to merge 6 commits intotypeorm:masterfrom
mbursali93:fix/12094-upsert-mutation
Open

fix(upsert): prevent input entity mutation during upsert result merge#12155
mbursali93 wants to merge 6 commits intotypeorm:masterfrom
mbursali93:fix/12094-upsert-mutation

Conversation

@mbursali93
Copy link
Copy Markdown

Upsert queries (INSERT ... ON DUPLICATE KEY UPDATE) internally use
ReturningResultsEntityUpdator which merges generated values into
the provided entity.

This mutates the input object passed to repository.upsert().

This change skips merging generated values into the input entity
when expressionMap.onUpdate is set.

Insert behavior remains unchanged.

Added tests to verify:

  • upsert does not mutate entity on insert
  • upsert does not mutate entity on update
  • normal insert still mutates entity

Fixes #12094

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Prevent input entity mutation in upsert operations

🐞 Bug fix 🧪 Tests

Grey Divider

Walkthroughs

Description
• Prevent input entity mutation during upsert result merge
• Skip merging generated values when onUpdate is set
• Add comprehensive test coverage for upsert behavior
  - Verify no mutation on insert operation
  - Verify no mutation on update operation
  - Verify normal insert still mutates entity
Diagram
flowchart LR
  A["Upsert Operation"] --> B["ReturningResultsEntityUpdator"]
  B --> C{"expressionMap.onUpdate set?"}
  C -->|Yes| D["Skip merge to input entity"]
  C -->|No| E["Merge generated values"]
  D --> F["Return generated map"]
  E --> F
Loading

Grey Divider

File Changes

1. src/query-builder/ReturningResultsEntityUpdator.ts 🐞 Bug fix +7/-5

Conditionally skip entity merge on upsert update

• Wrapped entity merge operation with conditional check for expressionMap.onUpdate
• Prevents merging generated values into input entity during upsert update operations
• Preserves existing behavior for insert operations

src/query-builder/ReturningResultsEntityUpdator.ts


2. test/github-issues/12094/entity/User.ts 🧪 Tests +13/-0

Add User entity for upsert tests

• Created test entity with auto-generated id, name, and email columns
• Supports MySQL upsert testing with email as unique key

test/github-issues/12094/entity/User.ts


3. test/github-issues/12094/issue-12094.test.ts 🧪 Tests +84/-0

Add comprehensive upsert mutation tests

• Test verifying upsert insert does not mutate input entity
• Test verifying upsert update does not mutate input entity
• Test verifying normal insert still mutates entity with generated id
• Uses MySQL datasource with proper setup and teardown

test/github-issues/12094/issue-12094.test.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects bot commented Mar 9, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (3) 📎 Requirement gaps (0)

Grey Divider


Action required

1. describe.only committed 📘 Rule violation ⛯ Reliability
Description
The new test suite uses describe.only, which will exclude the rest of the test suite when running
tests. This can mask failures and break CI expectations.
Code

test/github-issues/12094/issue-12094.test.ts[11]

+describe.only("github issues > #12094", () => {
Evidence
Compliance ID 4 forbids abnormal test/debug patterns; describe.only is a common debugging artifact
that changes test runner behavior by running only this suite.

Rule 4: Remove AI-generated noise
test/github-issues/12094/issue-12094.test.ts[11-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A focused test run directive (`describe.only`) was committed, causing only this suite to execute and potentially hiding failures elsewhere.
## Issue Context
CI and local runs should execute the full test suite; `.only` should not be present in committed code.
## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[11-11]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Upsert update test invalid 🐞 Bug ✓ Correctness
Description
The test case meant to verify the “upsert performs update” scenario uses email as the conflict
path, but email is not unique in the test entity. As a result, MySQL may never hit the
duplicate-key update path, so the test can pass without exercising the intended behavior.
Code

test/github-issues/12094/entity/User.ts[R8-12]

+    @Column()
+    name: string
+
+    @Column()
+    email: string
Evidence
The User.email column is a plain column (no unique constraint), yet the test uses
repo.upsert(input, ['email']) and expects that the second upsert performs an update. Without a
unique index/PK on email, the database may insert a second row instead of updating, so the test
does not guarantee coverage of the update path.

test/github-issues/12094/entity/User.ts[8-13]
test/github-issues/12094/issue-12094.test.ts[52-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The “upsert performs update” test may not actually perform an update because the conflict column (`email`) is not unique.
### Issue Context
MySQL’s duplicate-key update path requires a unique index/PK to trigger.
### Fix Focus Areas
- test/github-issues/12094/entity/User.ts[1-13]
- test/github-issues/12094/issue-12094.test.ts[52-71]
### Suggested test changes
- Add a uniqueness constraint, e.g. `@Column({ unique: true }) email: string`.
- After the upsert, verify the update path happened:
- `expect(await repo.count()).to.equal(1)`
- `expect((await repo.findOneByOrFail({ email: "[email protected]" })).name).to.equal("After")`
- Keep the existing assertion that the *input object* was not mutated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Tests only in github-issues 📘 Rule violation ⛯ Reliability
Description
This issue fix adds coverage only under test/github-issues/12094 instead of the functional test
suite. This violates the guideline to keep issue fixes in test/functional unless there is a clear
reason.
Code

test/github-issues/12094/issue-12094.test.ts[R1-84]

+import { expect } from "chai"
+import { DataSource } from "../../../src"
+import {
+    closeTestingConnections,
+    reloadTestingDatabases,
+    setupSingleTestingConnection,
+} from "../../utils/test-utils"
+import type { MysqlDataSourceOptions } from "../../../src/driver/mysql/MysqlDataSourceOptions"
+import { User } from "./entity/User"
+
+describe("github issues > #12094", () => {
+    let dataSource: DataSource
+
+    before(async () => {
+        const options = setupSingleTestingConnection("mysql", {
+            entities: [User],
+        }) as MysqlDataSourceOptions
+
+        if (!options) return
+
+        dataSource = new DataSource({
+            ...options,
+            replication: undefined,
+        })
+
+        await dataSource.initialize()
+    })
+
+    beforeEach(async () => {
+        if (!dataSource) return
+        await reloadTestingDatabases([dataSource])
+    })
+
+    after(() => closeTestingConnections([dataSource]))
+
+    it("should not mutate input entity when upsert performs insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            email: "[email protected]",
+            name: "Test",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "Test",
+        })
+    })
+
+    it("should not mutate input entity when upsert performs update", async () => {
+        const repo = dataSource.getRepository(User)
+
+        await repo.insert({
+            email: "[email protected]",
+            name: "Before",
+        })
+
+        const input = {
+            email: "[email protected]",
+            name: "After",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "After",
+        })
+    })
+
+    it("should still mutate entity on normal insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            name: "Test",
+            email: "[email protected]",
+        }
+
+        await repo.insert(input)
+        expect(input).to.have.property("id")
+    })
+})
Evidence
PR Compliance ID 3 requires issue fixes to be added/updated in test/functional; the new tests were
added under test/github-issues/12094 with no indication they are also covered in functional tests.

Rule 3: Prefer functional tests over per-issue tests
test/github-issues/12094/issue-12094.test.ts[11-13]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Tests for this fix were added under `test/github-issues/12094`, but the compliance checklist requires issue fixes to live in the functional test suite (`test/functional`) unless there's a clear reason.
## Issue Context
The PR adds new regression coverage for #12094 around `repository.upsert()` mutating input entities.
## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[1-84]
- test/github-issues/12094/entity/User.ts[1-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Upsert loses generated IDs 🐞 Bug ✓ Correctness
Description
When expressionMap.onUpdate is set, generated primary keys are no longer merged into the entity, but
later logic still reads the entity’s primary key to build InsertResult.identifiers and to perform
the fallback re-select. This can produce undefined identifiers or throw a TypeORMError during upsert
inserts that require a reselect (DEFAULT/createDate/etc).
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
The PR skips merging generatedMap into the provided entity for onUpdate (upsert/orUpdate), but later
code still uses metadata.getEntityIdMap(entity) for (1) the fallback re-select criteria and (2)
InsertResult.identifiers. Since getEntityIdMap returns undefined when PK fields are missing, upsert
inserts with generated PKs can fail or return incorrect identifiers, especially when
insertionColumns includes DEFAULT/createDate/etc and triggers the fallback path.

src/query-builder/ReturningResultsEntityUpdator.ts[190-212]
src/query-builder/ReturningResultsEntityUpdator.ts[219-237]
src/query-builder/ReturningResultsEntityUpdator.ts[279-283]
src/metadata/EntityMetadata.ts[1162-1173]
src/metadata/EntityMetadata.ts[645-653]
src/metadata/EntityMetadata.ts[985-1001]
src/query-builder/InsertQueryBuilder.ts[172-183]
src/entity-manager/EntityManager.ts[769-786]
src/query-builder/InsertQueryBuilder.ts[441-463]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ReturningResultsEntityUpdator.insert()` now skips `manager.merge(entity, generatedMap)` when `expressionMap.onUpdate` is set (upsert/orUpdate). But the method still calls `metadata.getEntityIdMap(entity)` later to:
1) build `entityIds` for the fallback re-select (drivers without INSERT RETURNING), and
2) populate `insertResult.identifiers`.
If the primary key is generated (common) and wasn’t provided in the input object, `getEntityIdMap(entity)` becomes `undefined`, which can either throw (fallback reselect path) or yield `undefined` identifiers.
### Issue Context
- Upsert uses `InsertQueryBuilder.orUpdate()`, which sets `expressionMap.onUpdate`.
- Insert result processing still needs entity IDs even if we choose not to mutate the caller’s object.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[180-283]
- src/query-builder/InsertQueryBuilder.ts[421-464]
### Implementation sketch
- Build a temporary object per row for ID derivation (do **not** mutate `entity`):
- `const entityForId = this.queryRunner.manager.merge(metadata.target as any, {}, entity, generatedMap)` (or a shallow clone if safe),
- Use `metadata.getEntityIdMap(entityForId)` for `entityIds` and `insertResult.identifiers`.
- In the fallback reselect branch, after loading `returningResult[entityIndex]`, update `generatedMaps[entityIndex]` as today, and optionally also include those values in `entityForId` (again, without mutating the original) when computing identifiers.
- Add a regression test covering an entity with generated PK + `@CreateDateColumn()` (or a column default) using `upsert()` on a non-RETURNING driver like MySQL; assert it doesn’t throw and that `InsertResult.identifiers[0]` is populated while the input object remains unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. upsert mutation change undocumented 📘 Rule violation ✓ Correctness
Description
The PR changes a user-observable behavior by preventing generated values from being merged into the
input entity during certain upsert updates. No documentation/samples were updated to reflect this
behavioral change.
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
PR Compliance ID 2 requires docs/sample updates for user-facing changes; the PR explicitly changes
upsert input-mutation behavior and the repository upsert documentation section does not mention
entity mutation semantics.

Rule 2: Docs updated for user-facing changes
src/query-builder/ReturningResultsEntityUpdator.ts[206-212]
docs/docs/working-with-entity-manager/6-repository-api.md[175-210]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR changes `repository.upsert()` input mutation behavior (generated values are no longer merged into the provided entity for certain update-style upserts), which is user-facing but not reflected in docs/samples.
## Issue Context
Users may rely on the prior side-effect (mutated input entity containing generated values like ids/defaults). The docs currently describe upsert usage and returned values, but not whether the input object is mutated.
## Fix Focus Areas
- docs/docs/working-with-entity-manager/6-repository-api.md[175-210]
- src/query-builder/ReturningResultsEntityUpdator.ts[206-212]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Upsert reselect mutates input 🐞 Bug ✓ Correctness
Description
In the no-RETURNING fallback path, the code still merges reselected values back into the original
entity, even for upsert/orUpdate operations. This means upsert can still mutate the caller’s object
for entities with DEFAULT/createDate/updateDate/version columns (when the entityId is present).
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
The PR only guards the initial merge of generatedMap into entity. If insertionColumns is non-empty
(defaults/createDate/etc) and the driver lacks INSERT RETURNING, a re-select is performed and the
results are merged into the original entity without checking onUpdate, which can still mutate input
objects for upsert.

src/query-builder/ReturningResultsEntityUpdator.ts[219-276]
src/metadata/EntityMetadata.ts[1162-1173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Even after skipping `merge(entity, generatedMap)` for upsert/orUpdate, the fallback reselect branch still executes `merge(entity, returningResult)` unconditionally, which can mutate the caller’s input object for entities that trigger `getInsertionReturningColumns()` (DEFAULT/createDate/updateDate/version/etc).
### Issue Context
This is most visible on drivers without INSERT RETURNING (e.g., MySQL) where the ORM reselects inserted rows to retrieve default/generated values.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[219-276]
### Implementation sketch
- Wrap the `merge(..., entity, returningResult[entityIndex])` call with the same `if (!this.expressionMap.onUpdate)` guard (or invert it and explicitly handle the upsert case).
- Ensure `InsertResult.generatedMaps` still receives the merged values (`generatedMaps[entityIndex]` merge can remain).
- If identifiers need those values, compute them from a temporary merged object instead of mutating `entity`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

7. MySQL test may not skip 🐞 Bug ⛯ Reliability
Description
If MySQL isn’t configured/enabled in the test environment, the suite’s before() hook returns early
leaving dataSource undefined, but the it() blocks still dereference it. This can cause avoidable
test failures in environments that don’t run MySQL.
Code

test/github-issues/12094/issue-12094.test.ts[R14-27]

+    before(async () => {
+        const options = setupSingleTestingConnection("mysql", {
+            entities: [User],
+        }) as MysqlDataSourceOptions
+
+        if (!options) return
+
+        dataSource = new DataSource({
+            ...options,
+            replication: undefined,
+        })
+
+        await dataSource.initialize()
+    })
Evidence
The before() hook explicitly allows options to be falsy and returns without initializing
dataSource. The test cases then call dataSource.getRepository(...) without guarding, which would
throw if MySQL isn’t available.

test/github-issues/12094/issue-12094.test.ts[14-27]
test/github-issues/12094/issue-12094.test.ts[36-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test suite can dereference an uninitialized `dataSource` when MySQL config is absent.
### Issue Context
The `before()` hook returns early on missing options, but the tests don’t guard.
### Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[14-27]
- test/github-issues/12094/issue-12094.test.ts[36-83]
### Suggested fix
- Add `if (!dataSource) return` at the beginning of each `it(...)` block (or refactor to skip the suite when `options` is missing).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Grey Divider

Previous review results

Review updated until commit da1235d

Results up to commit 6a0542e


🐞 Bugs (4) 📘 Rule violations (3) 📎 Requirement gaps (0)

Grey Divider
Action required
1. describe.only committed 📘 Rule violation ⛯ Reliability
Description
The new test suite uses describe.only, which will exclude the rest of the test suite when running
tests. This can mask failures and break CI expectations.
Code

test/github-issues/12094/issue-12094.test.ts[11]

+describe.only("github issues > #12094", () => {
Evidence
Compliance ID 4 forbids abnormal test/debug patterns; describe.only is a common debugging artifact
that changes test runner behavior by running only this suite.

Rule 4: Remove AI-generated noise
test/github-issues/12094/issue-12094.test.ts[11-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A focused test run directive (`describe.only`) was committed, causing only this suite to execute and potentially hiding failures elsewhere.
## Issue Context
CI and local runs should execute the full test suite; `.only` should not be present in committed code.
## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[11-11]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Upsert update test invalid 🐞 Bug ✓ Correctness
Description
The test case meant to verify the “upsert performs update” scenario uses email as the conflict
path, but email is not unique in the test entity. As a result, MySQL may never hit the
duplicate-key update path, so the test can pass without exercising the intended behavior.
Code

test/github-issues/12094/entity/User.ts[R8-12]

+    @Column()
+    name: string
+
+    @Column()
+    email: string
Evidence
The User.email column is a plain column (no unique constraint), yet the test uses
repo.upsert(input, ['email']) and expects that the second upsert performs an update. Without a
unique index/PK on email, the database may insert a second row instead of updating, so the test
does not guarantee coverage of the update path.

test/github-issues/12094/entity/User.ts[8-13]
test/github-issues/12094/issue-12094.test.ts[52-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The “upsert performs update” test may not actually perform an update because the conflict column (`email`) is not unique.
### Issue Context
MySQL’s duplicate-key update path requires a unique index/PK to trigger.
### Fix Focus Areas
- test/github-issues/12094/entity/User.ts[1-13]
- test/github-issues/12094/issue-12094.test.ts[52-71]
### Suggested test changes
- Add a uniqueness constraint, e.g. `@Column({ unique: true }) email: string`.
- After the upsert, verify the update path happened:
- `expect(await repo.count()).to.equal(1)`
- `expect((await repo.findOneByOrFail({ email: "[email protected]" })).name).to.equal("After")`
- Keep the existing assertion that the *input object* was not mutated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Tests only in github-issues 📘 Rule violation ⛯ Reliability
Description
This issue fix adds coverage only under test/github-issues/12094 instead of the functional test
suite. This violates the guideline to keep issue fixes in test/functional unless there is a clear
reason.
Code

test/github-issues/12094/issue-12094.test.ts[R1-84]

+import { expect } from "chai"
+import { DataSource } from "../../../src"
+import {
+    closeTestingConnections,
+    reloadTestingDatabases,
+    setupSingleTestingConnection,
+} from "../../utils/test-utils"
+import type { MysqlDataSourceOptions } from "../../../src/driver/mysql/MysqlDataSourceOptions"
+import { User } from "./entity/User"
+
+describe("github issues > #12094", () => {
+    let dataSource: DataSource
+
+    before(async () => {
+        const options = setupSingleTestingConnection("mysql", {
+            entities: [User],
+        }) as MysqlDataSourceOptions
+
+        if (!options) return
+
+        dataSource = new DataSource({
+            ...options,
+            replication: undefined,
+        })
+
+        await dataSource.initialize()
+    })
+
+    beforeEach(async () => {
+        if (!dataSource) return
+        await reloadTestingDatabases([dataSource])
+    })
+
+    after(() => closeTestingConnections([dataSource]))
+
+    it("should not mutate input entity when upsert performs insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            email: "[email protected]",
+            name: "Test",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "Test",
+        })
+    })
+
+    it("should not mutate input entity when upsert performs update", async () => {
+        const repo = dataSource.getRepository(User)
+
+        await repo.insert({
+            email: "[email protected]",
+            name: "Before",
+        })
+
+        const input = {
+            email: "[email protected]",
+            name: "After",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "After",
+        })
+    })
+
+    it("should still mutate entity on normal insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            name: "Test",
+            email: "[email protected]",
+        }
+
+        await repo.insert(input)
+        expect(input).to.have.property("id")
+    })
+})
Evidence
PR Compliance ID 3 requires issue fixes to be added/updated in test/functional; the new tests were
added under test/github-issues/12094 with no indication they are also covered in functional tests.

Rule 3: Prefer functional tests over per-issue tests
test/github-issues/12094/issue-12094.test.ts[11-13]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Tests for this fix were added under `test/github-issues/12094`, but the compliance checklist requires issue fixes to live in the functional test suite (`test/functional`) unless there's a clear reason.
## Issue Context
The PR adds new regression coverage for #12094 around `repository.upsert()` mutating input entities.
## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[1-84]
- test/github-issues/12094/entity/User.ts[1-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Upsert loses generated IDs 🐞 Bug ✓ Correctness
Description
When expressionMap.onUpdate is set, generated primary keys are no longer merged into the entity, but
later logic still reads the entity’s primary key to build InsertResult.identifiers and to perform
the fallback re-select. This can produce undefined identifiers or throw a TypeORMError during upsert
inserts that require a reselect (DEFAULT/createDate/etc).
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
The PR skips merging generatedMap into the provided entity for onUpdate (upsert/orUpdate), but later
code still uses metadata.getEntityIdMap(entity) for (1) the fallback re-select criteria and (2)
InsertResult.identifiers. Since getEntityIdMap returns undefined when PK fields are missing, upsert
inserts with generated PKs can fail or return incorrect identifiers, especially when
insertionColumns includes DEFAULT/createDate/etc and triggers the fallback path.

src/query-builder/ReturningResultsEntityUpdator.ts[190-212]
src/query-builder/ReturningResultsEntityUpdator.ts[219-237]
src/query-builder/ReturningResultsEntityUpdator.ts[279-283]
src/metadata/EntityMetadata.ts[1162-1173]
src/metadata/EntityMetadata.ts[645-653]
src/metadata/EntityMetadata.ts[985-1001]
src/query-builder/InsertQueryBuilder.ts[172-183]
src/entity-manager/EntityManager.ts[769-786]
src/query-builder/InsertQueryBuilder.ts[441-463]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ReturningResultsEntityUpdator.insert()` now skips `manager.merge(entity, generatedMap)` when `expressionMap.onUpdate` is set (upsert/orUpdate). But the method still calls `metadata.getEntityIdMap(entity)` later to:
1) build `entityIds` for the fallback re-select (drivers without INSERT RETURNING), and
2) populate `insertResult.identifiers`.
If the primary key is generated (common) and wasn’t provided in the input object, `getEntityIdMap(entity)` becomes `undefined`, which can either throw (fallback reselect path) or yield `undefined` identifiers.
### Issue Context
- Upsert uses `InsertQueryBuilder.orUpdate()`, which sets `expressionMap.onUpdate`.
- Insert result processing still needs entity IDs even if we choose not to mutate the caller’s object.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[180-283]
- src/query-builder/InsertQueryBuilder.ts[421-464]
### Implementation sketch
- Build a temporary object per row for ID derivation (do **not** mutate `entity`):
- `const entityForId = this.queryRunner.manager.merge(metadata.target as any, {}, entity, generatedMap)` (or a shallow clone if safe),
- Use `metadata.getEntityIdMap(entityForId)` for `entityIds` and `insertResult.identifiers`.
- In the fallback reselect branch, after loading `returningResult[entityIndex]`, update `generatedMaps[entityIndex]` as today, and optionally also include those values in `entityForId` (again, without mutating the original) when computing identifiers.
- Add a regression test covering an entity with generated PK + `@CreateDateColumn()` (or a column default) using `upsert()` on a non-RETURNING driver like MySQL; assert it doesn’t throw and that `InsertResult.identifiers[0]` is populated while the input object remains unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
5. upsert mutation change undocumented 📘 Rule violation ✓ Correctness
Description
The PR changes a user-observable behavior by preventing generated values from being merged into the
input entity during certain upsert updates. No documentation/samples were updated to reflect this
behavioral change.
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
PR Compliance ID 2 requires docs/sample updates for user-facing changes; the PR explicitly changes
upsert input-mutation behavior and the repository upsert documentation section does not mention
entity mutation semantics.

Rule 2: Docs updated for user-facing changes
src/query-builder/ReturningResultsEntityUpdator.ts[206-212]
docs/docs/working-with-entity-manager/6-repository-api.md[175-210]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR changes `repository.upsert()` input mutation behavior (generated values are no longer merged into the provided entity for certain update-style upserts), which is user-facing but not reflected in docs/samples.
## Issue Context
Users may rely on the prior side-effect (mutated input entity containing generated values like ids/defaults). The docs currently describe upsert usage and returned values, but not whether the input object is mutated.
## Fix Focus Areas
- docs/docs/working-with-entity-manager/6-repository-api.md[175-210]
- src/query-builder/ReturningResultsEntityUpdator.ts[206-212]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Upsert reselect mutates input 🐞 Bug ✓ Correctness
Description
In the no-RETURNING fallback path, the code still merges reselected values back into the original
entity, even for upsert/orUpdate operations. This means upsert can still mutate the caller’s object
for entities with DEFAULT/createDate/updateDate/version columns (when the entityId is present).
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
The PR only guards the initial merge of generatedMap into entity. If insertionColumns is non-empty
(defaults/createDate/etc) and the driver lacks INSERT RETURNING, a re-select is performed and the
results are merged into the original entity without checking onUpdate, which can still mutate input
objects for upsert.

src/query-builder/ReturningResultsEntityUpdator.ts[219-276]
src/metadata/EntityMetadata.ts[1162-1173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Even after skipping `merge(entity, generatedMap)` for upsert/orUpdate, the fallback reselect branch still executes `merge(entity, returningResult)` unconditionally, which can mutate the caller’s input object for entities that trigger `getInsertionReturningColumns()` (DEFAULT/createDate/updateDate/version/etc).
### Issue Context
This is most visible on drivers without INSERT RETURNING (e.g., MySQL) where the ORM reselects inserted rows to retrieve default/generated values.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[219-276]
### Implementation sketch
- Wrap the `merge(..., entity, returningResult[entityIndex])` call with the same `if (!this.expressionMap.onUpdate)` guard (or invert it and explicitly handle the upsert case).
- Ensure `InsertResult.generatedMaps` still receives the merged values (`generatedMaps[entityIndex]` merge can remain).
- If identifiers need those values, compute them from a temporary merged object instead of mutating `entity`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments
7. MySQL test may not skip 🐞 Bug ⛯ Reliability
Description
If MySQL isn’t configured/enabled in the test environment, the suite’s before() hook returns early
leaving dataSource undefined, but the it() blocks still dereference it. This can cause avoidable
test failures in environments that don’t run MySQL.
Code

test/github-issues/12094/issue-12094.test.ts[R14-27]

+    before(async () => {
+        const options = setupSingleTestingConnection("mysql", {
+            entities: [User],
+        }) as MysqlDataSourceOptions
+
+        if (!options) return
+
+        dataSource = new DataSource({
+            ...options,
+            replication: undefined,
+        })
+
+        await dataSource.initialize()
+    })
Evidence
The before() hook explicitly allows options to be falsy and returns without initializing
dataSource. The test cases then call dataSource.getRepository(...) without guarding, which would
throw if MySQL isn’t available.

test/github-issues/12094/issue-12094.test.ts[14-27]
test/github-issues/12094/issue-12094.test.ts[36-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test suite can dereference an uninitialized `dataSource` when MySQL config is absent.
### Issue Context
The `before()` hook returns early on missing options, but the tests don’t guard.
### Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[14-27]
- test/github-issues/12094/issue-12094.test.ts[36-83]
### Suggested fix
- Add `if (!dataSource) return` at the beginning of each `it(...)` block (or refactor to skip the suite when `options` is missing).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider Grey Divider
Results up to commit 9227b18


🐞 Bugs (4) 📘 Rule violations (3) 📎 Requirement gaps (0)

Grey Divider
Action required
1. describe.only committed 📘 Rule violation ⛯ Reliability ⭐ New
Description
The new test suite uses describe.only, which will exclude the rest of the test suite when running
tests. This can mask failures and break CI expectations.
Code

test/github-issues/12094/issue-12094.test.ts[11]

+describe.only("github issues > #12094", () => {
Evidence
Compliance ID 4 forbids abnormal test/debug patterns; describe.only is a common debugging artifact
that changes test runner behavior by running only this suite.

Rule 4: Remove AI-generated noise
test/github-issues/12094/issue-12094.test.ts[11-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A focused test run directive (`describe.only`) was committed, causing only this suite to execute and potentially hiding failures elsewhere.

## Issue Context
CI and local runs should execute the full test suite; `.only` should not be present in committed code.

## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[11-11]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Upsert update test invalid 🐞 Bug ✓ Correctness
Description
The test case meant to verify the “upsert performs update” scenario uses email as the conflict
path, but email is not unique in the test entity. As a result, MySQL may never hit the
duplicate-key update path, so the test can pass without exercising the intended behavior.
Code

test/github-issues/12094/entity/User.ts[R8-12]

+    @Column()
+    name: string
+
+    @Column()
+    email: string
Evidence
The User.email column is a plain column (no unique constraint), yet the test uses
repo.upsert(input, ['email']) and expects that the second upsert performs an update. Without a
unique index/PK on email, the database may insert a second row instead of updating, so the test
does not guarantee coverage of the update path.

test/github-issues/12094/entity/User.ts[8-13]
test/github-issues/12094/issue-12094.test.ts[52-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The “upsert performs update” test may not actually perform an update because the conflict column (`email`) is not unique.
### Issue Context
MySQL’s duplicate-key update path requires a unique index/PK to trigger.
### Fix Focus Areas
- test/github-issues/12094/entity/User.ts[1-13]
- test/github-issues/12094/issue-12094.test.ts[52-71]
### Suggested test changes
- Add a uniqueness constraint, e.g. `@Column({ unique: true }) email: string`.
- After the upsert, verify the update path happened:
- `expect(await repo.count()).to.equal(1)`
- `expect((await repo.findOneByOrFail({ email: "[email protected]" })).name).to.equal("After")`
- Keep the existing assertion that the *input object* was not mutated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Tests only in github-issues 📘 Rule violation ⛯ Reliability
Description
This issue fix adds coverage only under test/github-issues/12094 instead of the functional test
suite. This violates the guideline to keep issue fixes in test/functional unless there is a clear
reason.
Code

test/github-issues/12094/issue-12094.test.ts[R1-84]

+import { expect } from "chai"
+import { DataSource } from "../../../src"
+import {
+    closeTestingConnections,
+    reloadTestingDatabases,
+    setupSingleTestingConnection,
+} from "../../utils/test-utils"
+import type { MysqlDataSourceOptions } from "../../../src/driver/mysql/MysqlDataSourceOptions"
+import { User } from "./entity/User"
+
+describe("github issues > #12094", () => {
+    let dataSource: DataSource
+
+    before(async () => {
+        const options = setupSingleTestingConnection("mysql", {
+            entities: [User],
+        }) as MysqlDataSourceOptions
+
+        if (!options) return
+
+        dataSource = new DataSource({
+            ...options,
+            replication: undefined,
+        })
+
+        await dataSource.initialize()
+    })
+
+    beforeEach(async () => {
+        if (!dataSource) return
+        await reloadTestingDatabases([dataSource])
+    })
+
+    after(() => closeTestingConnections([dataSource]))
+
+    it("should not mutate input entity when upsert performs insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            email: "[email protected]",
+            name: "Test",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "Test",
+        })
+    })
+
+    it("should not mutate input entity when upsert performs update", async () => {
+        const repo = dataSource.getRepository(User)
+
+        await repo.insert({
+            email: "[email protected]",
+            name: "Before",
+        })
+
+        const input = {
+            email: "[email protected]",
+            name: "After",
+        }
+
+        await repo.upsert(input, ["email"])
+
+        expect(input).to.deep.equal({
+            email: "[email protected]",
+            name: "After",
+        })
+    })
+
+    it("should still mutate entity on normal insert", async () => {
+        const repo = dataSource.getRepository(User)
+
+        const input = {
+            name: "Test",
+            email: "[email protected]",
+        }
+
+        await repo.insert(input)
+        expect(input).to.have.property("id")
+    })
+})
Evidence
PR Compliance ID 3 requires issue fixes to be added/updated in test/functional; the new tests were
added under test/github-issues/12094 with no indication they are also covered in functional tests.

Rule 3: Prefer functional tests over per-issue tests
test/github-issues/12094/issue-12094.test.ts[11-13]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Tests for this fix were added under `test/github-issues/12094`, but the compliance checklist requires issue fixes to live in the functional test suite (`test/functional`) unless there's a clear reason.
## Issue Context
The PR adds new regression coverage for #12094 around `repository.upsert()` mutating input entities.
## Fix Focus Areas
- test/github-issues/12094/issue-12094.test.ts[1-84]
- test/github-issues/12094/entity/User.ts[1-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Upsert loses generated IDs 🐞 Bug ✓ Correctness
Description
When expressionMap.onUpdate is set, generated primary keys are no longer merged into the entity, but
later logic still reads the entity’s primary key to build InsertResult.identifiers and to perform
the fallback re-select. This can produce undefined identifiers or throw a TypeORMError during upsert
inserts that require a reselect (DEFAULT/createDate/etc).
Code

src/query-builder/ReturningResultsEntityUpdator.ts[R206-212]

+            if (!this.expressionMap.onUpdate) {
+                this.queryRunner.manager.merge(
+                    metadata.target as any,
+                    entity,
+                    generatedMap,
+                )
+            }
Evidence
The PR skips merging generatedMap into the provided entity for onUpdate (upsert/orUpdate), but later
code still uses metadata.getEntityIdMap(entity) for (1) the fallback re-select criteria and (2)
InsertResult.identifiers. Since getEntityIdMap returns undefined when PK fields are missing, upsert
inserts with generated PKs can fail or return incorrect identifiers, especially when
insertionColumns includes DEFAULT/createDate/etc and triggers the fallback path.

src/query-builder/ReturningResultsEntityUpdator.ts[190-212]
src/query-builder/ReturningResultsEntityUpdator.ts[219-237]
src/query-builder/ReturningResultsEntityUpdator.ts[279-283]
src/metadata/EntityMetadata.ts[1162-1173]
src/metadata/EntityMetadata.ts[645-653]
src/metadata/EntityMetadata.ts[985-1001]
src/query-builder/InsertQueryBuilder.ts[172-183]
src/entity-manager/EntityManager.ts[769-786]
src/query-builder/InsertQueryBuilder.ts[441-463]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ReturningResultsEntityUpdator.insert()` now skips `manager.merge(entity, generatedMap)` when `expressionMap.onUpdate` is set (upsert/orUpdate). But the method still calls `metadata.getEntityIdMap(entity)` later to:
1) build `entityIds` for the fallback re-select (drivers without INSERT RETURNING), and
2) populate `insertResult.identifiers`.
If the primary key is generated (common) and wasn’t provided in the input object, `getEntityIdMap(entity)` becomes `undefined`, which can either throw (fallback reselect path) or yield `undefined` identifiers.
### Issue Context
- Upsert uses `InsertQueryBuilder.orUpdate()`, which sets `expressionMap.onUpdate`.
- Insert result processing still needs entity IDs even if we choose not to mutate the caller’s object.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[180-283]
- src/query-builder/InsertQueryBuilder.ts[421-464]
### Implementation sketch
- Build a temporary object per row for ID derivation (do **not** mutate `entity`):
- `const entityForId = this.queryRunner.manager.merge(metadata.target as any, {}, entity, generatedMap)` (or a shallow clone if safe),
- Use `metadata.getEntityIdMap(entityForId)` for `entityIds` and `insertResult.identifiers`.
- In the fallback reselect branch, after loading `returningResult[entityIndex]`, update `generatedMaps[entityIndex]` as today, and optionally also include those values in `entityForId` (again, without mutating the original) when computing identifiers.
- Add a regression test covering an entity with generated PK + `@CreateDateColumn()` (or a column default) using `upsert()` on a non-RETURNING driver like MySQL; assert it doesn’t throw and that `InsertResult.identifiers[0]` is populated while the input object remains unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
5. upsert mutation change undocumented 📘 Rule violation ✓ Correctness

...

@mbursali93 mbursali93 force-pushed the fix/12094-upsert-mutation branch from 521beda to d05929c Compare March 9, 2026 01:11
@mbursali93 mbursali93 changed the title Fix/12094 upsert mutation fix(upsert): prevent input entity mutation during upsert result merge Mar 9, 2026
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Persistent review updated to latest commit d05929c

@mbursali93 mbursali93 force-pushed the fix/12094-upsert-mutation branch from d05929c to a889801 Compare March 10, 2026 16:32
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Persistent review updated to latest commit a889801

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Persistent review updated to latest commit 9227b18

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Persistent review updated to latest commit 6a0542e

@sonarqubecloud
Copy link
Copy Markdown

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Persistent review updated to latest commit da1235d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

upsert() with MySQL mutates input entity objects in-place with wrong auto-increment IDs after ON DUPLICATE KEY UPDATE

1 participant