pqi-conformance
Differential conformance tests for pqi adapters
https://github.com/nikita-volkov/pqi-conformance
| Version on this page: | 1.0.9.0 |
| Stackage Nightly 2026-08-19: | 1.0.9.0 |
| Latest on Hackage: | 1.0.10.0 |
pqi-conformance-1.0.9.0@sha256:63e3f4fdb4fa3cef55a30428c034b4734cb71124ecb8ab320cf56a00c5503263,7636Module documentation for 1.0.9.0
pqi-conformance
A reusable hspec toolkit that
checks any pqi adapter against the
battle-tested postgresql-libpq
library as a reference.
Goal: full libpq fidelity
The purpose of this suite is to enforce identical output to libpq
for every protocol-derived value. This means error message strings, notice
text, result status, field metadata, cell data, and all structured error fields
must all match libpq’s output exactly, not just in shape or presence.
The suite runs the same operation on the candidate adapter and on a direct
postgresql-libpq
reference connection (the fidelity reference, which delegates directly to the C
libpq library), connected to the same throwaway PostgreSQL container. It then
asserts that the protocol-derived observations are equal.
Structurally incomparable values
A small number of values are structurally incomparable across connections and are handled differently:
backendPID- the OS process ID of the backend. Each connection gets a distinct backend, so the two PIDs will never match. The spec asserts> 0independently for each adapter.socket- the file descriptor of the client socket. Also connection-specific. Covered only by its own presence check.Notify.bePid- the PID of the notifying backend. Since each adapter’s connection has its own backend, cross-adapter comparison would always fail. Instead, each adapter asserts independently thatnotification.bePid == backendPID connection(a within-connection assertion that verifies the PID field is correctly populated).
These omissions are a structural constraint of the differential testing approach, not an intentional leniency in the suite. All other values - including error message text, notice text, and cancel error text - are compared in full.
Structure: one module per operation
The suite is organised as one spec module per API operation, under
Pqi.Conformance.Operation.* - ...Operation.Exec, ...Operation.ExecParams,
...Operation.LoSeek, ...Operation.Fnumber, and so on, one for every public
method of Pqi.IsConnection, Pqi.IsResult, and Pqi.IsCancel,
plus the connection-independent Adapter fields Pqi.unescapeBytea and
Pqi.resStatus. Each module holds the differential
scenarios that exercise that one operation (its happy paths, its edge cases,
and its error paths). Shared scenario fragments live in
Pqi.Conformance.Scenario.
Usage
An adapter’s own test suite is a one-liner - it hands specs a Proxy of its
connection type and specs takes care of booting the container and running the
whole battery (every operation, the coverage meta-test, and SCRAM):
module Main (main) where
import Data.Proxy (Proxy (..))
import Pqi.Conformance (specs)
import MyAdapter (MyConnection)
import Test.Hspec (hspec)
main :: IO ()
main = hspec (specs (Proxy @MyConnection))
MyConnection only needs a Pqi.IsConnection instance; the candidate and
the postgresql-libpq reference are both driven through that interface.
Contributing
Contributions extending the suite are very welcome. The more thoroughly we cover the operations, the more confidence we can have in the adapters.
Changes
v1.0.9.0
Non-breaking
-
Added a
connectdbspec covering a host name that DNS cannot resolve, assertingerrorMessageexactly against the reference. Foundpqi-native‘s failure message is the rawShowngetAddrInfoexception, worded unrelatedly to libpq’s own (locale-translated) sentence for the same failure, so a downstream classifier that pattern-matches libpq’s wording reacts inconsistently between the two adapters. Found viahasqlissue #329. -
Added a
connectdbspec covering a handshake-time connection reset (ECONNRESET, forced viaSO_LINGER0) as distinct from a clean EOF. Foundpqi-native’shandshakeFailureMessageonly special-cases EOF with libpq-matching wording (“server closed the connection unexpectedly”) and falls back to the rawShownIOException(e.g.Network.Socket.recvBuf: resource vanished (Connection reset by peer)) for any other handshake-time I/O error, even though libpq itself reports a hard reset with the very same EOF wording. Found viahasqlissue #329. -
Added an
execspec covering a connection reset while a query response is in flight, after a valid handshake. Found none ofpqi-native’s query functions (exec,execParams,prepare,execPrepared,describePrepared,describePortal) wrap their read loop in any exception handler at all, so a connection lost mid-query escapes as a raw, uncaughtIOExceptioninstead of any classified failure - unlike libpq, which returns aFatalErrorresult with its own “server closed the connection unexpectedly” message and never throws. Found viahasqlissue #329.
v1.0.8.0
Non-breaking
- Added a
connectdbspec covering a Unix-socket directory that doesn’t exist, assertingerrorMessageexactly against the reference. Foundpqi-native‘s failure message is the rawShownIOException, which happens to contain"could not connect to server"- one ofHasql.Connection‘s networking (transient) patterns - while libpq’s message for the identicalENOENTdoesn’t, so the two adapters driveHasql.Connection.acquireto opposite classifications of the same failure. Found viahasqlissue #329.
v1.0.7.0
Non-breaking
- Added a
connectdbspec covering apostgresql://URI whose host segment is a percent-encoded Unix-socket directory, checked via the parsed host rather than the failure message. Foundpqi-native’s URI parser never runs the host segment through percent-decoding, unlike every sibling component.
v1.0.6.0
Non-breaking
-
Added a
pipelineSyncspec covering a pipelined command whose result gets misattributed to a later, unrelated command after a prior pipeline aborted on a server error: a named-statement prepare discarded by the server after an earlier command in the same pipeline fails, followed by a plainSELECTin the next pipeline on the same connection, which must come backTuplesOkrather than short-circuiting asCommandOk.pqi-native1.0.1.5 fails it, 1.0.1.6 fixes it. Found viapqi-nativeissue #9. -
Added a
connectdbspec covering a mid-handshake server rejection: a listener that accepts the connection, writes the first three bytes of anErrorResponse frame, then closes before the frame completes - mirroring how a server sheds load with e.g. “sorry, too many clients already”. A sound adapter reportsConnectionBadwith a classified error message, the same way it reports any other rejected handshake, instead of letting the underlying I/O exception escapeconnectdb.Unlike most operation specs, this one drives a raw listener rather than the shared PostgreSQL container: the failure is about how an adapter reacts to a truncated read, which a real server only triggers racily (e.g. under
max_connectionspressure). A hand-rolled listener reproduces the exact byte pattern deterministically. Adds a newnetworkdependency.The spec asserts full equality on both
statusanderrorMessage, not juststatus: an adapter that swaps in a made-up message instead of reproducing libpq’s actual wording would otherwise slip through unnoticed. Getting that message comparison to hold required two adjustments once real libpq’s behavior was checked against: the conninfo passessslmode=disable, since libpq negotiates SSL before the startup packet by default and the candidate adapters under test never do, so without it the two sides would be reacting to the truncated bytes at different points in the protocol; and the listener’s host is given as a literal IP (127.0.0.1) rather than a name, since libpq only parenthesizes a resolved IP in its failure message when it differs from the given host string, which a literal IP never does. Both attempts now also share one listener/port, since the failure message embeds the port number and two independently-bound ephemeral listeners would otherwise never produce an equal message even when every other detail matches.pqi-nativethrows an uncaughtIOExceptionout ofconnectdbhere, since only the initial TCP connect was wrapped in an exception handler, not the handshake read that follows it. Found viapqi-nativeissue #8, itself found while investigatinghasqlissue #329.
v1.0.5.0
Non-breaking
- Added
sendQueryParamsspecs covering a parameter list longer than 65535:libpqrejects it locally, without writing anything to the socket, both standalone and in the middle of a pipeline where the commands surrounding it must still get dispatched and drained without desync.pqi-native1.0.1.3 fails both, encoding the parameter count as a wrapping 16-bit field instead of validating it, which corrupted theBindmessage and desynchronized the connection; 1.0.1.4 fixes it. Found viahasqlissue #326.
v1.0.4.0
Non-breaking
-
Added a
pipelineSyncspec covering an async interruption landing while the sync’sReadyForQueryis the only thing left outstanding. An adapter that defers the interrupt across the wait for a frame, rather than across only the instructions that move the frame into its buffer, has delivery pinned to the instant the frame arrives - the one moment at which it holds a fully-read message it has not yet accounted for. Dropping that particular message costs the caller the connection rather than a result: the adapter still has a sync outstanding, so the nextgetResultgoes back to the socket for a message the backend has already sent and will not send again.Unlike the existing interruption spec, which sweeps a timer across the window, this one constructs it. A flush request separates the command’s results from the sync’s
ReadyForQuery, and aDEFERRABLE INITIALLY DEFERREDtrigger - fired by the backend’s handling ofSyncitself - holds thatReadyForQueryback for half a second, so the interrupt lands with 450ms of margin either side.pqi-native1.0.1.2 fails it, 1.0.1.3 passes it. Found via a hang inhasql’sIntegration.Sharing.Connection.Use.PipelineAbortedInterruptionCleanup
v1.0.3.0
Non-breaking
- Added a
pipelineSyncspec covering resilience to an asynchronous interruption around a pipeline abort. Found the native adapter can leave a connection permanently unusable instead of failing outright, since none of its transport/connection code is exception-safe
v1.0.2.1
Fixes
- Fix a flaky test
v1.0.2.0
Non-breaking
- Added a differential spec for
sendQueryParamsin pipeline mode, catching that the native adapter charges its ownParseCompleteagainst a pendingsendPrepareand so emits a spurious leadingCommandOk, shifting every later result
v1.0.1.0
Non-breaking
- Picked up
pqi1.1.0.0, which renamedNotify’s fields tonotifyRelname/notifyBePid/notifyExtrato matchpostgresql-libpq
v1.0.0.1
Doc corrections.
v1.0.0.0
Non-breaking
- Differential spec for the new
resStatusfield ofPqi.Adapter
v0.1.2.2
Fixes
- Fixed the reference adapter’s
ntuplesandnfieldsintermittently returning garbage, which made the differential suite fail correct candidates.postgresql-libpqimportsPQntuplesandPQnfieldsas pure functions and lets the resulting thunk escapewithForeignPtr, so if the result handle got collected before the count was forced, the number was read from memory thatPQclearhad already freed. Both counts are now forced while the handle is still alive.
v0.1.2.1
- Fixed the publishing.
v0.1.2.0
- Added another test.
v0.1.1.1
- Fixed the docs.
v0.1.1.0
Non-breaking
- Added differential coverage for extra conninfo params (e.g.
application_name) supplied at connect time, closing the gap where onlySET-based updates were covered
v0.1.0.1
Fixes
- Removed remaining
OverloadedRecordDotusages from the source, since that extension (along withDuplicateRecordFieldsandNoFieldSelectors) was dropped from the default extensions to support GHC 8.10
v0.1.0.0
Breaking
- Migrated to record-of-functions API structure
Non-breaking
- Added parity specification for pipeline sync operations
- Added UnescapeBytea to the conformance test battery