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
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
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:
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.
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
The reachability argument rests on NULL-vs-unique-index semantics being uniform across SQLite, Postgres and MySQL. That holds for the three today, and it is a lot of load for an implicit assumption to carry — a target column with a non-null DEFAULT, or a fourth dialect, changes the answer with nothing going red.
Any future addition to insertOnlyUpsertColumns re-opens the branch the same way id did. There is nothing that fails when it does.
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.
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.upsertbuilds its merge set as "every formatted column that is not insert-only", then:A bare
insertion.merge()is knex's merge-ALL. Compiled, on all three dialects this driver serves: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:
idis minted for every payload that omits it and was always mergeable, so the set could never empty.id, which woke the branch — measured on live PostgreSQL 16.13, an object whose only non-idcolumn is anauto_number, where merge-ALL renumbered the rowCASE-0005toCASE-0006and 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, soexcluded.targetis 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
DO NOTHINGis the honest statement of "nothing to merge", but knex compiles.onConflict(...).ignore()on MySQL toinsert ignore into ...— which swallows unrelated errors too and drops the conflict target entirely. Measured. So this needs a decision, not a one-liner, and that is the other half of why it was not widened into 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.insertOnlyUpsertColumnsre-opens the branch the same wayiddid. There is nothing that fails when it does.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 NOTHINGwith a MySQL-specific lowering that is notinsert 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_numberexclusion this branch defeats), #8621.