You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This pull request links relevant issues as Fixes #00000
There are new or updated tests validating the change (tests/**.test.ts)
Documentation has been updated to reflect this change (docs/docs/**.md)
PR Type
Bug fix, Tests
Description
Prevent throwing errors on no-op upserts for drivers without RETURNING support
Handle cases where insertId is 0 (MySQL) by skipping re-fetch for affected entities
Only re-fetch entities with known IDs instead of throwing on missing IDs
Add test case validating no-op upsert behavior with identical values
Diagram Walkthrough
flowchart LR
A["No-op Upsert<br/>insertId=0"] --> B["Collect Entities<br/>with Known IDs"]
B --> C["Skip Re-fetch<br/>for No-op Entities"]
C --> D["Re-fetch Only<br/>Entities with IDs"]
D --> E["Merge Results<br/>Successfully"]
Loading
File Walkthrough
Relevant files
Bug fix
ReturningResultsEntityUpdator.ts
Handle no-op upserts by skipping re-fetch for missing IDs
Replace the primary key serialization logic from a join("\0") method to using JSON.stringify on an array of normalized values to prevent key collisions and handle complex data types correctly.
[To ensure code accuracy, apply this suggestion manually]
Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a potential bug with the primary key serialization logic, where joining with \0 can cause collisions or incorrect stringification for complex types, and proposes a more robust JSON.stringify approach.
Medium
Use safe multi-id filtering
Replace .where(entitiesWithIds.map((e) => e.entityId)) with .whereInIds(entitiesWithIds.map((e) => e.entityId)) to use the correct and safer API for querying by multiple primary keys.
.from(metadata.target, metadata.targetName)
-.where(entitiesWithIds.map((e) => e.entityId))+.whereInIds(entitiesWithIds.map((e) => e.entityId))
.setOption("create-pojo") // use POJO because created object can contain default values, e.g. property = null and those properties might be overridden by merge process
.getMany()
Apply / Chat
Suggestion importance[1-10]: 7
__
Why: The suggestion correctly identifies that using .where() with an array of IDs is not the standard API, and whereInIds() is the safer, more explicit method for this purpose, improving code robustness and clarity.
Improve the primary key serialization logic to robustly handle embedded or non-primitive key types by using ColumnMetadata.getEntityValue and JSON.stringify.
Why: The suggestion correctly identifies that the new primary key serialization logic is not robust and will fail with embedded or non-primitive primary keys, proposing a correct fix using getEntityValue and JSON.stringify.
Add an orderBy clause to the query to ensure the fetched results are in a deterministic order, preventing data from being merged into the wrong entities.
Why: This suggestion addresses a critical bug where the database results are not guaranteed to be in the same order as the input entities, which would cause incorrect data to be merged into the wrong entities.
High
Skip undefined entity IDs
Add a check to ensure entityId is not undefined before pushing it to insertResult.identifiers to prevent potential runtime errors.
Why: This suggestion prevents a potential runtime error by correctly handling cases where an entity ID might be undefined, which is the core problem this PR aims to solve for no-op upserts.
Medium
General
Refactor array filtering using reduce
Refactor the forEach loop that filters entities with IDs into a more concise reduce operation.
-const entitiesWithIds: {- entityId: ObjectLiteral- index: number-}[] = []-entities.forEach((entity, index) => {- const entityId = metadata.getEntityIdMap(entity)- if (entityId) {- entitiesWithIds.push({ entityId, index })- }-})+const entitiesWithIds = entities.reduce(+ (acc, entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ acc.push({ entityId, index })+ }+ return acc+ },+ [] as { entityId: ObjectLiteral; index: number }[],+)
Suggestion importance[1-10]: 3
__
Why: The suggestion offers a stylistic refactoring from forEach to reduce, which is functionally equivalent and provides only a marginal improvement in conciseness at the potential cost of readability.
1. Embedded PK merge broken 🐞 Bug✓ Correctness⭐ New
Description
The new re-fetch merge logic builds a PK lookup key via row[c.propertyPath] and
entityId[c.propertyPath]. For embedded columns TypeORM represents values as nested objects (not
flat keys with dotted paths), so this lookup can produce undefined keys, causing fetched rows to
not match the correct entity (skipping merges or merging into the wrong entity).
+ // Build a lookup from serialized PK → fetched row so that+ // positional mis-ordering of getMany() results cannot cause+ // values to be merged into the wrong entity.+ const pkSerializer = (row: ObjectLiteral) =>+ metadata.primaryColumns+ .map((c) => row[c.propertyPath])+ .join("\0")+ const resultByPk = new Map<string, any>(+ returningResult.map((row) => [pkSerializer(row), row]),
)
- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ entitiesWithIds.forEach(({ index, entityId }) => {+ const key = metadata.primaryColumns+ .map((c) => entityId[c.propertyPath])+ .join("\0")+ const row = resultByPk.get(key)+ if (!row) return++ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ row,+ )++ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ row,+ )+ })
Evidence
ReturningResultsEntityUpdator.insert serializes PKs using row[c.propertyPath] /
entityId[c.propertyPath]. But for embedded columns, TypeORM constructs nested maps (e.g., `{ data:
{ info: { id: ... }}}), not a flat object keyed by the dotted propertyPath`. Therefore
bracket-access using a dotted propertyPath won’t retrieve the PK value, breaking the lookup.
The codebase already uses JSON.stringify of a structured id object as a stable hash elsewhere, and
ColumnMetadata APIs exist to correctly access embedded values.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
### Issue description
`ReturningResultsEntityUpdator.insert()` builds a lookup key for fetched rows using `row[c.propertyPath]` / `entityId[c.propertyPath]`. This breaks when primary columns are embedded (nested object structure), causing failed matches and potentially incorrect merge behavior.
### Issue Context
TypeORM represents embedded column value maps as nested objects (not flat objects keyed by dotted paths). The PK lookup should therefore be derived from an id map (e.g. `metadata.getEntityIdMap(...)`) or use `ColumnMetadata.getEntityValue(...)`, not bracket access by `propertyPath`.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[264-293]
- src/metadata/EntityMetadata.ts[645-653]
- src/metadata/ColumnMetadata.ts[564-620]
- src/query-builder/transformer/RawSqlResultsToEntityTransformer.ts[704-714]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
2. Unsafe refetch index merge 🐞 Bug✓ Correctness
Description
For drivers without RETURNING, the refetch query uses getMany() without an ORDER BY and then
merges returningResult[resultIndex] into entities[index] by positional index. SQL result
ordering is undefined without ORDER BY, so merged default/update-date/version values can be
applied to the wrong entity/generatedMap (silent data corruption), and this becomes reachable now
that entities can be skipped due to missing ids.
+ const returningResult: any[] = await this.queryRunner.manager+ .createQueryBuilder()+ .select(+ metadata.primaryColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .addSelect(+ insertionColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .from(metadata.target, metadata.targetName)+ .where(entitiesWithIds.map((e) => e.entityId))+ .setOption("create-pojo") // use POJO because created object can contain default values, e.g. property = null and those properties might be overridden by merge process+ .getMany()- entities.forEach((entity, entityIndex) => {- this.queryRunner.manager.merge(- metadata.target as any,- generatedMaps[entityIndex],- returningResult[entityIndex],- )+ entitiesWithIds.forEach(({ index }, resultIndex) => {+ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ returningResult[resultIndex],+ )- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ returningResult[resultIndex],+ )+ })
Evidence
The refetch SELECT has no ordering, and the merge logic assumes returningResult aligns 1:1 with
entitiesWithIds order. Meanwhile, where(ObjectLiteral[]) is compiled to OR-clauses, which
constrain matched rows but do not impose any ordering on the returned rows—so positional merging is
not safe.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
The refetch/merge logic for non-RETURNING drivers merges reloaded rows into entities by array index, but the SELECT has no `ORDER BY`, so row order is not guaranteed. This can silently merge returned values (defaults/updateDate/version) into the wrong entity.
### Issue Context
- `.where(ObjectLiteral[])` becomes OR clauses that match the right rows but do not preserve any ordering.
- `getMany()` without ordering can return rows in arbitrary order.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[245-276]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
3. No docs for no-op upsert 📘 Rule violation✓ Correctness
Description
This PR changes upsert behavior to skip re-fetching entities without IDs instead of throwing,
which is user-facing for drivers without RETURNING support. The upsert documentation does not
describe this no-op/insertId=0 behavior, so users may be surprised by the changed outcome.
+ // Collect only entities whose id is known. For upserts on drivers without+ // RETURNING support (e.g. MySQL), a no-op upsert (identical values already+ // in the DB) results in insertId=0, so the entity id is not set. In that+ // case we skip the re-fetch for that entity rather than throwing.+ const entitiesWithIds: {+ entityId: ObjectLiteral+ index: number+ }[] = []+ entities.forEach((entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ entitiesWithIds.push({ entityId, index })+ }
})
- // to select just inserted entities we need a criteria to select by.- // for newly inserted entities in drivers which do not support returning statement- // row identifier can only be an increment column- // (since its the only thing that can be generated by those databases)- // or (and) other primary key which is defined by a user and inserted value has it+ if (entitiesWithIds.length > 0) {
Evidence
The checklist requires documentation updates for user-facing behavior changes. The updated code
explicitly changes no-op upsert handling (skipping instead of throwing), while the existing upsert
docs only describe general upsert usage and returning support without mentioning the no-op upsert /
missing-id behavior.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
This PR changes `upsert` behavior for drivers without RETURNING support by skipping the re-fetch when an entity ID is unavailable (e.g., MySQL no-op upsert returning `insertId=0`). The documentation does not mention this behavior change.
## Issue Context
The implementation comment in `ReturningResultsEntityUpdator` documents the scenario, but the public docs for `repository.upsert` / `manager.upsert` do not explain what to expect in no-op upserts on non-RETURNING drivers.
## Fix Focus Areas
- docs/docs/working-with-entity-manager/6-repository-api.md[175-257]
- docs/docs/working-with-entity-manager/5-entity-manager-api.md[217-237]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
New JSDoc @param tags were added without any descriptions, which adds low-value comment noise.
This conflicts with the requirement to avoid AI-like or unnecessary comment additions.
/**
* Updates entities with a special columns after updation query execution.
+ * @param updateResult+ * @param entities
*/
Evidence
The checklist asks to avoid AI-generated noise such as extra comments; the added @param tags
provide no additional information because they lack descriptions.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
Two JSDoc blocks add `@param` tags without descriptions, creating unnecessary comment noise.
## Issue Context
The compliance checklist requires avoiding AI-like or low-value comment additions.
## Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[26-30]
- src/query-builder/ReturningResultsEntityUpdator.ts[136-140]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
5. Undefined identifiers after no-op upsert 🐞 Bug✓ Correctness
Description
The PR skips refetch (and no longer throws) when an entity id can’t be determined (e.g., MySQL
insertId=0 on a no-op upsert), but insertResult.identifiers is still populated for all entities
using metadata.getEntityIdMap(entity). For MySQL, createGeneratedMap won’t set an increment PK
when insertId is 0 (falsy), and getEntityIdMap (skipNulls) will return undefined, so
InsertResult.identifiers can contain undefined entries—violating its documented
ObjectLiteral[] contract and surprising callers.
+ // Collect only entities whose id is known. For upserts on drivers without+ // RETURNING support (e.g. MySQL), a no-op upsert (identical values already+ // in the DB) results in insertId=0, so the entity id is not set. In that+ // case we skip the re-fetch for that entity rather than throwing.+ const entitiesWithIds: {+ entityId: ObjectLiteral+ index: number+ }[] = []+ entities.forEach((entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ entitiesWithIds.push({ entityId, index })+ }
})
Evidence
MySQL driver only populates increment PK when insertResult.insertId is truthy; with insertId=0, no
PK value is generated. getEntityIdMap skips null/undefined values and returns undefined when PK
is missing. With this PR, such entities are allowed to proceed (no throw), and the method still
pushes the (possibly undefined) id map into InsertResult.identifiers, despite
InsertResult.identifiers being documented as ObjectLiteral[].
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
After this PR, entities without determinable primary keys (e.g. MySQL no-op upsert with insertId=0 and no PK provided) are allowed to proceed, but `InsertResult.identifiers` is still filled using `getEntityIdMap(entity)` which can be `undefined`. This violates the documented `ObjectLiteral[]` contract and can break callers.
### Issue Context
- MySQL `createGeneratedMap` only sets increment PK when `insertResult.insertId` is truthy.
- `EntityMetadata.getEntityIdMap` uses `skipNulls: true` and returns `undefined` when PK values are missing.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[223-236]
- src/query-builder/ReturningResultsEntityUpdator.ts[280-284]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
ⓘ The new review experience is currently in Beta. Learn more
On MySQL/Aurora when an upsert returns insertId=0, the driver-generated map can include the
primary key with value undefined, which then gets merged into the entity and returned in
InsertResult.generatedMaps. With this PR skipping the re-fetch/throw path, callers can now observe
mutated entities and generatedMaps containing id: undefined.
+ // Collect only entities whose id is known. For upserts on drivers without+ // RETURNING support (e.g. MySQL), a no-op upsert (identical values already+ // in the DB) results in insertId=0, so the entity id is not set. In that+ // case we skip the re-fetch for that entity rather than throwing.+ const entitiesWithIds: {+ entityId: ObjectLiteral+ index: number+ }[] = []+ entities.forEach((entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ entitiesWithIds.push({ entityId, index })+ }
})
- // to select just inserted entities we need a criteria to select by.- // for newly inserted entities in drivers which do not support returning statement- // row identifier can only be an increment column- // (since its the only thing that can be generated by those databases)- // or (and) other primary key which is defined by a user and inserted value has it+ if (entitiesWithIds.length > 0) {+ // to select just inserted entities we need a criteria to select by.+ // for newly inserted entities in drivers which do not support returning statement+ // row identifier can only be an increment column+ // (since its the only thing that can be generated by those databases)+ // or (and) other primary key which is defined by a user and inserted value has it- const returningResult: any = await this.queryRunner.manager- .createQueryBuilder()- .select(- metadata.primaryColumns.map(- (column) =>- metadata.targetName + "." + column.propertyPath,- ),- )- .addSelect(- insertionColumns.map(- (column) =>- metadata.targetName + "." + column.propertyPath,- ),- )- .from(metadata.target, metadata.targetName)- .where(entityIds)- .setOption("create-pojo") // use POJO because created object can contain default values, e.g. property = null and those properties might be overridden by merge process- .getMany()+ const returningResult: any[] = await this.queryRunner.manager+ .createQueryBuilder()+ .select(+ metadata.primaryColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .addSelect(+ insertionColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .from(metadata.target, metadata.targetName)+ .where(entitiesWithIds.map((e) => e.entityId))+ .setOption("create-pojo") // use POJO because created object can contain default values, e.g. property = null and those properties might be overridden by merge process+ .getMany()- entities.forEach((entity, entityIndex) => {- this.queryRunner.manager.merge(- metadata.target as any,- generatedMaps[entityIndex],- returningResult[entityIndex],+ // Build a lookup from serialized PK → fetched row so that+ // positional mis-ordering of getMany() results cannot cause+ // values to be merged into the wrong entity.+ // Use getEntityValue() so embedded PKs (nested objects) are+ // resolved correctly instead of flat bracket-access by propertyPath.+ const pkSerializer = (row: ObjectLiteral) =>+ metadata.primaryColumns+ .map((c) => c.getEntityValue(row))+ .join("\0")+ const resultByPk = new Map<string, any>(+ returningResult.map((row) => [pkSerializer(row), row]),
)
- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ entitiesWithIds.forEach(({ index, entityId }) => {+ const key = metadata.primaryColumns+ .map((c) => c.getEntityValue(entityId))+ .join("\0")+ const row = resultByPk.get(key)+ if (!row) return++ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ row,+ )++ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ row,+ )+ })+ }
}
entities.forEach((entity, entityIndex) => {
- const entityId = metadata.getEntityIdMap(entity)!+ const entityId = metadata.getEntityIdMap(entity) ?? {}
insertResult.identifiers.push(entityId)
insertResult.generatedMaps.push(generatedMaps[entityIndex])
})
Evidence
ReturningResultsEntityUpdator.insert always merges the driver’s generated map into the passed
entity, but for MySQL/Aurora the driver can build a value map for an increment PK even when
insertId is falsy (e.g. 0), producing { id: undefined }. Because
ColumnMetadata.createValueMap includes keys even when the value is undefined, and
OrmUtils.merge assigns primitives as-is, this can overwrite an existing id and leaks into
InsertResult.generatedMaps; the PR then skips the re-fetch for entities without ids and pushes
{} identifiers, so the undefined-PK generatedMap is no longer “caught” by a throw/re-fetch step.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
### Issue description
For MySQL/Aurora, `createGeneratedMap()` can produce `{ id: undefined }` for increment PKs when `insertId` is falsy (notably `0` on upsert update/no-op). `ReturningResultsEntityUpdator.insert()` merges this into the input entity and also returns it via `InsertResult.generatedMaps`, which is confusing and can wipe an explicitly-provided id.
### Issue Context
This PR intentionally skips re-fetching and throwing when entity id cannot be determined. That makes it more important that we do not merge/return “generated” PK fields with `undefined` values.
### Fix Focus Areas
- src/driver/mysql/MysqlDriver.ts[976-1023]
- src/driver/aurora-mysql/AuroraMysqlDriver.ts[883-912]
- src/query-builder/ReturningResultsEntityUpdator.ts[154-209]
### Suggested direction
Prefer fixing in the drivers:
- Only `mergeDeep` a generated column’s value map if the computed `value` is not `undefined` (and potentially not `null`). This will cause `Object.keys(generatedMap).length` to be `0` and `createGeneratedMap` to return `undefined`, avoiding `{id: undefined}`.
Alternative / additional guard:
- In `ReturningResultsEntityUpdator.insert`, deep-prune `undefined` leaves from `generatedMap` before merging into `entity` and before storing it in `generatedMaps`.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
2. Embedded PK merge broken 🐞 Bug✓ Correctness
Description
The new re-fetch merge logic builds a PK lookup key via row[c.propertyPath] and
entityId[c.propertyPath]. For embedded columns TypeORM represents values as nested objects (not
flat keys with dotted paths), so this lookup can produce undefined keys, causing fetched rows to
not match the correct entity (skipping merges or merging into the wrong entity).
+ // Build a lookup from serialized PK → fetched row so that+ // positional mis-ordering of getMany() results cannot cause+ // values to be merged into the wrong entity.+ const pkSerializer = (row: ObjectLiteral) =>+ metadata.primaryColumns+ .map((c) => row[c.propertyPath])+ .join("\0")+ const resultByPk = new Map<string, any>(+ returningResult.map((row) => [pkSerializer(row), row]),
)
- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ entitiesWithIds.forEach(({ index, entityId }) => {+ const key = metadata.primaryColumns+ .map((c) => entityId[c.propertyPath])+ .join("\0")+ const row = resultByPk.get(key)+ if (!row) return++ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ row,+ )++ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ row,+ )+ })
Evidence
ReturningResultsEntityUpdator.insert serializes PKs using row[c.propertyPath] /
entityId[c.propertyPath]. But for embedded columns, TypeORM constructs nested maps (e.g., `{ data:
{ info: { id: ... }}}), not a flat object keyed by the dotted propertyPath`. Therefore
bracket-access using a dotted propertyPath won’t retrieve the PK value, breaking the lookup. The
codebase already uses JSON.stringify of a structured id object as a stable hash elsewhere, and
ColumnMetadata APIs exist to correctly access embedded values.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
`ReturningResultsEntityUpdator.insert()` builds a lookup key for fetched rows using `row[c.propertyPath]` / `entityId[c.propertyPath]`. This breaks when primary columns are embedded (nested object structure), causing failed matches and potentially incorrect merge behavior.
### Issue Context
TypeORM represents embedded column value maps as nested objects (not flat objects keyed by dotted paths). The PK lookup should therefore be derived from an id map (e.g. `metadata.getEntityIdMap(...)`) or use `ColumnMetadata.getEntityValue(...)`, not bracket access by `propertyPath`.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[264-293]
- src/metadata/EntityMetadata.ts[645-653]
- src/metadata/ColumnMetadata.ts[564-620]
- src/query-builder/transformer/RawSqlResultsToEntityTransformer.ts[704-714]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
3. Unsafe refetch index merge 🐞 Bug✓ Correctness
Description
For drivers without RETURNING, the refetch query uses getMany() without an ORDER BY and then
merges returningResult[resultIndex] into entities[index] by positional index. SQL result
ordering is undefined without ORDER BY, so merged default/update-date/version values can be
applied to the wrong entity/generatedMap (silent data corruption), and this becomes reachable now
that entities can be skipped due to missing ids.
+ const returningResult: any[] = await this.queryRunner.manager+ .createQueryBuilder()+ .select(+ metadata.primaryColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .addSelect(+ insertionColumns.map(+ (column) =>+ metadata.targetName + "." + column.propertyPath,+ ),+ )+ .from(metadata.target, metadata.targetName)+ .where(entitiesWithIds.map((e) => e.entityId))+ .setOption("create-pojo") // use POJO because created object can contain default values, e.g. property = null and those properties might be overridden by merge process+ .getMany()- entities.forEach((entity, entityIndex) => {- this.queryRunner.manager.merge(- metadata.target as any,- generatedMaps[entityIndex],- returningResult[entityIndex],- )+ entitiesWithIds.forEach(({ index }, resultIndex) => {+ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ returningResult[resultIndex],+ )- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ returningResult[resultIndex],+ )+ })
Evidence
The refetch SELECT has no ordering, and the merge logic assumes returningResult aligns 1:1 with
entitiesWithIds order. Meanwhile, where(ObjectLiteral[]) is compiled to OR-clauses, which
constrain matched rows but do not impose any ordering on the returned rows—so positional merging is
not safe.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
The refetch/merge logic for non-RETURNING drivers merges reloaded rows into entities by array index, but the SELECT has no `ORDER BY`, so row order is not guaranteed. This can silently merge returned values (defaults/updateDate/version) into the wrong entity.
### Issue Context
- `.where(ObjectLiteral[])` becomes OR clauses that match the right rows but do not preserve any ordering.
- `getMany()` without ordering can return rows in arbitrary order.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[245-276]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
4. PK key collision risk 🐞 Bug⛯ Reliability⭐ New
Description
The new PK→row matching uses a string serialization (join("\0")) that relies on JS string coercion
and a delimiter that could appear in string PK values. In rare edge cases (e.g., PK values
containing \0 or non-string PKs with ambiguous toString()), it could map fetched rows to the
wrong entity.
+ // Build a lookup from serialized PK → fetched row so that+ // positional mis-ordering of getMany() results cannot cause+ // values to be merged into the wrong entity.+ // Use getEntityValue() so embedded PKs (nested objects) are+ // resolved correctly instead of flat bracket-access by propertyPath.+ const pkSerializer = (row: ObjectLiteral) =>+ metadata.primaryColumns+ .map((c) => c.getEntityValue(row))+ .join("\0")+ const resultByPk = new Map<string, any>(+ returningResult.map((row) => [pkSerializer(row), row]),
)
- this.queryRunner.manager.merge(- metadata.target as any,- entity,- returningResult[entityIndex],- )- })+ entitiesWithIds.forEach(({ index, entityId }) => {+ const key = metadata.primaryColumns+ .map((c) => c.getEntityValue(entityId))+ .join("\0")+ const row = resultByPk.get(key)+ if (!row) return++ this.queryRunner.manager.merge(+ metadata.target as any,+ generatedMaps[index],+ row,+ )++ this.queryRunner.manager.merge(+ metadata.target as any,+ entities[index],+ row,+ )+ })
Evidence
The lookup key is built by concatenating primary key values into a single string via join("\0")
both for result rows and for entityId maps, without escaping or type-tagging. This is generally
fine for common PKs (numbers/UUIDs), but the approach is not collision-proof for arbitrary PK
types/values.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
### Issue description
PK matching relies on `values.join("\0")`, which can theoretically collide if values contain the delimiter or stringify ambiguously.
### Issue Context
This logic is used to avoid positional mismatches from `getMany()` ordering; improving the serializer would preserve that benefit while reducing edge-case collision risk.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[260-277]
### Suggested direction
- Build keys as an array of per-column components with explicit type tags and escaping, e.g.:
- `const key = metadata.primaryColumns.map(c => {
const v = c.getEntityValue(x)
return `${typeof v}:${String(v).replaceAll("\\0", "\\0\\0")}`
}).join("\\0")`
- Alternatively, serialize per-column values using `JSON.stringify` of the ordered array (with a replacer for Buffer/Date as needed).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
5. No docs for no-op upsert 📘 Rule violation✓ Correctness
Description
This PR changes upsert behavior to skip re-fetching entities without IDs instead of throwing,
which is user-facing for drivers without RETURNING support. The upsert documentation does not
describe this no-op/insertId=0 behavior, so users may be surprised by the changed outcome.
+ // Collect only entities whose id is known. For upserts on drivers without+ // RETURNING support (e.g. MySQL), a no-op upsert (identical values already+ // in the DB) results in insertId=0, so the entity id is not set. In that+ // case we skip the re-fetch for that entity rather than throwing.+ const entitiesWithIds: {+ entityId: ObjectLiteral+ index: number+ }[] = []+ entities.forEach((entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ entitiesWithIds.push({ entityId, index })+ }
})
- // to select just inserted entities we need a criteria to select by.- // for newly inserted entities in drivers which do not support returning statement- // row identifier can only be an increment column- // (since its the only thing that can be generated by those databases)- // or (and) other primary key which is defined by a user and inserted value has it+ if (entitiesWithIds.length > 0) {
Evidence
The checklist requires documentation updates for user-facing behavior changes. The updated code
explicitly changes no-op upsert handling (skipping instead of throwing), while the existing upsert
docs only describe general upsert usage and returning support without mentioning the no-op upsert /
missing-id behavior.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
This PR changes `upsert` behavior for drivers without RETURNING support by skipping the re-fetch when an entity ID is unavailable (e.g., MySQL no-op upsert returning `insertId=0`). The documentation does not mention this behavior change.
## Issue Context
The implementation comment in `ReturningResultsEntityUpdator` documents the scenario, but the public docs for `repository.upsert` / `manager.upsert` do not explain what to expect in no-op upserts on non-RETURNING drivers.
## Fix Focus Areas
- docs/docs/working-with-entity-manager/6-repository-api.md[175-257]
- docs/docs/working-with-entity-manager/5-entity-manager-api.md[217-237]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
New JSDoc @param tags were added without any descriptions, which adds low-value comment noise.
This conflicts with the requirement to avoid AI-like or unnecessary comment additions.
/**
* Updates entities with a special columns after updation query execution.
+ * @param updateResult+ * @param entities
*/
Evidence
The checklist asks to avoid AI-generated noise such as extra comments; the added @param tags
provide no additional information because they lack descriptions.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
Two JSDoc blocks add `@param` tags without descriptions, creating unnecessary comment noise.
## Issue Context
The compliance checklist requires avoiding AI-like or low-value comment additions.
## Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[26-30]
- src/query-builder/ReturningResultsEntityUpdator.ts[136-140]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
View more (1) 7. Undefined identifiers after no-op upsert 🐞 Bug✓ Correctness
Description
The PR skips refetch (and no longer throws) when an entity id can’t be determined (e.g., MySQL
insertId=0 on a no-op upsert), but insertResult.identifiers is still populated for all entities
using metadata.getEntityIdMap(entity). For MySQL, createGeneratedMap won’t set an increment PK
when insertId is 0 (falsy), and getEntityIdMap (skipNulls) will return undefined, so
InsertResult.identifiers can contain undefined entries—violating its documented
ObjectLiteral[] contract and surprising callers.
+ // Collect only entities whose id is known. For upserts on drivers without+ // RETURNING support (e.g. MySQL), a no-op upsert (identical values already+ // in the DB) results in insertId=0, so the entity id is not set. In that+ // case we skip the re-fetch for that entity rather than throwing.+ const entitiesWithIds: {+ entityId: ObjectLiteral+ index: number+ }[] = []+ entities.forEach((entity, index) => {+ const entityId = metadata.getEntityIdMap(entity)+ if (entityId) {+ entitiesWithIds.push({ entityId, index })+ }
})
Evidence
MySQL driver only populates increment PK when insertResult.insertId is truthy; with insertId=0, no
PK value is generated. getEntityIdMap skips null/undefined values and returns undefined when PK
is missing. With this PR, such entities are allowed to proceed (no throw), and the method still
pushes the (possibly undefined) id map into InsertResult.identifiers, despite
InsertResult.identifiers being documented as ObjectLiteral[].
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
After this PR, entities without determinable primary keys (e.g. MySQL no-op upsert with insertId=0 and no PK provided) are allowed to proceed, but `InsertResult.identifiers` is still filled using `getEntityIdMap(entity)` which can be `undefined`. This violates the documented `ObjectLiteral[]` contract and can break callers.
### Issue Context
- MySQL `createGeneratedMap` only sets increment PK when `insertResult.insertId` is truthy.
- `EntityMetadata.getEntityIdMap` uses `skipNulls: true` and returns `undefined` when PK values are missing.
### Fix Focus Areas
- src/query-builder/ReturningResultsEntityUpdator.ts[223-236]
- src/query-builder/ReturningResultsEntityUpdator.ts[280-284]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
ⓘ The new review experience is currently in Beta. Learn more
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Close #10909
Description of change
Pull-Request Checklist
masterbranchFixes #00000tests/**.test.ts)docs/docs/**.md)