Skip to content

libpq: SQLSTATE-based error matching for query failures#7

Merged
adunstan merged 1 commit into
adunstan:pytap/v3from
gburd:feature/sqlstate-error-matching
Jun 17, 2026
Merged

libpq: SQLSTATE-based error matching for query failures#7
adunstan merged 1 commit into
adunstan:pytap/v3from
gburd:feature/sqlstate-error-matching

Conversation

@gburd

@gburd gburd commented Jun 16, 2026

Copy link
Copy Markdown

This is one of a small set of capability differences my pytest port [1] has that pytap/v3 lacks; opening them as separate PRs so each is reviewable on its own and you can pick what's worth taking. No obligation — purely to make the deltas visible.

What this adds

Your QueryError (and ResultData) currently carry only the error message text, so a test can't assert on a specific error condition except by string-matching the (locale-dependent) message. This adds:

  • SQLSTATE extraction from the failed result via PQresultErrorField (already declared in your bindings.py, just unused) onto a new ResultData.sqlstate, carried on QueryError.sqlstate / .sqlstate_class.
  • Named QueryError subclasses for the SQLSTATEs tests most often assert on — QueryCanceled, UniqueViolation, DeadlockDetected, SerializationFailure, etc. — dispatched by query_error_for() in query_safe.

So a test reads:

with pytest.raises(QueryCanceled):
    session.query_safe("SELECT pg_sleep(5)")   # under statement_timeout

instead of pytest.raises(QueryError) followed by a message regex. Every subclass is still catchable as QueryError/LibpqError, and an unmapped SQLSTATE still raises a plain QueryError, so nothing existing breaks.

Motivation / provenance

This revives the per-error-code idea from an earlier revision of Jelte's patchset (which you noted on -hackers had been dropped). SQLSTATE matching is the stable, locale-independent contract — message text changes with translations and across major versions, SQLSTATEs don't — so it's the right basis for negative-path assertions, of which the suite has many (constraint violations, lock timeouts, recovery-conflict cancellations).

The diff is deliberately small and in your idiom: +1 field on ResultData, the subclasses + a dispatch table in errors.py, and one call-site change in query_safe. query_oneval is left raising plain QueryError since it reads only the connection-level message (no per-result SQLSTATE); easy to extend later if wanted.

[1] gburd/postgres#24

Extract the SQLSTATE from a failed result (PQresultErrorField, which bindings.py
already declares) onto ResultData.sqlstate, carry it on QueryError, and add
named QueryError subclasses (QueryCanceled, UniqueViolation, DeadlockDetected,
...) so a test can write `with pytest.raises(QueryCanceled):` instead of
catching the generic QueryError and string-matching its message. query_safe
raises the SQLSTATE-specific subclass via query_error_for(); every subclass
remains catchable as QueryError / LibpqError, and an unmapped SQLSTATE still
raises a plain QueryError.
@gburd gburd mentioned this pull request Jun 16, 2026
@adunstan adunstan merged commit e725bfe into adunstan:pytap/v3 Jun 17, 2026
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.

2 participants