Classify constraint violations by SQLite's error codes, not its message text - #37
Merged
shockalotti merged 1 commit intoSep 9, 2026
Merged
Conversation
Thirteen call sites decided what a database error meant by matching English: strings.Contains(err.Error(), "UNIQUE constraint failed"). That is invisible to every gate, and it cannot answer the question it is being asked. SQLite reports a PRIMARY KEY collision as SQLITE_CONSTRAINT_PRIMARYKEY (1555) and an ordinary unique violation as SQLITE_CONSTRAINT_UNIQUE (2067), and it uses the SAME message for both. So the text match cannot distinguish them, and a code match written from the message alone would match 2067 and silently stop recognising every primary-key collision. idempotency_keys.idempotency_key is a bare PRIMARY KEY, so the replay path depends on 1555 being read as a unique violation. IsUniqueViolation, IsCheckViolation and IsForeignKeyViolation answer from the driver's code and fall back to the message only when the concrete driver type is no longer attached. A driver error whose code does not match returns false rather than falling through: falling through would classify by whether the message happened to contain an English phrase, and would readmit 1555 by its text after excluding it by code. Each class is provoked against the real schema rather than constructed, so the test asserts what the driver does rather than what its author believed. Removing 1555 from IsUniqueViolation fails the primary-key case, which is what makes that case worth having. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes Shared SQLite constraint classifiers keyed on extended result codes, with provoked tests and all prior message-substring call sites moved over.
db.IsUniqueViolation/IsCheckViolation/IsForeignKeyViolation— code-first via*sqlite.Error.Code(), text only when the driver type is gone; non-matching codes do not fall through.- PK 1555 counted as unique — required for
idempotency_keysbare PRIMARY KEY /claimIdempotencyKeyreplay. - Call-site migration — booking service, availability, override, teams, event types, idempotency, booking answers FK; local helpers removed.
- Provoked tests — real schema UNIQUE/PK/CHECK/FK plus exclusivity and negative cases; text-fallback branch covered separately.
Grok | 𝕏
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

This is the piece of #29 you said you would take, on its own against
main, with the fourpg*constants and thepgconnimport dropped. No dialect layer, no second migration set, no new dependency:modernc.org/sqliteis already a direct dependency, and nothing else is added.The bug it fixes
Thirteen call sites decided what a database error meant by matching English text:
SQLite reports a PRIMARY KEY collision as
SQLITE_CONSTRAINT_PRIMARYKEY(1555) and an ordinary unique violation asSQLITE_CONSTRAINT_UNIQUE(2067), and it uses the same message for both. So the text cannot distinguish them, and nothing built on it can either.The trap is that the obvious fix is also wrong. A code match written from the message alone matches 2067 and silently stops recognising primary-key collisions, which matters here:
idempotency_keys.idempotency_keyis a barePRIMARY KEY, soclaimIdempotencyKey's entire replay path arrives as 1555. Both codes belong toIsUniqueViolation.What is in it
internal/db/constraint.goaddsIsUniqueViolation,IsCheckViolationandIsForeignKeyViolation, and the thirteen call sites use them. Two local helpers with the same job (booking.isUniqueViolation,handler.isForeignKeyViolation) are removed in favour of the shared ones.The classification is code-first, with the message only as a fallback. A
*sqlite.Errorwhose code does not match returns false rather than falling through to the text comparison. Falling through would classify an error by whether its message happened to contain an English phrase, which is the fragility being removed, and it would reintroduce the primary-key trap in reverse: a 1555 excluded by code would be readmitted by its "UNIQUE constraint failed" text.The fallback itself is deliberate rather than vestigial, and the reasoning is in the
violatesdoc comment you asked to keep.Evidence
Every class is provoked against the real migrated schema rather than constructed by hand, because a hand-built error only proves the predicate agrees with what its author believed the driver returns, and the belief being replaced here was exactly that kind of belief.
assertOnlyadditionally requires that exactly one of the three predicates matches, so the classes stay distinguishable and a CHECK violation cannot quietly become a 409. The negative cases carry the same weight: a missing table, a plain error,nil, and aNOT NULLviolation (1299) must match none of the three.The primary-key case is the one worth pointing at. Deleting
sqliteConstraintPrimaryKeyfromIsUniqueViolationfails it, and the failure prints the driver's own words:That is the message and the code side by side, which is the whole argument for this change in one line.
gofmt,go vetandgo test ./...are clean onmain+ this branch.🤖 Generated with Claude Code