Skip to content

drivers(sql): upsert's empty-merge-set fallback is merge-ALL, which re-admits every insert-only column — currently unreachable, but it is the wrong shape for "nothing to merge" #8740

Description

@hotlong

Found while implementing #8622. Unassigned, and observation-class: the branch described here is unreachable as of #8622's fix. Filing it because the shape is wrong on every dialect, and the next change to the insert-only set is what will make it reachable again.

The branch

SqlDriver.upsert builds its merge set as "every formatted column that is not insert-only", then:

constmergeColumns=Object.keys(formatted).filter((c)=>!insertOnlyColumns.has(c));
...
await(columnsToMerge.length>0 ? insertion.merge(columnsToMerge) : insertion.merge());// ^^^^^^^^^^^^^^^^^

A bare insertion.merge() is knex's merge-ALL. Compiled, on all three dialects this driver serves:

better-sqlite3 insert into `t` (`case_no`, `id`) values (?, ?) on conflict (`email`) do update set `id` = excluded.`id`
pg insert into "t" ("case_no", "id") values (?, ?) on conflict ("email") do update set "id" = excluded."id"
mysql2 insert into `t` (`case_no`, `id`) values (?, ?) on duplicate key update `id` = values(`id`)

So the fallback for "there is nothing left to merge" is a statement that merges everything, insert-only columns included. It is the exact inverse of what the surrounding code is for.

Why it is not a live defect today

Two guards, neither of them the fallback itself:

  1. Before drivers(sql): an upsert that merges on a non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite and MySQL alike #8622, the branch was dead: id is minted for every payload that omits it and was always mergeable, so the set could never empty.
  2. drivers(sql): an upsert that merges on a non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite and MySQL alike #8622 excluded id, which woke the branch — measured on live PostgreSQL 16.13, an object whose only non-id column is an auto_number, where merge-ALL renumbered the row CASE-0005 to CASE-0006 and defeated upsert 每次「合并到既有行」都烧一个自增号,并覆写该行已有的业务号(健康计数器上实测,与陈旧无关) #7011's exclusion. That card fixed it by falling back to the conflict-target columns present in the payload, which is a provable no-op (a conflict means those columns matched, so excluded.target is the stored value).

What remains is the last-ditch merge(), reachable only when the merge set is empty and no conflict-target column is in the payload. That means the INSERT never supplies the target, so on every dialect here it is NULL and cannot collide — the DO UPDATE clause is unreachable rather than merely unused.

Why it is still worth a card

Suggested shape

Decide what an upsert whose columns are all insert-only should DO on conflict, and spell it once — the candidates being a dialect-uniform no-op UPDATE (what #8622 chose for the reachable case), or DO NOTHING with a MySQL-specific lowering that is not insert ignore. Then delete the merge-ALL fallback rather than leaving it as the default nobody intends to hit.

Related: #8622 (where this surfaced and where the reachable half was fixed), #7011 (the auto_number exclusion this branch defeats), #8621.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions