Skip to content

fix(session): map no-such-table to 1146 and give 1062 a MySQL-shaped message - #31

Merged
luthermonson merged 1 commit into
mainfrom
fix/error-map-fidelity
Aug 14, 2026
Merged

fix(session): map no-such-table to 1146 and give 1062 a MySQL-shaped message#31
luthermonson merged 1 commit into
mainfrom
fix/error-map-fidelity

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Two fidelity gaps in litewire-session's error map, both of which made litewire's errors unusable to client code litewire does not control and cannot patch.

1. no such table → 1146 / 42S02

SQLite's no such table: X fell through to the ER_UNKNOWN_ERROR catch-all (1105 / HY000), which carries no information at all — a missing table looked exactly like every other failure. MySQL clients expect 1146 with SQLSTATE 42S02, and frameworks branch on it to tell "the schema is not migrated yet" apart from "this query is broken": Doctrine raises TableNotFoundException off 1146, and Laravel's schema tooling keys off 42S02.

The message is forwarded verbatim. SQLite already names the table, and unlike the duplicate-key case there is no MySQL message shape clients match on here.

Matching is on the substring, so it also catches the Turso backend's wording, which prefixes the same text with Parse error: .

No change was needed in litewire-mysql: it delegates to the session classifier and converts the numeric code, and opensrv-mysql's ErrorKind::ER_NO_SUCH_TABLE already carries 42S02. There is a test pinning that down, because the SQLSTATE in the wire packet comes from that table rather than from the SQLSTATE litewire maps, and the two have to agree.

2. A MySQL-shaped message for 1062

Duplicate keys were already 1062 / 23000, but carried SQLite's raw text. Stock MySQL drivers detect a duplicate by matching the message, not just the code, so UNIQUE constraint failed: users.email never matched anything and ePHPm's Laravel integration had to override isUniqueConstraintError() to work around it.

The message is now:

Duplicate entry '<unknown>' for key 'users.email' (UNIQUE constraint failed: users.email)

The synthesised part is a prefix, not a replacement — SQLite's own words are kept in parentheses, so nobody loses debugging information.

Two imprecisions, both unavoidable at this layer and both documented at the call site:

  • The value. SQLite's message does not contain the offending value, and the statement's parameters are not available here. Inventing one would be a lie; omitting the field would break the shape clients match on. The slot says <unknown>.
  • The key. MySQL names the index; SQLite names the columns. For a single-column unique index — which produces almost all real duplicates — and for the index-named-after-its-column convention WordPress and Laravel both follow, these coincide. For a composite constraint the column list stands in for the index name.

A message with no column list after the marker is left exactly as it was: with nothing to put in the for key slot, a content-free Duplicate entry '<unknown>' for key '<unknown>' would be strictly worse than the original text.

Existing assertions updated — why each is a fixture correction

Both error maps had the same two tests, and both encoded precisely the behaviour this issue asks us to change.

unknown_falls_back_to_1105 used "no such table: sprockets" as its stand-in for an unclassifiable error — the exact string now deliberately classified as 1146. Re-fixtured onto "disk I/O error" (a real SQLite error with no MySQL analogue) and "something the classifier never heard of" (not a SQLite error at all). Two independent reasons for the fallback to be reached, so the test cannot quietly go vacuous if one of them ever gains a mapping, with a comment saying to move the fixture rather than delete the test if disk I/O error does. The session version also now asserts the message survives, which it did not before.

classify_preserves_message asserted the mapped message was byte-identical to the backend's. Narrowed to "the backend's text is present", since 1062 now carries a prefix — the property that matters, that SQLite's words reach the operator, is still asserted. To make sure nothing was lost, the same test now also does the strict byte-for-byte check across five classifications that are not reshaped (database is locked, FOREIGN KEY constraint failed, attempt to write a readonly database, no such table: …, disk I/O error). Verbatim forwarding is covered on more paths than before, not fewer.

litewire-postgres has its own independent classify and was not touched; its tests are unaffected.

The real-backend tests caught a bug the string tests could not

Writing the session-level tests against Rusqlite::memory() rather than hand-written strings immediately failed:

got: Duplicate entry '<unknown>' for key 'UNIQUE constraint failed: users.email'
     (SQLite error: UNIQUE constraint failed: users.email)

rusqlite prefixes its message with SQLite error: , so splitting on the first ": " grabbed the wrapper's colon and named the key after the whole constraint message. The column list is now located by the constraint failed: marker instead, which is immune to wrappers on either side. This is exactly the failure mode the unit tests could not see, since they feed the classifier the idealised string.

Testing

  • cargo test --workspace and cargo test --workspace --all-features pass.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings clean; cargo +nightly fmt --all -- --check clean.
  • New unit tests in litewire-session/src/error_map.rs: 1146 for the bare and Parse error:-prefixed wordings; the exact 1062 message; the message matching the marker sequence stock drivers scan for; primary-key violations taking the same shape; a composite constraint using the whole column list; a backend prefix not corrupting the key; and a message with no column list keeping its original text.
  • New unit tests in litewire-mysql/src/error_map.rs: the wire ErrorKind for 1146 and that ErrorKind::sqlstate() agrees with the mapped SQLSTATE, plus the reshaped 1062 message reaching the adapter.
  • New crates/litewire-session/tests/error_fidelity.rs — 6 tests running statements that genuinely fail against a real in-memory rusqlite backend: missing table on SELECT/INSERT/UPDATE/DELETE, an existing table never being misreported as 1146, real unique and primary-key collisions producing the MySQL shape with SQLite's text retained, and unreshaped errors (both classified and fallback) keeping SQLite's wording exactly. These also pin down the message text SQLite actually emits, which is what the substring matching depends on and the thing most likely to drift on a rusqlite upgrade.
  • New crates/litewire/tests/mysql_error_codes.rs — 3 tests asserting on the error packet a real mysql_async client receives: 1146/42S02 for a missing table (and the connection still usable afterwards), 1062/23000 with the MySQL-shaped message, and a reconstruction of the exception string PDO would build, checked against both Integrity constraint violation: 1062 and the Duplicate entry ... for key ... pattern.

Fixes #22

…message

Two fidelity gaps that made litewire's errors unusable to clients we do
not control.

SQLite's "no such table" fell through to the ER_UNKNOWN_ERROR catch-all
(1105 / HY000), so a missing table was indistinguishable from any other
failure. Frameworks branch on 1146 / 42S02 to tell "not migrated yet"
apart from a broken query -- Doctrine raises TableNotFoundException off
1146. It now maps to 1146 / 42S02, message forwarded verbatim.

Duplicate keys carried raw SQLite text under 1062. Stock MySQL drivers
detect a duplicate by matching the message, not just the code, so they
never recognised "UNIQUE constraint failed: users.email" and ePHPm's
Laravel integration had to override isUniqueConstraintError(). The
message is now MySQL-shaped -- Duplicate entry '<unknown>' for key
'users.email' (UNIQUE constraint failed: users.email) -- keeping
SQLite's text so nothing is lost for debugging. The value slot says
<unknown> because SQLite's message does not contain the offending
value; inventing one would be a lie and omitting the field would break
the shape clients match on.

Two existing assertions per error_map encoded the behaviour this
changes, and are updated deliberately, not to make a fix go green:

- unknown_falls_back_to_1105 used "no such table: sprockets" as its
  stand-in for an unclassifiable error, which is exactly the string now
  classified. Re-fixtured onto two strings that cannot both gain a
  mapping, with a comment saying how to move it again if one does. It
  also now asserts the message survives, which it did not before.
- classify_preserves_message asserted the message was byte-identical to
  the backend's. Narrowed to "the backend's text is present", and
  extended with a byte-for-byte check across five classifications that
  are not reshaped -- so verbatim forwarding is covered on more paths
  than before, not fewer.

Writing the tests against a real backend rather than hand-written
strings caught a bug the unit tests could not: rusqlite prefixes its
message with "SQLite error: ", so splitting on the first ": " named the
key "UNIQUE constraint failed: users.email". The column list is now
located by the "constraint failed: " marker.

Fixes #22
@luthermonson
luthermonson merged commit 9e80899 into main Aug 14, 2026
3 checks passed
@luthermonson
luthermonson deleted the fix/error-map-fidelity branch August 14, 2026 07:54
luthermonson added a commit that referenced this pull request Sep 1, 2026
…message (#31)

Two fidelity gaps that made litewire's errors unusable to clients we do
not control.

SQLite's "no such table" fell through to the ER_UNKNOWN_ERROR catch-all
(1105 / HY000), so a missing table was indistinguishable from any other
failure. Frameworks branch on 1146 / 42S02 to tell "not migrated yet"
apart from a broken query -- Doctrine raises TableNotFoundException off
1146. It now maps to 1146 / 42S02, message forwarded verbatim.

Duplicate keys carried raw SQLite text under 1062. Stock MySQL drivers
detect a duplicate by matching the message, not just the code, so they
never recognised "UNIQUE constraint failed: users.email" and ePHPm's
Laravel integration had to override isUniqueConstraintError(). The
message is now MySQL-shaped -- Duplicate entry '<unknown>' for key
'users.email' (UNIQUE constraint failed: users.email) -- keeping
SQLite's text so nothing is lost for debugging. The value slot says
<unknown> because SQLite's message does not contain the offending
value; inventing one would be a lie and omitting the field would break
the shape clients match on.

Two existing assertions per error_map encoded the behaviour this
changes, and are updated deliberately, not to make a fix go green:

- unknown_falls_back_to_1105 used "no such table: sprockets" as its
  stand-in for an unclassifiable error, which is exactly the string now
  classified. Re-fixtured onto two strings that cannot both gain a
  mapping, with a comment saying how to move it again if one does. It
  also now asserts the message survives, which it did not before.
- classify_preserves_message asserted the message was byte-identical to
  the backend's. Narrowed to "the backend's text is present", and
  extended with a byte-for-byte check across five classifications that
  are not reshaped -- so verbatim forwarding is covered on more paths
  than before, not fewer.

Writing the tests against a real backend rather than hand-written
strings caught a bug the unit tests could not: rusqlite prefixes its
message with "SQLite error: ", so splitting on the first ": " named the
key "UNIQUE constraint failed: users.email". The column list is now
located by the "constraint failed: " marker.

Fixes #22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

error_map: add 1146 (no such table) and MySQL-shaped 1062 messages — frameworks classify by both

1 participant