Skip to content

drivers(sql): what MySQL does with a conflict target it cannot honour is unmeasured — knex compiles the target away, so the refusal cannot arise there #8592

Description

@os-zhuang

Filed out of #8567, which measured Postgres and generalised the unbacked-conflict-target refusal to it. Unassigned. Observation-class and deliberately unmeasured — the first deliverable of this card is the measurement, not a fix.

What #8567 established, without a server

SqlDriver.upsert passes conflictKeys to knex's onConflict(...).merge(...). On the mysql2 dialect knex compiles that to ON DUPLICATE KEY UPDATE, which takes no conflict target:

mysql2 -> insert into `plain` (`email`, `id`, `title`) values (?, ?, ?)
on duplicate key update `title` = values(`title`) <- named keys are gone
pg -> insert into "plain" (...) values ($1, $2, $3)
on conflict ("email") do update set "title" = excluded."title"

The caller's keys never leave the process, so the server is never asked to find an index for them. The unbacked-conflict-target condition cannot arise on MySQL, and isUnbackedConflictTargetError correctly carries no MySQL limb.

The open question this leaves

If the target is compiled away, MySQL does not refuse — it merges on whichever unique key the row happens to collide with. A caller who wrote conflictKeys: ['email'] on a table whose only unique index is on tax_id gets a silent merge against tax_id on MySQL, where SQLite and Postgres both refuse. That is a worse failure than the one #8445 and #8567 fixed: those were legible-versus-illegible errors, this is a wrong write with no error at all.

⚠️The paragraph above is an inference from the compiled SQL, not an observation. Nobody has watched a MySQL server do it. #8445's dispatch ruled that describing dialect behaviour from memory or inference is not acceptable evidence, and #8567's dev correctly declined to file this card on that basis — the container has no mysqld/mariadbd and its docker daemon is unreachable.

The measurement channel exists — in CI, not on a dev box

That premise is resolvable. CI already provisions a live MySQL for the conformance job:

# .github/workflows/ci.yml:583image: mysql:8.0# .github/workflows/ci.yml:673OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance

and packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts already reads OS_TEST_MYSQL_URL, reporting an un-run cell rather than passing vacuously. sql-driver-upsert-conflict-target-dialects.test.ts (landing with #8567) currently declares its MySQL cell un-run for exactly this reason — the file runs under Test Core, which has no such service.

What this card is, in order

  1. Measure. On a runner that has the MySQL service: create an object whose unique index is on a column other than the one named in conflictKeys, upsert with the mismatched key, and record what actually happens — silent merge on the other key, an error, or something else. ⛔ Do not write the answer into a test before observing it.
  2. Then decide, and only then. Options range from leaving it (documented, dialect-honest) through refusing pre-flight in driver-sql when the dialect cannot honour a named target — the latter changes the accept set on MySQL, so it is a minor and wants its own argument, not a rider.
  3. Either way the MySQL cell should stop being structurally un-runnable: either wire this file's matrix into a job that has the servers, or state why it belongs where it is.

Related: #8567 (where the compiled SQL was read), #8445 / #8413 (the two faces that refuse this condition), #8590 (the other finding out of #8567), #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