Skip to content

drivers(sql): on MySQL an upsert still merges on a unique key the caller never named — even when the named conflict target IS backed #8755

Description

@hotlong

Found while implementing #8621 (the pre-flight refusal for an UNBACKED conflict target on MySQL). Filed rather than fixed: it is a different condition with a different fix, and #8621 deliberately does not move it.

The condition

#8621 refuses a conflictKeys upsert on MySQL when no PRIMARY KEY or UNIQUE index backs the named target. That closes the case #8592 measured. It does not close the general one, because ON DUPLICATE KEY UPDATE carries no conflict target at all — so once the statement is accepted, any unique index on the table can absorb the conflict, including one the caller never named.

A properly declared, properly indexed conflict target is therefore still not honoured as a target on MySQL whenever the table carries a second unique index.

Measured — live MySQL 8.0.46

Ubuntu noble mysql-server, mysqld --daemonize, default_time_zone='+08:00', driven through the same knex + mysql2 path SqlDriver.upsert takes. Both business columns declared unique: true, so the named target email is backed and the pre-flight from #8621 passes it:

CREATE TABLE `probe_two_unique` (
`id` varchar(255) NOT NULL, `email` varchar(255) DEFAULT NULL,
`tax_id` varchar(255) DEFAULT NULL, `title` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_probe_two_unique_email` (`email`),
UNIQUE KEY `uniq_probe_two_unique_tax_id` (`tax_id`)
)
seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email'])
-> RESOLVED. [{id:'tU0Pal1GHDzBHcOg', email:'a@b.com', tax_id:'T-1', title:'first'}]
B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email'])
-> RESOLVED. [{id:'tU0Pal1GHDzBHcOg', email:'other@b.com', tax_id:'T-1', title:'second'}]
ONE row. `email` — the key the caller named, and a backed one — did NOT
collide; `tax_id` did, and MySQL merged on it.

The identical call on SQLite and Postgres inserts a second row: ON CONFLICT (email) names an arbiter index, so a tax_id collision is not absorbed by that statement — it raises a unique violation instead, which is a legible error rather than a silent merge across two different email values.

So the dialect divergence #8621 narrows is narrowed, not removed: after #8621, MySQL refuses the calls SQLite and Postgres refuse, but it can still merge where they would not.

Why it was not folded into #8621

Refusing this shape means refusing every conflictKeys upsert on any MySQL table carrying more than one unique index — an accept-set change far wider than the one #8621's ruling covers, and one that would refuse calls which are correct whenever no second index can actually collide. The remedies are also not accept-set-neutral in the way #8621's is, and they differ in kind:

  • A — refuse at pre-flight when the table carries any unique index outside the named target. Loud and cheap; refuses a large set of calls that work today, most of which are fine in practice.
  • B — compile a target-honouring statement on MySQL (e.g. probe-then-write, or INSERT ... ON DUPLICATE KEY UPDATE guarded by a transaction plus a SELECT ... FOR UPDATE on the named key). Preserves the capability; costs a round trip and needs its own concurrency argument.
  • C — declare it a documented dialect limit and reject conflictKeys naming a non-primary key on MySQL outright.

Each is a real decision about MySQL's accept set, so this needs adjudication rather than a dev picking one.

Related: #8621 (the unbacked-target pre-flight), #8592 (the original live-MySQL measurement), #8567 (the compiled-SQL read that shows the target is dropped), #5240 (one condition, one wording).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions