Skip to main content

pylon_pgcon/
lib.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! Postgres connection pooling and query execution, via `tokio-postgres` +
21//! `deadpool-postgres`. No dependency on `pylon-core` or PyO3 — usable as a
22//! plain Rust Postgres client crate on its own; `pylon-core` depends on
23//! *this* crate for execution, not the other way around.
24//!
25//! `query_raw` returns raw `tokio_postgres::Row`s; `wire` decodes the
26//! composite `result` column those rows carry into `pylon_value::DecodedValue`
27//! (the shared decode target `pylon-cache` also stores).
28
29pub mod error;
30pub mod listener;
31pub mod numeric;
32pub mod wire;
33
34pub use error::{Error, Result};
35pub use listener::PgListener;
36pub use wire::{ExtensionOids, decode_value};
37
38use pylon_value::DecodedValue;
39
40/// Captures a column's raw wire bytes regardless of its declared Postgres
41/// type. `tokio_postgres`'s own `&[u8]` `FromSql` impl only accepts
42/// `BYTEA` — the `result` column pylon-core emits is always `record`
43/// (OID 2249), so a plain `row.get::<_, &[u8]>(0)` would panic on the type
44/// check. This wrapper's `accepts` is unconditionally `true`, matching the
45/// well-established pattern for pulling raw bytes out of any column.
46struct RawBytes<'a>(&'a [u8]);
47
48impl<'a> postgres_types::FromSql<'a> for RawBytes<'a> {
49    fn from_sql(
50        _ty: &postgres_types::Type,
51        raw: &'a [u8],
52    ) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
53        Ok(RawBytes(raw))
54    }
55
56    fn accepts(_ty: &postgres_types::Type) -> bool {
57        true
58    }
59}
60
61/// Wraps a `DecodedValue` for binding as a query parameter. `accepts` is
62/// unconditionally `true` (mirroring `RawBytes` above) because the target
63/// `Type` isn't known until `Statement::params()` reports it — see
64/// `wire::encode_value`, which does the actual type-directed encoding.
65#[derive(Debug)]
66struct BoundParam<'a>(&'a DecodedValue);
67
68impl postgres_types::ToSql for BoundParam<'_> {
69    fn to_sql(
70        &self,
71        ty: &postgres_types::Type,
72        out: &mut bytes::BytesMut,
73    ) -> std::result::Result<postgres_types::IsNull, Box<dyn std::error::Error + Sync + Send>> {
74        Ok(wire::encode_value(self.0, ty, out)?)
75    }
76
77    fn accepts(_ty: &postgres_types::Type) -> bool {
78        true
79    }
80
81    postgres_types::to_sql_checked!();
82}
83
84// `deadpool_postgres::Pool` is `Arc`-backed internally, so cloning a
85// `PgPool` is cheap and shares the same underlying pool — needed at the
86// pyo3 boundary, where a lock guard over the process-global pool slot
87// can't be held across an `.await` (it isn't `Send`), so callers clone the
88// pool out from under the lock first.
89#[derive(Clone, Debug)]
90pub struct PgPool {
91    pool: deadpool_postgres::Pool,
92    types: ExtensionOids,
93    /// Label for this pool in observability output. Set via
94    /// `PgPool::set_name`; `"default"` until then.
95    name: String,
96}
97
98/// Notified with `(pool name, time spent waiting)` every time a caller
99/// acquires a pooled connection.
100///
101/// A hook rather than a direct metrics call because the dependency only goes
102/// one way: `pylon-workers` (which owns the Prometheus registry) depends on
103/// this crate, so this crate can't call into it. Whoever owns the registry
104/// installs an observer at startup; nobody installing one costs a single
105/// `OnceLock` read per checkout.
106pub type PoolWaitObserver = fn(&str, std::time::Duration);
107
108static POOL_WAIT_OBSERVER: std::sync::OnceLock<PoolWaitObserver> = std::sync::OnceLock::new();
109
110/// Installs the pool-wait observer. The first call wins; later ones are
111/// ignored, so a process that initialises metrics twice can't double-count.
112pub fn set_pool_wait_observer(observer: PoolWaitObserver) {
113    let _ = POOL_WAIT_OBSERVER.set(observer);
114}
115
116/// Snapshot of a pool's connection accounting — deadpool's own `Status`,
117/// re-exported under this crate's name so callers (the Prometheus gauge
118/// sampler in `pylon-py`) don't need a direct `deadpool_postgres`
119/// dependency just to read four numbers.
120pub struct PoolStatus {
121    /// Connections actually established right now (idle + checked out).
122    pub size: usize,
123    /// Idle connections available for immediate checkout right now.
124    pub available: usize,
125    /// Callers currently blocked in `.get()` waiting for a connection.
126    pub waiting: usize,
127    pub max_size: usize,
128}
129
130impl PgPool {
131    /// Connects using a `postgresql://` DSN, matching the DSN Python's
132    /// `DatabaseConfig`/`_build_dsn` already produces today. No TLS support
133    /// yet — no SSL/TLS surface exists anywhere in the project currently
134    /// (confirmed by a full-repo grep during planning), so this isn't a
135    /// regression; it's simply not needed until it is.
136    ///
137    /// Unlike `deadpool_postgres::Pool::builder(..).build()` on its own —
138    /// which only validates the DSN and is otherwise lazy, deferring the
139    /// first real connection attempt to whenever a caller first acquires
140    /// one — this eagerly acquires and immediately releases one connection
141    /// before returning, so a bad host/port/database/credentials fails
142    /// right here. That eager-connect
143    /// behavior, which the caller (`Client.ensure_connected`) depends on to
144    /// raise `ConnectionFailedError`/`ConnectionTimeoutError` immediately
145    /// rather than silently deferring the failure to the first query.
146    pub async fn connect(dsn: &str, max_size: usize) -> Result<Self> {
147        let pg_config = session_config(dsn)?;
148        let manager = deadpool_postgres::Manager::new(pg_config, tokio_postgres::NoTls);
149        let pool = deadpool_postgres::Pool::builder(manager)
150            .max_size(max_size)
151            .create_timeout(Some(std::time::Duration::from_secs(10)))
152            .runtime(deadpool_postgres::Runtime::Tokio1)
153            .build()?;
154        let client = pool.get().await?;
155        // Same round trip that proves the pool can connect also classifies
156        // this database's enum/domain/extension type OIDs, which are
157        // assigned locally and so can't be known statically.
158        let types = discover_types(&client).await?;
159        drop(client);
160        Ok(Self {
161            pool,
162            types,
163            name: "default".to_string(),
164        })
165    }
166
167    /// The type OIDs discovered for this database when the pool connected.
168    /// Pass this to `query_typed`/`query_composite` rather than
169    /// `ExtensionOids::default()` — without it, a `vector`, enum, or domain
170    /// column has no decoder.
171    pub fn types(&self) -> &ExtensionOids {
172        &self.types
173    }
174
175    /// Re-runs type discovery. Needed after a migration creates an enum,
176    /// domain, or extension type, since the registry is a connect-time
177    /// snapshot and a pool normally outlives a migration.
178    pub async fn refresh_types(&mut self) -> Result<()> {
179        let client = self.checkout().await?;
180        self.types = discover_types(&client).await?;
181        // A migration is also exactly when a cached prepared statement can
182        // go stale: Postgres invalidates a server-side plan whose result
183        // type changed, and re-executing one raises `0A000 cached plan must
184        // not change result type`. The two always need clearing together.
185        drop(client);
186        self.clear_statement_caches();
187        Ok(())
188    }
189
190    /// Drops every pooled connection's prepared-statement cache.
191    ///
192    /// Must be called after any DDL that could change a statement's result
193    /// type or parameter types — `refresh_types` already does, so migration
194    /// paths get this for free. Cheap: it only empties the cache maps, it
195    /// does not touch the connections.
196    pub fn clear_statement_caches(&self) {
197        self.pool.manager().statement_caches.clear();
198    }
199
200    /// Labels this pool in observability output — see `PoolWaitObserver`.
201    pub fn set_name(&mut self, name: impl Into<String>) {
202        self.name = name.into();
203    }
204
205    /// Acquires a pooled connection, reporting how long that took to the
206    /// pool-wait observer if one is installed.
207    ///
208    /// Every query path goes through this rather than `self.pool.get()`
209    /// directly, so "time spent waiting for a connection" is measured in one
210    /// place and can't drift between call sites.
211    async fn checkout(&self) -> Result<deadpool_postgres::Object> {
212        let started = std::time::Instant::now();
213        let client = self.pool.get().await?;
214        if let Some(observe) = POOL_WAIT_OBSERVER.get() {
215            observe(&self.name, started.elapsed());
216        }
217        Ok(client)
218    }
219
220    /// Current connection accounting — for the Prometheus gauge sampler
221    /// (`pylon-py`'s `record_pool_metrics`), not used on any query path.
222    pub fn status(&self) -> PoolStatus {
223        let s = self.pool.status();
224        PoolStatus {
225            size: s.size,
226            available: s.available,
227            waiting: s.waiting,
228            max_size: s.max_size,
229        }
230    }
231
232    /// Executes `sql` with no parameters and returns the raw rows.
233    /// Composite/record decoding is a later phase — this only proves the
234    /// pool can connect and round-trip a query end to end.
235    pub async fn query_raw(&self, sql: &str) -> Result<Vec<tokio_postgres::Row>> {
236        let client = self.checkout().await?;
237        let rows = client.query(sql, &[]).await?;
238        Ok(rows)
239    }
240
241    /// Runs `sql` (expected to produce exactly one column, matching
242    /// pylon-core's `SELECT (...) AS result` emission) and decodes that
243    /// column of every row via `wire::decode_value`, using its actual
244    /// declared Postgres type (not assumed to be `record` — a bare scalar
245    /// `result` column decodes just as well through the same path).
246    pub async fn query_composite(&self, sql: &str, ext: &ExtensionOids) -> Result<Vec<DecodedValue>> {
247        let rows = self.query_raw(sql).await?;
248        rows.iter().map(|row| decode_result_column(row, ext)).collect()
249    }
250
251    /// Runs `sql` with bound `params`, matched positionally to `$1, $2, ...`
252    /// — the same convention `pylon-core`'s `param_names` already assumes.
253    /// No caller-supplied parameter types: `prepare` asks Postgres itself
254    /// to analyze the SQL and report each placeholder's expected `Type`
255    /// (`Statement::params()`), which drives `wire::encode_value`'s
256    /// encoding directly. Decodes the single result column exactly like
257    /// `query_composite`.
258    pub async fn query_typed(
259        &self,
260        sql: &str,
261        params: &[DecodedValue],
262        ext: &ExtensionOids,
263    ) -> Result<Vec<DecodedValue>> {
264        let client = self.checkout().await?;
265        query_typed_on(&client, sql, params, ext).await
266    }
267
268    /// Like `query_typed`, but decodes every column of every row by name
269    /// instead of assuming a single `(...) AS result` column — for
270    /// hand-written admin SQL (CLI commands, not `pylon-core`-emitted
271    /// query bodies) that reads named columns directly.
272    pub async fn query_typed_named(
273        &self,
274        sql: &str,
275        params: &[DecodedValue],
276        ext: &ExtensionOids,
277    ) -> Result<Vec<DecodedValue>> {
278        let client = self.checkout().await?;
279        query_typed_named_on(&client, sql, params, ext).await
280    }
281
282    /// `query_typed`, with the session globals database triggers read
283    /// (`pylon.globals`) set first on the same connection. Each mutating
284    /// statement sets them anew, so a pooled connection never hands one
285    /// statement's globals to the next.
286    pub async fn query_typed_with_globals(
287        &self,
288        sql: &str,
289        params: &[DecodedValue],
290        ext: &ExtensionOids,
291        globals: &str,
292    ) -> Result<Vec<DecodedValue>> {
293        let client = self.checkout().await?;
294        set_globals_on(&client, globals, false).await?;
295        query_typed_on(&client, sql, params, ext).await
296    }
297
298    /// `execute_typed`, with `pylon.globals` set — see `query_typed_with_globals`.
299    pub async fn execute_typed_with_globals(&self, sql: &str, params: &[DecodedValue], globals: &str) -> Result<u64> {
300        let client = self.checkout().await?;
301        set_globals_on(&client, globals, false).await?;
302        execute_typed_on(&client, sql, params).await
303    }
304
305    /// Runs `sql` with bound `params` (same convention as `query_typed`)
306    /// and discards the result, returning the number of rows affected —
307    /// for `INSERT`/`UPDATE`/`DELETE` where the caller has no `RETURNING`
308    /// clause to decode.
309    pub async fn execute_typed(&self, sql: &str, params: &[DecodedValue]) -> Result<u64> {
310        let client = self.checkout().await?;
311        execute_typed_on(&client, sql, params).await
312    }
313
314    /// Runs `EXPLAIN (ANALYZE, FORMAT JSON, VERBOSE) sql` with bound
315    /// `params` and returns the raw JSON output text verbatim — for
316    /// `analyze <query>` (see `pylon_core::analyze`), which parses this text
317    /// itself to correlate plan nodes back to the query's own shape.
318    /// Actually *runs* the query (`ANALYZE`), same as Postgres's own
319    /// `EXPLAIN ANALYZE`, not just its planner estimate.
320    pub async fn query_explain(&self, sql: &str, params: &[DecodedValue]) -> Result<String> {
321        let client = self.checkout().await?;
322        query_explain_on(&client, sql, params).await
323    }
324
325    /// Acquires one pooled connection and starts an explicit transaction at
326    /// the given isolation level (`"read_uncommitted"`, `"read_committed"`,
327    /// `"repeatable_read"`, or `"serializable"` — matching
328    /// `AsyncTransaction`'s existing accepted values in `client.py`, itself
329    /// the four standard SQL isolation levels). The returned
330    /// `PgTransaction` owns the connection until `commit`/`rollback`
331    /// consumes it.
332    pub async fn begin(&self, isolation: &str) -> Result<PgTransaction> {
333        let client = self.checkout().await?;
334        let level = match isolation {
335            "read_uncommitted" => "READ UNCOMMITTED",
336            "read_committed" => "READ COMMITTED",
337            "repeatable_read" => "REPEATABLE READ",
338            "serializable" => "SERIALIZABLE",
339            other => return Err(Error::message(format!("unknown isolation level: {other:?}"))),
340        };
341        client.batch_execute(&format!("BEGIN ISOLATION LEVEL {level}")).await?;
342        Ok(PgTransaction {
343            client,
344            types: self.types.clone(),
345        })
346    }
347
348    /// Starts a transaction with no explicit isolation level — whatever
349    /// Postgres's own session/database default is applies. Used by
350    /// migration execution, which (unlike `begin`) never needs a specific
351    /// isolation level — this matches a plain `conn.transaction()`
352    /// (no `isolation=` kwarg) that the old Python migration executor used.
353    pub async fn begin_default(&self) -> Result<PgTransaction> {
354        let client = self.checkout().await?;
355        client.batch_execute("BEGIN").await?;
356        Ok(PgTransaction {
357            client,
358            types: self.types.clone(),
359        })
360    }
361
362    /// Runs `sql` via the simple query protocol — no bind parameters, but
363    /// (unlike `query_typed`/`execute_typed`, which prepare via the extended
364    /// protocol and so accept exactly one statement) able to run several
365    /// `;`-separated statements in one call. Matches
366    /// `Connection.execute(sql)` called with no arguments, which migration
367    /// DDL steps rely on (a step's body is whatever raw SQL text sits
368    /// between `-- pylon:step` markers, often more than one statement).
369    pub async fn batch_execute(&self, sql: &str) -> Result<()> {
370        let client = self.checkout().await?;
371        client.batch_execute(sql).await?;
372        Ok(())
373    }
374
375    /// Checks out one pooled connection and hands back a handle the caller
376    /// holds across several calls, with no transaction started — for
377    /// state that's scoped to a single session rather than a single
378    /// statement or transaction, the way Postgres advisory locks
379    /// (`pg_advisory_lock`/`pg_advisory_unlock`) are: they're released by
380    /// an explicit unlock (or the session ending), not by a transaction
381    /// boundary, so acquiring and releasing one has to happen on the same
382    /// held connection — calling `PgPool::batch_execute` twice wouldn't
383    /// work, since each call may checkout a different pooled connection.
384    pub async fn connection(&self) -> Result<PgConnection> {
385        let client = self.checkout().await?;
386        Ok(PgConnection {
387            client,
388            types: self.types.clone(),
389        })
390    }
391}
392
393/// One connection checked out of the pool and held by the caller, with no
394/// transaction open — see `PgPool::connection`.
395#[derive(Debug)]
396pub struct PgConnection {
397    client: deadpool_postgres::Object,
398    types: ExtensionOids,
399}
400
401impl PgConnection {
402    /// The type OIDs discovered when the owning pool connected.
403    pub fn types(&self) -> &ExtensionOids {
404        &self.types
405    }
406
407    pub async fn query_typed(
408        &self,
409        sql: &str,
410        params: &[DecodedValue],
411        ext: &ExtensionOids,
412    ) -> Result<Vec<DecodedValue>> {
413        query_typed_on(&self.client, sql, params, ext).await
414    }
415
416    pub async fn execute_typed(&self, sql: &str, params: &[DecodedValue]) -> Result<u64> {
417        execute_typed_on(&self.client, sql, params).await
418    }
419
420    pub async fn batch_execute(&self, sql: &str) -> Result<()> {
421        self.client.batch_execute(sql).await?;
422        Ok(())
423    }
424}
425
426/// Binds `params` to an already-prepared `stmt` and decodes the single
427/// `result` column of every row.
428///
429/// Split out from the `*_on` entry points below so the two kinds of
430/// connection this crate holds can each prepare the way that suits them —
431/// a pooled `deadpool_postgres::Object` through its per-connection
432/// statement cache, a bare `tokio_postgres::Client` (the LISTEN/NOTIFY
433/// connection, which is not pooled) through a plain `prepare` — without
434/// either duplicating the binding and decoding below.
435async fn query_with_stmt(
436    client: &tokio_postgres::Client,
437    stmt: &tokio_postgres::Statement,
438    params: &[DecodedValue],
439    ext: &ExtensionOids,
440) -> Result<Vec<DecodedValue>> {
441    let bound: Vec<BoundParam<'_>> = params.iter().map(BoundParam).collect();
442    let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> =
443        bound.iter().map(|p| p as &(dyn postgres_types::ToSql + Sync)).collect();
444    let rows = client.query(stmt, &param_refs).await?;
445    rows.iter().map(|row| decode_result_column(row, ext)).collect()
446}
447
448/// Like `query_with_stmt`, but decodes every column of every row by name
449/// (`decode_row_named`) instead of assuming column 0 is the whole result —
450/// see `decode_row_named`'s doc comment.
451async fn query_named_with_stmt(
452    client: &tokio_postgres::Client,
453    stmt: &tokio_postgres::Statement,
454    params: &[DecodedValue],
455    ext: &ExtensionOids,
456) -> Result<Vec<DecodedValue>> {
457    let bound: Vec<BoundParam<'_>> = params.iter().map(BoundParam).collect();
458    let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> =
459        bound.iter().map(|p| p as &(dyn postgres_types::ToSql + Sync)).collect();
460    let rows = client.query(stmt, &param_refs).await?;
461    rows.iter().map(|row| decode_row_named(row, ext)).collect()
462}
463
464async fn execute_with_stmt(
465    client: &tokio_postgres::Client,
466    stmt: &tokio_postgres::Statement,
467    params: &[DecodedValue],
468) -> Result<u64> {
469    let bound: Vec<BoundParam<'_>> = params.iter().map(BoundParam).collect();
470    let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> =
471        bound.iter().map(|p| p as &(dyn postgres_types::ToSql + Sync)).collect();
472    Ok(client.execute(stmt, &param_refs).await?)
473}
474
475/// Prepares through the pooled connection's own statement cache, so a query
476/// run twice on the same connection costs one round trip rather than two.
477///
478/// `tokio_postgres::Client::prepare` always issues a PARSE/DESCRIBE round
479/// trip; on a query whose execution is itself a single round trip that
480/// doubles the wire cost. `deadpool`'s cache is keyed on the SQL text and
481/// lives as long as the connection, which is exactly the lifetime a
482/// prepared statement has server-side. Any statement it hands back can go
483/// stale if the schema changes underneath it — see
484/// `PgPool::clear_statement_caches`, which every migration path calls.
485pub(crate) async fn query_typed_on(
486    client: &deadpool_postgres::Object,
487    sql: &str,
488    params: &[DecodedValue],
489    ext: &ExtensionOids,
490) -> Result<Vec<DecodedValue>> {
491    let stmt = client.prepare_cached(sql).await?;
492    query_with_stmt(client, &stmt, params, ext).await
493}
494
495/// See `query_typed_on` — same caching, `decode_row_named` decoding.
496pub(crate) async fn query_typed_named_on(
497    client: &deadpool_postgres::Object,
498    sql: &str,
499    params: &[DecodedValue],
500    ext: &ExtensionOids,
501) -> Result<Vec<DecodedValue>> {
502    let stmt = client.prepare_cached(sql).await?;
503    query_named_with_stmt(client, &stmt, params, ext).await
504}
505
506/// Sets `pylon.globals`, which a database trigger reads its session globals
507/// from; `local` scopes it to the current transaction.
508async fn set_globals_on(client: &deadpool_postgres::Object, globals: &str, local: bool) -> Result<()> {
509    let sql = if local {
510        "SELECT set_config('pylon.globals', $1, true)"
511    } else {
512        "SELECT set_config('pylon.globals', $1, false)"
513    };
514    execute_typed_on(client, sql, &[DecodedValue::Str(globals.to_string())]).await?;
515    Ok(())
516}
517
518/// See `query_typed_on` — same caching, result discarded.
519pub(crate) async fn execute_typed_on(
520    client: &deadpool_postgres::Object,
521    sql: &str,
522    params: &[DecodedValue],
523) -> Result<u64> {
524    let stmt = client.prepare_cached(sql).await?;
525    execute_with_stmt(client, &stmt, params).await
526}
527
528/// The unpooled counterparts, for the LISTEN/NOTIFY connection — a bare
529/// `tokio_postgres::Client` with no `deadpool` wrapper around it and so no
530/// statement cache to reach. Its query volume is a handful of worker
531/// statements, not the query hot path, so a plain `prepare` is fine here.
532pub(crate) async fn query_typed_on_raw(
533    client: &tokio_postgres::Client,
534    sql: &str,
535    params: &[DecodedValue],
536    ext: &ExtensionOids,
537) -> Result<Vec<DecodedValue>> {
538    let stmt = client.prepare(sql).await?;
539    query_with_stmt(client, &stmt, params, ext).await
540}
541
542pub(crate) async fn query_typed_named_on_raw(
543    client: &tokio_postgres::Client,
544    sql: &str,
545    params: &[DecodedValue],
546    ext: &ExtensionOids,
547) -> Result<Vec<DecodedValue>> {
548    let stmt = client.prepare(sql).await?;
549    query_named_with_stmt(client, &stmt, params, ext).await
550}
551
552pub(crate) async fn execute_typed_on_raw(
553    client: &tokio_postgres::Client,
554    sql: &str,
555    params: &[DecodedValue],
556) -> Result<u64> {
557    let stmt = client.prepare(sql).await?;
558    execute_with_stmt(client, &stmt, params).await
559}
560
561/// `EXPLAIN (FORMAT JSON)` returns exactly one row with one column (named
562/// `QUERY PLAN`, typed `json`) holding the entire plan as JSON text — reused
563/// via `RawBytes` (see its own doc comment) rather than depending on the
564/// `with-serde_json-1` feature, since `json`'s wire format is just its plain
565/// UTF-8 text (unlike `jsonb`, which prefixes a version byte).
566pub(crate) async fn query_explain_on(
567    client: &deadpool_postgres::Object,
568    sql: &str,
569    params: &[DecodedValue],
570) -> Result<String> {
571    let wrapped = format!("EXPLAIN (ANALYZE, FORMAT JSON, VERBOSE) {sql}");
572    let stmt = client.prepare_cached(&wrapped).await?;
573    let bound: Vec<BoundParam<'_>> = params.iter().map(BoundParam).collect();
574    let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> =
575        bound.iter().map(|p| p as &(dyn postgres_types::ToSql + Sync)).collect();
576    let rows = client.query(&stmt, &param_refs).await?;
577    let row = rows
578        .into_iter()
579        .next()
580        .ok_or_else(|| Error::message("EXPLAIN produced no output row".to_string()))?;
581    let RawBytes(bytes) = row.try_get::<_, RawBytes<'_>>(0)?;
582    Ok(String::from_utf8_lossy(bytes).into_owned())
583}
584
585/// An explicit transaction on a single connection checked out of the pool.
586/// `deadpool-postgres`'s default recycling method (`Fast`) does *not* run
587/// any reset query when a connection is returned to the pool — unlike
588/// pool release, which always issues `ROLLBACK` itself if the
589/// released connection still has an open transaction. That safety net has
590/// to be reproduced here explicitly, or a connection released mid- or
591/// aborted-transaction would silently corrupt the next borrower's session.
592/// Hence `commit` rolls back on its own failure before returning the error,
593/// and both `commit`/`rollback` consume `self` so the connection is only
594/// ever returned to the pool (via `Drop`) once it is guaranteed to be back
595/// in a clean, non-transactional state.
596#[derive(Debug)]
597pub struct PgTransaction {
598    client: deadpool_postgres::Object,
599    types: ExtensionOids,
600}
601
602impl PgTransaction {
603    /// The type OIDs discovered when the owning pool connected.
604    pub fn types(&self) -> &ExtensionOids {
605        &self.types
606    }
607
608    /// `query_typed`, with `pylon.globals` set for the rest of this
609    /// transaction — see `PgPool::query_typed_with_globals`.
610    pub async fn query_typed_with_globals(
611        &self,
612        sql: &str,
613        params: &[DecodedValue],
614        ext: &ExtensionOids,
615        globals: &str,
616    ) -> Result<Vec<DecodedValue>> {
617        set_globals_on(&self.client, globals, true).await?;
618        query_typed_on(&self.client, sql, params, ext).await
619    }
620
621    /// `execute_typed`, with `pylon.globals` set — see `query_typed_with_globals`.
622    pub async fn execute_typed_with_globals(&self, sql: &str, params: &[DecodedValue], globals: &str) -> Result<u64> {
623        set_globals_on(&self.client, globals, true).await?;
624        execute_typed_on(&self.client, sql, params).await
625    }
626
627    pub async fn query_typed(
628        &self,
629        sql: &str,
630        params: &[DecodedValue],
631        ext: &ExtensionOids,
632    ) -> Result<Vec<DecodedValue>> {
633        query_typed_on(&self.client, sql, params, ext).await
634    }
635
636    pub async fn execute_typed(&self, sql: &str, params: &[DecodedValue]) -> Result<u64> {
637        execute_typed_on(&self.client, sql, params).await
638    }
639
640    /// Runs `sql` via the simple query protocol — see `PgPool::batch_execute`
641    /// for why migration DDL steps need this instead of `execute_typed`.
642    pub async fn batch_execute(&self, sql: &str) -> Result<()> {
643        self.client.batch_execute(sql).await?;
644        Ok(())
645    }
646
647    /// Establishes a named savepoint inside this transaction. Used by
648    /// migration execution's dev-mode rebase: a step runs inside a
649    /// savepoint so an "already exists" error (from `watch` having applied
650    /// the same DDL earlier) can be rolled back to just that step instead
651    /// of aborting the whole outer transaction.
652    pub async fn savepoint(&self, name: &str) -> Result<()> {
653        self.client
654            .batch_execute(&format!("SAVEPOINT {}", listener::quote_ident(name)))
655            .await?;
656        Ok(())
657    }
658
659    pub async fn release_savepoint(&self, name: &str) -> Result<()> {
660        self.client
661            .batch_execute(&format!("RELEASE SAVEPOINT {}", listener::quote_ident(name)))
662            .await?;
663        Ok(())
664    }
665
666    pub async fn rollback_to_savepoint(&self, name: &str) -> Result<()> {
667        self.client
668            .batch_execute(&format!("ROLLBACK TO SAVEPOINT {}", listener::quote_ident(name)))
669            .await?;
670        Ok(())
671    }
672
673    /// Commits the transaction. On failure (e.g. a serialization failure or
674    /// deadlock detected at COMMIT time), best-effort rolls back first so
675    /// the connection isn't returned to the pool still aborted — the
676    /// original commit error is what's returned either way.
677    pub async fn commit(self) -> Result<()> {
678        match self.client.batch_execute("COMMIT").await {
679            Ok(()) => Ok(()),
680            Err(e) => {
681                let _ = self.client.batch_execute("ROLLBACK").await;
682                Err(e.into())
683            }
684        }
685    }
686
687    pub async fn rollback(self) -> Result<()> {
688        self.client.batch_execute("ROLLBACK").await?;
689        Ok(())
690    }
691}
692
693/// Decodes a row's column 0 (the `result` column pylon-core's SQL always
694/// projects) into a `DecodedValue`, using its actual declared Postgres type.
695/// Column 0 itself can be SQL NULL at the top level (not just a NULL
696/// *field within* a composite, which `decode_record`/`decode_array`
697/// already handle) — `RawBytes` has no `from_sql_null` override, so a
698/// direct `row.try_get::<_, RawBytes>(0)` errors on a null column; going
699/// through `Option<RawBytes>` (which `postgres_types` implements generically
700/// for any `T: FromSql`, yielding `None` for SQL NULL) avoids that.
701/// Parses `dsn` and pins the session settings every Pylon connection needs.
702///
703/// `timezone` decides what a `timestamptz` renders as and which day
704/// `date_part('day', …)` reports; `intervalstyle` decides whether an interval
705/// renders as `01:30:00` or `PT1H30M`. Left to the server's configuration,
706/// the same query would give different answers against two databases, and
707/// `<str>` of a datetime or a duration would stop matching the ISO 8601 the
708/// rest of the stack assumes. Sent as startup-packet options rather than a
709/// `SET` after connecting, so a pooled connection cannot be handed out
710/// before they apply.
711fn session_config(dsn: &str) -> Result<tokio_postgres::Config> {
712    let mut config: tokio_postgres::Config = dsn.parse()?;
713    let pinned = "-c timezone=UTC -c intervalstyle=iso_8601";
714    let options = match config.get_options() {
715        Some(existing) => format!("{existing} {pinned}"),
716        None => pinned.to_string(),
717    };
718    config.options(options);
719    Ok(config)
720}
721
722/// Runs `wire::TYPE_DISCOVERY_SQL` on `client` and builds the registry from
723/// it. Shared by `PgPool::connect` and `PgListener::connect` so a pooled
724/// connection and a dedicated listener classify types identically.
725pub(crate) async fn discover_types(client: &tokio_postgres::Client) -> Result<ExtensionOids> {
726    let rows = client.query(wire::TYPE_DISCOVERY_SQL, &[]).await?;
727    Ok(ExtensionOids::from_discovery_rows(rows.iter().map(|r| {
728        let oid: i64 = r.get(0);
729        let typtype: String = r.get(1);
730        let base_oid: i64 = r.get(2);
731        let typname: String = r.get(3);
732        (oid as u32, typtype, base_oid as u32, typname)
733    })))
734}
735
736fn decode_result_column(row: &tokio_postgres::Row, ext: &ExtensionOids) -> Result<DecodedValue> {
737    let oid = row.columns()[0].type_().oid();
738    match row.try_get::<_, Option<RawBytes>>(0)? {
739        None => Ok(DecodedValue::Null),
740        Some(RawBytes(bytes)) => wire::decode_value(oid, bytes, ext),
741    }
742}
743
744/// Decodes *every* column of `row`, keyed by name, as a `DecodedValue::Object`
745/// — the general named-column decode path, unlike
746/// `decode_result_column` (which only ever decodes column 0, matching
747/// pylon-core's own single-composite-column SQL emission convention). For
748/// hand-written queries with several named columns a caller accesses by
749/// name (`row["col"]`) — `pylon.worker`/`pylon.vector`/`pylon.search`'s
750/// index-outbox queries, not PyQL-compiled SQL.
751fn decode_row_named(row: &tokio_postgres::Row, ext: &ExtensionOids) -> Result<DecodedValue> {
752    let mut fields = Vec::with_capacity(row.columns().len());
753    for (i, col) in row.columns().iter().enumerate() {
754        let oid = col.type_().oid();
755        let value = match row.try_get::<_, Option<RawBytes>>(i)? {
756            None => DecodedValue::Null,
757            Some(RawBytes(bytes)) => wire::decode_value(oid, bytes, ext)?,
758        };
759        fields.push((col.name().to_string(), value));
760    }
761    Ok(DecodedValue::Object(fields))
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    /// Real Postgres required — `PYLON_PGCON_TEST_DSN` must be set (no
769    /// hardcoded fallback; point it at a disposable database, never a real
770    /// one). Not run by default (`cargo test -- --ignored` to opt in) so
771    /// the default test run stays hermetic.
772    fn test_dsn() -> String {
773        std::env::var("PYLON_PGCON_TEST_DSN").expect("PYLON_PGCON_TEST_DSN must be set to run live-Postgres tests")
774    }
775
776    #[tokio::test]
777    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
778    async fn connects_and_round_trips_a_scalar_query() {
779        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
780        let rows = pool.query_raw("SELECT 1 + 1").await.unwrap();
781        assert_eq!(rows.len(), 1);
782        let value: i32 = rows[0].get(0);
783        assert_eq!(value, 2);
784    }
785
786    #[tokio::test]
787    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
788    async fn pool_is_reused_across_multiple_queries() {
789        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
790        for i in 0..5 {
791            let rows = pool.query_raw(&format!("SELECT {i}")).await.unwrap();
792            let value: i32 = rows[0].get(0);
793            assert_eq!(value, i);
794        }
795    }
796
797    #[tokio::test]
798    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
799    async fn invalid_dsn_fails_to_connect() {
800        let result = PgPool::connect("not-a-valid-dsn", 5).await;
801        assert!(result.is_err());
802    }
803
804    #[tokio::test]
805    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
806    async fn connect_fails_eagerly_against_a_nonexistent_database() {
807        // A well-formed DSN pointing at a database that doesn't exist must
808        // fail right here, not lazily on the first query — matching
809        // the eager-connect behavior, which
810        // `Client.ensure_connected()` depends on to map this straight to
811        // `ConnectionFailedError` instead of silently deferring the
812        // failure past `ensure_connected()` returning successfully.
813        // Swap out whatever the real database name is (not a hardcoded
814        // substring — that silently no-ops and leaves this pointed at the
815        // real, existing test database if the DSN's db name ever changes)
816        // for one guaranteed not to exist.
817        let dsn = test_dsn();
818        let (prefix, _db) = dsn.rsplit_once('/').expect("DSN must have a database path segment");
819        let bad_dsn = format!("{prefix}/pgcon_definitely_does_not_exist");
820        let result = PgPool::connect(&bad_dsn, 5).await;
821        assert!(result.is_err());
822    }
823
824    #[tokio::test]
825    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
826    async fn bad_sql_returns_an_error_not_a_panic() {
827        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
828        let result = pool.query_raw("SELECT this is not valid sql").await;
829        assert!(result.is_err());
830    }
831
832    // ── query_composite against real Postgres wire data ─────────────────
833    //
834    // The wire::tests module already checks decode_value against
835    // hand-crafted byte buffers — these tests instead run real SQL through
836    // a real connection, so any mismatch between my assumptions about
837    // Postgres's binary format and what Postgres actually sends shows up
838    // here, not just in self-consistent hand-rolled fixtures.
839
840    #[tokio::test]
841    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
842    async fn decodes_a_bare_scalar_result_column() {
843        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
844        let rows = pool
845            .query_composite("SELECT 42::int8 AS result", &ExtensionOids::default())
846            .await
847            .unwrap();
848        assert_eq!(rows, vec![DecodedValue::I64(42)]);
849    }
850
851    #[tokio::test]
852    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
853    async fn decodes_a_composite_matching_pylon_cores_own_emission_shape() {
854        // Mirrors exactly what `sql/mod.rs::emit_bound_select` emits:
855        // `SELECT (type_disc, col1, col2, ...) AS result`.
856        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
857        let sql = "SELECT ('Person'::text, 'Alice'::text, 30::int8, NULL::text) AS result";
858        let rows = pool.query_composite(sql, &ExtensionOids::default()).await.unwrap();
859        assert_eq!(
860            rows,
861            vec![DecodedValue::Composite(vec![
862                DecodedValue::Str("Person".into()),
863                DecodedValue::Str("Alice".into()),
864                DecodedValue::I64(30),
865                DecodedValue::Null,
866            ])]
867        );
868    }
869
870    #[tokio::test]
871    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
872    async fn decodes_nested_composite_and_array_of_composite_for_real() {
873        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
874        let sql = "SELECT (\
875            'Product'::text, \
876            ROW('Tag'::text, 'sale'::text), \
877            ARRAY[ROW(1::int8), ROW(2::int8)]::record[]\
878        ) AS result";
879        let rows = pool.query_composite(sql, &ExtensionOids::default()).await.unwrap();
880        assert_eq!(
881            rows,
882            vec![DecodedValue::Composite(vec![
883                DecodedValue::Str("Product".into()),
884                DecodedValue::Composite(vec![DecodedValue::Str("Tag".into()), DecodedValue::Str("sale".into())]),
885                DecodedValue::Array(vec![
886                    DecodedValue::Composite(vec![DecodedValue::I64(1)]),
887                    DecodedValue::Composite(vec![DecodedValue::I64(2)]),
888                ]),
889            ])]
890        );
891    }
892
893    #[tokio::test]
894    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
895    async fn decodes_array_of_text_for_real() {
896        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
897        let sql = "SELECT (ARRAY['a', 'b', NULL]::text[]) AS result";
898        let rows = pool.query_composite(sql, &ExtensionOids::default()).await.unwrap();
899        assert_eq!(
900            rows,
901            vec![DecodedValue::Array(vec![
902                DecodedValue::Str("a".into()),
903                DecodedValue::Str("b".into()),
904                DecodedValue::Null,
905            ])]
906        );
907    }
908
909    #[tokio::test]
910    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
911    async fn decodes_numeric_and_jsonb_and_uuid_for_real() {
912        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
913        let sql = "SELECT (\
914            12.50::numeric, \
915            '{\"a\": 1, \"b\": [1,2]}'::jsonb, \
916            '11111111-1111-1111-1111-111111111111'::uuid\
917        ) AS result";
918        let rows = pool.query_composite(sql, &ExtensionOids::default()).await.unwrap();
919        let DecodedValue::Composite(fields) = &rows[0] else {
920            panic!("expected Composite")
921        };
922        assert_eq!(fields[0], DecodedValue::Decimal("12.50".to_string()));
923        assert_eq!(
924            fields[1],
925            DecodedValue::Object(vec![
926                ("a".into(), DecodedValue::I64(1)),
927                (
928                    "b".into(),
929                    DecodedValue::Array(vec![DecodedValue::I64(1), DecodedValue::I64(2)])
930                ),
931            ])
932        );
933        assert_eq!(fields[2], DecodedValue::Uuid([0x11; 16]));
934    }
935
936    #[tokio::test]
937    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
938    async fn decodes_bytea_for_real() {
939        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
940        let rows = pool
941            .query_composite("SELECT '\\xdeadbeef'::bytea AS result", &ExtensionOids::default())
942            .await
943            .unwrap();
944        assert_eq!(rows, vec![DecodedValue::Bytes(vec![0xde, 0xad, 0xbe, 0xef])]);
945    }
946
947    #[tokio::test]
948    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
949    async fn decodes_enum_cast_to_text_for_real() {
950        // pylon-core always ::text-casts enum-typed columns (sql/mod.rs's
951        // emit_scalar) specifically so the runtime-assigned enum OID never
952        // needs to be known statically — this is the actually-exercised path.
953        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
954        let sql = "DO $$ BEGIN CREATE TYPE pgcon_test_enum AS ENUM ('a', 'b'); \
955                   EXCEPTION WHEN duplicate_object THEN NULL; END $$;";
956        pool.query_raw(sql).await.ok();
957        let rows = pool
958            .query_composite(
959                "SELECT ('a'::pgcon_test_enum::text) AS result",
960                &ExtensionOids::default(),
961            )
962            .await
963            .unwrap();
964        assert_eq!(rows, vec![DecodedValue::Str("a".to_string())]);
965    }
966
967    #[tokio::test]
968    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
969    async fn decodes_a_real_pgvector_value_through_connect_time_discovery() {
970        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
971        if pool.query_raw("CREATE EXTENSION IF NOT EXISTS vector").await.is_err() {
972            eprintln!("skipping: pgvector not installable on this server");
973            return;
974        }
975        // The extension may have been created after this pool connected.
976        let mut pool = pool;
977        pool.refresh_types().await.unwrap();
978        assert!(
979            pool.types().vector.is_some(),
980            "discovery should have found the vector OID"
981        );
982
983        // Nested in a record, exactly as pylon-core emits every result.
984        let rows = pool
985            .query_composite("SELECT ('doc', '[1.5,2.5]'::vector) AS result", pool.types())
986            .await
987            .unwrap();
988
989        let DecodedValue::Composite(fields) = &rows[0] else {
990            panic!("expected Composite, got {:?}", rows[0])
991        };
992        assert_eq!(fields[0], DecodedValue::Str("doc".to_string()));
993        assert_eq!(
994            fields[1],
995            DecodedValue::Array(vec![DecodedValue::F64(1.5), DecodedValue::F64(2.5)]),
996            "a vector must decode to its floats, not to mojibake or an error"
997        );
998    }
999
1000    #[tokio::test]
1001    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1002    async fn decodes_a_real_enum_and_domain_through_connect_time_discovery() {
1003        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1004        pool.query_raw(
1005            "DO $$ BEGIN CREATE TYPE pgcon_disc_enum AS ENUM ('x', 'y'); \
1006             EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1007        )
1008        .await
1009        .ok();
1010        pool.query_raw(
1011            "DO $$ BEGIN CREATE DOMAIN pgcon_disc_domain AS int8 CHECK (VALUE > 0); \
1012             EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1013        )
1014        .await
1015        .ok();
1016
1017        let mut pool = pool;
1018        pool.refresh_types().await.unwrap();
1019
1020        // Neither is ::text-cast here, so both arrive with their real
1021        // database-assigned OID and can only decode via the registry.
1022        let rows = pool
1023            .query_composite(
1024                "SELECT ('x'::pgcon_disc_enum, 42::pgcon_disc_domain) AS result",
1025                pool.types(),
1026            )
1027            .await
1028            .unwrap();
1029
1030        let DecodedValue::Composite(fields) = &rows[0] else {
1031            panic!("expected Composite, got {:?}", rows[0])
1032        };
1033        assert_eq!(fields[0], DecodedValue::Str("x".to_string()));
1034        assert_eq!(
1035            fields[1],
1036            DecodedValue::I64(42),
1037            "a domain must decode as its base type, not as text"
1038        );
1039    }
1040
1041    // ── query_typed: bound-parameter round trips against real Postgres ──
1042    //
1043    // Each test binds a DecodedValue as $1, has Postgres echo it straight
1044    // back out (so both encode_value AND decode_value are exercised in one
1045    // pass — a mismatch in either direction fails the assertion), matching
1046    // exactly how a real PyQL query binds a param and gets a result back.
1047
1048    async fn round_trip(pool: &PgPool, pg_type: &str, param: DecodedValue) -> DecodedValue {
1049        let sql = format!("SELECT ($1::{pg_type}) AS result");
1050        let rows = pool
1051            .query_typed(&sql, &[param], &ExtensionOids::default())
1052            .await
1053            .unwrap();
1054        rows.into_iter().next().unwrap()
1055    }
1056
1057    // ── query_explain against real Postgres ─────────────────────────────
1058
1059    #[tokio::test]
1060    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1061    async fn query_explain_returns_parseable_json_with_a_plan_node() {
1062        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1063        let raw = pool.query_explain("SELECT 1 + 1", &[]).await.unwrap();
1064        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1065        assert!(parsed[0]["Plan"]["Node Type"].is_string());
1066        // ANALYZE was requested, so the plan must carry real execution stats.
1067        assert!(parsed[0]["Plan"]["Actual Total Time"].is_number());
1068    }
1069
1070    #[tokio::test]
1071    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1072    async fn query_explain_binds_params_the_same_way_query_typed_does() {
1073        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1074        let raw = pool
1075            .query_explain("SELECT $1::int8 + 1", &[DecodedValue::I64(41)])
1076            .await
1077            .unwrap();
1078        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1079        assert!(parsed[0]["Plan"]["Node Type"].is_string());
1080    }
1081
1082    #[tokio::test]
1083    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1084    async fn round_trips_bool_param() {
1085        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1086        assert_eq!(
1087            round_trip(&pool, "bool", DecodedValue::Bool(true)).await,
1088            DecodedValue::Bool(true)
1089        );
1090        assert_eq!(
1091            round_trip(&pool, "bool", DecodedValue::Bool(false)).await,
1092            DecodedValue::Bool(false)
1093        );
1094    }
1095
1096    #[tokio::test]
1097    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1098    async fn round_trips_integer_params_at_every_width() {
1099        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1100        assert_eq!(
1101            round_trip(&pool, "int2", DecodedValue::I64(30)).await,
1102            DecodedValue::I64(30)
1103        );
1104        assert_eq!(
1105            round_trip(&pool, "int4", DecodedValue::I64(70_000)).await,
1106            DecodedValue::I64(70_000)
1107        );
1108        assert_eq!(
1109            round_trip(&pool, "int8", DecodedValue::I64(9_223_372_036_854_775_807)).await,
1110            DecodedValue::I64(9_223_372_036_854_775_807)
1111        );
1112        assert_eq!(
1113            round_trip(&pool, "int8", DecodedValue::I64(-1)).await,
1114            DecodedValue::I64(-1)
1115        );
1116    }
1117
1118    #[tokio::test]
1119    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1120    async fn round_trips_float_params() {
1121        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1122        assert_eq!(
1123            round_trip(&pool, "float4", DecodedValue::F64(1.5)).await,
1124            DecodedValue::F64(1.5)
1125        );
1126        assert_eq!(
1127            round_trip(&pool, "float8", DecodedValue::F64(2.25)).await,
1128            DecodedValue::F64(2.25)
1129        );
1130    }
1131
1132    #[tokio::test]
1133    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1134    async fn round_trips_text_param() {
1135        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1136        assert_eq!(
1137            round_trip(&pool, "text", DecodedValue::Str("héllo 🎉".to_string())).await,
1138            DecodedValue::Str("héllo 🎉".to_string())
1139        );
1140    }
1141
1142    #[tokio::test]
1143    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1144    async fn round_trips_bytea_param() {
1145        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1146        assert_eq!(
1147            round_trip(&pool, "bytea", DecodedValue::Bytes(vec![1, 2, 3, 255])).await,
1148            DecodedValue::Bytes(vec![1, 2, 3, 255])
1149        );
1150    }
1151
1152    #[tokio::test]
1153    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1154    async fn round_trips_uuid_param() {
1155        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1156        let bytes = [0x11u8; 16];
1157        assert_eq!(
1158            round_trip(&pool, "uuid", DecodedValue::Uuid(bytes)).await,
1159            DecodedValue::Uuid(bytes)
1160        );
1161    }
1162
1163    #[tokio::test]
1164    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1165    async fn binds_a_plain_string_as_a_uuid_param() {
1166        // A JSON API request body (see `pylon-server`'s /api/<connection>/query
1167        // handler) necessarily carries a UUID query parameter as plain text —
1168        // there's no JSON "uuid" type — so it arrives as `DecodedValue::Str`,
1169        // not `::Uuid`. Regression test for a real bug: binding that Str
1170        // directly against a `uuid`-typed parameter used to send raw UTF-8
1171        // text bytes for a binary-format column, which Postgres rejected
1172        // with "incorrect binary data format in bind parameter 1".
1173        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1174        let as_string = DecodedValue::Str("11111111-1111-1111-1111-111111111111".to_string());
1175        assert_eq!(
1176            round_trip(&pool, "uuid", as_string).await,
1177            DecodedValue::Uuid([0x11; 16])
1178        );
1179    }
1180
1181    #[tokio::test]
1182    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1183    async fn binds_a_plain_string_as_a_jsonb_param() {
1184        // Regression test for a real bug: `migration apply`'s db_state
1185        // snapshot update binds already-serialized JSON text (a plain Rust
1186        // `String`, not a `DecodedValue::Object`) as `$1::jsonb` — the same
1187        // class of bug as the UUID case above (a Str value bound against a
1188        // non-text binary-format target type).
1189        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1190        let as_string = DecodedValue::Str(r#"{"a":1,"b":[1,2]}"#.to_string());
1191        assert_eq!(
1192            round_trip(&pool, "jsonb", as_string).await,
1193            DecodedValue::Object(vec![
1194                ("a".into(), DecodedValue::I64(1)),
1195                (
1196                    "b".into(),
1197                    DecodedValue::Array(vec![DecodedValue::I64(1), DecodedValue::I64(2)])
1198                ),
1199            ])
1200        );
1201    }
1202
1203    #[tokio::test]
1204    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1205    async fn round_trips_numeric_param() {
1206        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1207        assert_eq!(
1208            round_trip(&pool, "numeric", DecodedValue::Decimal("12.50".to_string())).await,
1209            DecodedValue::Decimal("12.50".to_string())
1210        );
1211        assert_eq!(
1212            round_trip(&pool, "numeric", DecodedValue::Decimal("-9999.001".to_string())).await,
1213            DecodedValue::Decimal("-9999.001".to_string())
1214        );
1215
1216        // Values a fixed 96-bit/28-place carrier cannot hold. Each of these
1217        // used to come back scaled by a power of ten, or refuse to serialize,
1218        // with `numeric` itself perfectly able to store them. The first is
1219        // what Python's `Decimal(0.0000004)` stringifies to.
1220        for text in [
1221            "0.00000039999999999999998189924473035450347424557548947632312774658203125",
1222            "0.00000012222222222222222222222222222222222222222",
1223            "12222222222222222222222222222222000000000",
1224            "100000000000000000000000000000",
1225            "123456789012345678901234567890.123456789012345678901234567890",
1226        ] {
1227            assert_eq!(
1228                round_trip(&pool, "numeric", DecodedValue::Decimal(text.to_string())).await,
1229                DecodedValue::Decimal(text.to_string()),
1230                "{text}"
1231            );
1232        }
1233
1234        // A JSON request body has no decimal type, so a numeric parameter can
1235        // arrive as a number or as text — both reach the same encoder.
1236        assert_eq!(
1237            round_trip(&pool, "numeric", DecodedValue::Str("0.0000004".to_string())).await,
1238            DecodedValue::Decimal("0.0000004".to_string())
1239        );
1240        assert_eq!(
1241            round_trip(&pool, "numeric", DecodedValue::I64(-12345)).await,
1242            DecodedValue::Decimal("-12345".to_string())
1243        );
1244        assert_eq!(
1245            round_trip(&pool, "numeric", DecodedValue::F64(0.1)).await,
1246            DecodedValue::Decimal("0.1".to_string())
1247        );
1248    }
1249
1250    #[tokio::test]
1251    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1252    async fn round_trips_null_param() {
1253        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1254        assert_eq!(round_trip(&pool, "int8", DecodedValue::Null).await, DecodedValue::Null);
1255        assert_eq!(round_trip(&pool, "text", DecodedValue::Null).await, DecodedValue::Null);
1256    }
1257
1258    #[tokio::test]
1259    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1260    async fn round_trips_array_param() {
1261        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1262        let param = DecodedValue::Array(vec![
1263            DecodedValue::Str("a".into()),
1264            DecodedValue::Str("b".into()),
1265            DecodedValue::Null,
1266        ]);
1267        assert_eq!(round_trip(&pool, "text[]", param.clone()).await, param);
1268    }
1269
1270    #[tokio::test]
1271    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1272    async fn round_trips_int_array_param() {
1273        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1274        let param = DecodedValue::Array(vec![DecodedValue::I64(1), DecodedValue::I64(2), DecodedValue::I64(3)]);
1275        assert_eq!(round_trip(&pool, "int8[]", param.clone()).await, param);
1276    }
1277
1278    #[tokio::test]
1279    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1280    async fn round_trips_jsonb_object_param() {
1281        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1282        let param = DecodedValue::Object(vec![
1283            ("a".into(), DecodedValue::I64(1)),
1284            ("b".into(), DecodedValue::Str("two".into())),
1285            (
1286                "c".into(),
1287                DecodedValue::Array(vec![DecodedValue::I64(1), DecodedValue::I64(2)]),
1288            ),
1289        ]);
1290        assert_eq!(round_trip(&pool, "jsonb", param.clone()).await, param);
1291    }
1292
1293    #[tokio::test]
1294    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1295    async fn query_typed_matches_pylon_cores_own_param_binding_convention() {
1296        // $1, $2, ... positional, matching multiple params in one query —
1297        // the same shape a real PyQL query with several kwargs produces.
1298        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1299        let sql = "SELECT ($1::text, $2::int8, $3::bool) AS result";
1300        let params = vec![
1301            DecodedValue::Str("Alice".into()),
1302            DecodedValue::I64(30),
1303            DecodedValue::Bool(true),
1304        ];
1305        let rows = pool.query_typed(sql, &params, &ExtensionOids::default()).await.unwrap();
1306        assert_eq!(
1307            rows,
1308            vec![DecodedValue::Composite(vec![
1309                DecodedValue::Str("Alice".into()),
1310                DecodedValue::I64(30),
1311                DecodedValue::Bool(true),
1312            ])]
1313        );
1314    }
1315
1316    #[tokio::test]
1317    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1318    async fn wrong_param_count_returns_an_error_not_a_panic() {
1319        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1320        let result = pool
1321            .query_typed(
1322                "SELECT $1::int8, $2::int8",
1323                &[DecodedValue::I64(1)],
1324                &ExtensionOids::default(),
1325            )
1326            .await;
1327        assert!(result.is_err());
1328    }
1329
1330    #[tokio::test]
1331    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1332    async fn execute_typed_runs_a_mutation_and_reports_affected_rows() {
1333        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1334        pool.query_raw("CREATE TEMP TABLE IF NOT EXISTS pgcon_execute_test (id int8, name text)")
1335            .await
1336            .unwrap();
1337
1338        let inserted = pool
1339            .execute_typed(
1340                "INSERT INTO pgcon_execute_test (id, name) VALUES ($1::int8, $2::text)",
1341                &[DecodedValue::I64(1), DecodedValue::Str("alice".into())],
1342            )
1343            .await
1344            .unwrap();
1345        assert_eq!(inserted, 1);
1346
1347        let updated = pool
1348            .execute_typed(
1349                "UPDATE pgcon_execute_test SET name = $1::text WHERE id = $2::int8",
1350                &[DecodedValue::Str("bob".into()), DecodedValue::I64(1)],
1351            )
1352            .await
1353            .unwrap();
1354        assert_eq!(updated, 1);
1355
1356        let rows = pool
1357            .query_composite(
1358                "SELECT (name) AS result FROM pgcon_execute_test",
1359                &ExtensionOids::default(),
1360            )
1361            .await
1362            .unwrap();
1363        assert_eq!(rows, vec![DecodedValue::Str("bob".to_string())]);
1364    }
1365
1366    // ── Error::sqlstate() against real Postgres constraint violations ──
1367    //
1368    // Error mapping to Pylon's Python exception hierarchy (a later phase)
1369    // classifies on these codes (`"23505"` unique violation, etc.)
1370    // already do today — verified against a real server response, not
1371    // assumed from the SQLSTATE spec alone.
1372
1373    #[tokio::test]
1374    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1375    async fn unique_violation_reports_23505() {
1376        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1377        pool.query_raw("CREATE TEMP TABLE pgcon_unique_test (id int8 PRIMARY KEY)")
1378            .await
1379            .unwrap();
1380        pool.execute_typed(
1381            "INSERT INTO pgcon_unique_test (id) VALUES ($1::int8)",
1382            &[DecodedValue::I64(1)],
1383        )
1384        .await
1385        .unwrap();
1386
1387        let err = pool
1388            .execute_typed(
1389                "INSERT INTO pgcon_unique_test (id) VALUES ($1::int8)",
1390                &[DecodedValue::I64(1)],
1391            )
1392            .await
1393            .unwrap_err();
1394        assert_eq!(err.sqlstate(), Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION));
1395        assert_eq!(err.sqlstate().unwrap().code(), "23505");
1396    }
1397
1398    #[tokio::test]
1399    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1400    async fn foreign_key_violation_reports_23503() {
1401        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1402        pool.query_raw("CREATE TEMP TABLE pgcon_fk_parent (id int8 PRIMARY KEY)")
1403            .await
1404            .unwrap();
1405        pool.query_raw("CREATE TEMP TABLE pgcon_fk_child (parent_id int8 REFERENCES pgcon_fk_parent(id))")
1406            .await
1407            .unwrap();
1408
1409        let err = pool
1410            .execute_typed(
1411                "INSERT INTO pgcon_fk_child (parent_id) VALUES ($1::int8)",
1412                &[DecodedValue::I64(999)],
1413            )
1414            .await
1415            .unwrap_err();
1416        assert_eq!(
1417            err.sqlstate(),
1418            Some(&tokio_postgres::error::SqlState::FOREIGN_KEY_VIOLATION)
1419        );
1420    }
1421
1422    #[tokio::test]
1423    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1424    async fn check_violation_reports_23514() {
1425        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1426        pool.query_raw("CREATE TEMP TABLE pgcon_check_test (age int8 CHECK (age >= 0))")
1427            .await
1428            .unwrap();
1429
1430        let err = pool
1431            .execute_typed(
1432                "INSERT INTO pgcon_check_test (age) VALUES ($1::int8)",
1433                &[DecodedValue::I64(-1)],
1434            )
1435            .await
1436            .unwrap_err();
1437        assert_eq!(err.sqlstate(), Some(&tokio_postgres::error::SqlState::CHECK_VIOLATION));
1438        // Temp tables live in a session-specific `pg_temp_N` schema, so only
1439        // the table name (not the exact schema) is asserted here.
1440        assert_eq!(err.violated_table().map(|(_, table)| table), Some("pgcon_check_test"));
1441        assert_eq!(err.violated_scalar(), None);
1442    }
1443
1444    #[tokio::test]
1445    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1446    async fn domain_check_violation_reports_the_domain_name_not_the_constraint_name() {
1447        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1448        pool.query_raw(
1449            "DO $$ BEGIN CREATE DOMAIN pgcon_rating AS int8 CHECK (VALUE BETWEEN 1 AND 5); \
1450             EXCEPTION WHEN duplicate_object THEN NULL; END $$",
1451        )
1452        .await
1453        .unwrap();
1454        pool.query_raw("CREATE TEMP TABLE pgcon_domain_check_test (rating pgcon_rating)")
1455            .await
1456            .unwrap();
1457
1458        let err = pool
1459            .execute_typed(
1460                "INSERT INTO pgcon_domain_check_test (rating) VALUES ($1::pgcon_rating)",
1461                &[DecodedValue::I64(99)],
1462            )
1463            .await
1464            .unwrap_err();
1465        assert_eq!(err.sqlstate(), Some(&tokio_postgres::error::SqlState::CHECK_VIOLATION));
1466        assert_eq!(err.violated_scalar(), Some(("public", "pgcon_rating")));
1467    }
1468
1469    #[tokio::test]
1470    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1471    async fn syntax_error_has_no_sqlstate_matching_constraint_codes() {
1472        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1473        let err = pool.query_raw("SELECT this is not valid sql").await.unwrap_err();
1474        assert_ne!(err.sqlstate(), Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION));
1475    }
1476
1477    #[tokio::test]
1478    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1479    async fn connection_pool_error_has_no_sqlstate() {
1480        // A bad DSN never reaches Postgres at all — no SQLSTATE to report,
1481        // unlike a real server-side rejection.
1482        let result = PgPool::connect("not-a-valid-dsn", 5).await;
1483        let err = result.unwrap_err();
1484        assert_eq!(err.sqlstate(), None);
1485    }
1486
1487    // ── PgTransaction: begin/commit/rollback on real Postgres ───────────
1488
1489    #[tokio::test]
1490    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1491    async fn committed_transaction_persists_its_writes() {
1492        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1493        pool.query_raw("CREATE TEMP TABLE pgcon_tx_commit_test (id int8 PRIMARY KEY)")
1494            .await
1495            .unwrap();
1496
1497        let tx = pool.begin("serializable").await.unwrap();
1498        tx.execute_typed(
1499            "INSERT INTO pgcon_tx_commit_test (id) VALUES ($1::int8)",
1500            &[DecodedValue::I64(1)],
1501        )
1502        .await
1503        .unwrap();
1504        tx.commit().await.unwrap();
1505
1506        let rows = pool
1507            .query_composite(
1508                "SELECT (id) AS result FROM pgcon_tx_commit_test",
1509                &ExtensionOids::default(),
1510            )
1511            .await
1512            .unwrap();
1513        assert_eq!(rows, vec![DecodedValue::I64(1)]);
1514    }
1515
1516    #[tokio::test]
1517    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1518    async fn rolled_back_transaction_discards_its_writes() {
1519        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1520        pool.query_raw("CREATE TEMP TABLE pgcon_tx_rollback_test (id int8 PRIMARY KEY)")
1521            .await
1522            .unwrap();
1523
1524        let tx = pool.begin("serializable").await.unwrap();
1525        tx.execute_typed(
1526            "INSERT INTO pgcon_tx_rollback_test (id) VALUES ($1::int8)",
1527            &[DecodedValue::I64(1)],
1528        )
1529        .await
1530        .unwrap();
1531        tx.rollback().await.unwrap();
1532
1533        let rows = pool
1534            .query_composite(
1535                "SELECT (id) AS result FROM pgcon_tx_rollback_test",
1536                &ExtensionOids::default(),
1537            )
1538            .await
1539            .unwrap();
1540        assert!(rows.is_empty());
1541    }
1542
1543    #[tokio::test]
1544    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1545    async fn begin_actually_sets_the_requested_isolation_level() {
1546        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1547        for (level, expected) in [
1548            ("read_committed", "read committed"),
1549            ("repeatable_read", "repeatable read"),
1550            ("serializable", "serializable"),
1551        ] {
1552            let tx = pool.begin(level).await.unwrap();
1553            let rows = tx
1554                .query_typed(
1555                    "SELECT (current_setting('transaction_isolation')) AS result",
1556                    &[],
1557                    &ExtensionOids::default(),
1558                )
1559                .await
1560                .unwrap();
1561            assert_eq!(rows, vec![DecodedValue::Str(expected.to_string())]);
1562            tx.rollback().await.unwrap();
1563        }
1564    }
1565
1566    #[tokio::test]
1567    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1568    async fn begin_rejects_an_unknown_isolation_level() {
1569        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1570        let result = pool.begin("not_a_real_level").await;
1571        assert!(result.is_err());
1572    }
1573
1574    #[tokio::test]
1575    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1576    async fn a_pooled_connection_is_reusable_after_commit_and_after_rollback() {
1577        // Guards the exact hazard begin()/PgTransaction's doc comment
1578        // describes: deadpool's default Fast recycling does nothing to a
1579        // connection returned mid-transaction, so if commit/rollback ever
1580        // failed to leave the session clean, this small pool (max_size 1)
1581        // would hang forever on the second `begin()` waiting for a
1582        // connection that never becomes usable again.
1583        let pool = PgPool::connect(&test_dsn(), 1).await.unwrap();
1584
1585        let tx = pool.begin("serializable").await.unwrap();
1586        tx.commit().await.unwrap();
1587
1588        let tx = pool.begin("serializable").await.unwrap();
1589        tx.rollback().await.unwrap();
1590
1591        let rows = pool.query_raw("SELECT 1").await.unwrap();
1592        assert_eq!(rows.len(), 1);
1593    }
1594
1595    #[tokio::test]
1596    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1597    async fn failed_commit_leaves_the_connection_reusable() {
1598        // Forces a real 40001 serialization failure at COMMIT time (the
1599        // same interleaving as `serializable_transactions_conflict_with_40001`
1600        // below), on a pool sized to exactly the two connections both
1601        // transactions occupy, then drains the pool with fresh queries to
1602        // prove every connection — including the one that failed COMMIT —
1603        // comes back healthy. Without `commit`'s best-effort
1604        // ROLLBACK-on-failure, the failed connection would still be
1605        // aborted server-side and the next query to land on it would
1606        // immediately fail with "current transaction is aborted".
1607        let pool = PgPool::connect(&test_dsn(), 2).await.unwrap();
1608        pool.query_raw("DROP TABLE IF EXISTS pgcon_tx_failed_commit_test")
1609            .await
1610            .unwrap();
1611        pool.query_raw("CREATE TABLE pgcon_tx_failed_commit_test (class int8, value int8)")
1612            .await
1613            .unwrap();
1614        pool.execute_typed(
1615            "INSERT INTO pgcon_tx_failed_commit_test (class, value) VALUES ($1::int8, $2::int8), ($3::int8, $4::int8)",
1616            &[
1617                DecodedValue::I64(1),
1618                DecodedValue::I64(10),
1619                DecodedValue::I64(2),
1620                DecodedValue::I64(20),
1621            ],
1622        )
1623        .await
1624        .unwrap();
1625
1626        let tx1 = pool.begin("serializable").await.unwrap();
1627        let tx2 = pool.begin("serializable").await.unwrap();
1628
1629        tx1.query_typed(
1630            "SELECT (sum(value)) AS result FROM pgcon_tx_failed_commit_test WHERE class = 1::int8",
1631            &[],
1632            &ExtensionOids::default(),
1633        )
1634        .await
1635        .unwrap();
1636        tx2.query_typed(
1637            "SELECT (sum(value)) AS result FROM pgcon_tx_failed_commit_test WHERE class = 2::int8",
1638            &[],
1639            &ExtensionOids::default(),
1640        )
1641        .await
1642        .unwrap();
1643        tx1.execute_typed(
1644            "INSERT INTO pgcon_tx_failed_commit_test (class, value) VALUES (2::int8, $1::int8)",
1645            &[DecodedValue::I64(10)],
1646        )
1647        .await
1648        .unwrap();
1649        tx2.execute_typed(
1650            "INSERT INTO pgcon_tx_failed_commit_test (class, value) VALUES (1::int8, $1::int8)",
1651            &[DecodedValue::I64(20)],
1652        )
1653        .await
1654        .unwrap();
1655
1656        tx1.commit().await.unwrap();
1657        let commit_result = tx2.commit().await;
1658        assert!(commit_result.is_err());
1659
1660        // Both pooled connections are back now (tx1 released on success,
1661        // tx2 released on Drop after the failed commit) — round-trip each.
1662        for _ in 0..2 {
1663            let rows = pool.query_raw("SELECT 1").await.unwrap();
1664            assert_eq!(rows.len(), 1);
1665        }
1666    }
1667
1668    #[tokio::test]
1669    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1670    async fn serializable_transactions_conflict_with_40001() {
1671        // The canonical serialization-anomaly example from the Postgres
1672        // docs (13.2.3): two SERIALIZABLE transactions each read one
1673        // class's total, then insert a row into the *other* class based on
1674        // what they read. Run concurrently with each SELECT completing
1675        // before either INSERT, this is guaranteed to leave one commit
1676        // rejected with 40001 — this is the exact SQLSTATE `pgcon_err`
1677        // (pylon-py/src/pgcon.rs) maps to `TransactionSerializationError`.
1678        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1679        pool.query_raw("DROP TABLE IF EXISTS pgcon_serialization_test")
1680            .await
1681            .unwrap();
1682        pool.query_raw("CREATE TABLE pgcon_serialization_test (class int8, value int8)")
1683            .await
1684            .unwrap();
1685        pool.execute_typed(
1686            "INSERT INTO pgcon_serialization_test (class, value) VALUES ($1::int8, $2::int8), ($3::int8, $4::int8)",
1687            &[
1688                DecodedValue::I64(1),
1689                DecodedValue::I64(10),
1690                DecodedValue::I64(2),
1691                DecodedValue::I64(20),
1692            ],
1693        )
1694        .await
1695        .unwrap();
1696
1697        let tx1 = pool.begin("serializable").await.unwrap();
1698        let tx2 = pool.begin("serializable").await.unwrap();
1699
1700        tx1.query_typed(
1701            "SELECT (sum(value)) AS result FROM pgcon_serialization_test WHERE class = 1::int8",
1702            &[],
1703            &ExtensionOids::default(),
1704        )
1705        .await
1706        .unwrap();
1707        tx2.query_typed(
1708            "SELECT (sum(value)) AS result FROM pgcon_serialization_test WHERE class = 2::int8",
1709            &[],
1710            &ExtensionOids::default(),
1711        )
1712        .await
1713        .unwrap();
1714
1715        tx1.execute_typed(
1716            "INSERT INTO pgcon_serialization_test (class, value) VALUES (2::int8, $1::int8)",
1717            &[DecodedValue::I64(10)],
1718        )
1719        .await
1720        .unwrap();
1721        tx2.execute_typed(
1722            "INSERT INTO pgcon_serialization_test (class, value) VALUES (1::int8, $1::int8)",
1723            &[DecodedValue::I64(20)],
1724        )
1725        .await
1726        .unwrap();
1727
1728        tx1.commit().await.unwrap();
1729        let err = tx2.commit().await.unwrap_err();
1730        assert_eq!(
1731            err.sqlstate(),
1732            Some(&tokio_postgres::error::SqlState::T_R_SERIALIZATION_FAILURE)
1733        );
1734    }
1735
1736    #[tokio::test]
1737    #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
1738    async fn concurrent_transactions_deadlock_with_40p01() {
1739        // Classic reproducible deadlock: two transactions lock two rows in
1740        // opposite order. tx1 locks row 1 then blocks on row 2; tx2 locks
1741        // row 2 then blocks on row 1 — Postgres's deadlock detector aborts
1742        // one of them with 40P01, `pgcon_err`'s other mapped SQLSTATE
1743        // (-> `TransactionDeadlockError`).
1744        let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
1745        pool.query_raw("DROP TABLE IF EXISTS pgcon_deadlock_test")
1746            .await
1747            .unwrap();
1748        pool.query_raw("CREATE TABLE pgcon_deadlock_test (id int8 PRIMARY KEY, value int8)")
1749            .await
1750            .unwrap();
1751        pool.execute_typed(
1752            "INSERT INTO pgcon_deadlock_test (id, value) VALUES ($1::int8, $2::int8), ($3::int8, $4::int8)",
1753            &[
1754                DecodedValue::I64(1),
1755                DecodedValue::I64(0),
1756                DecodedValue::I64(2),
1757                DecodedValue::I64(0),
1758            ],
1759        )
1760        .await
1761        .unwrap();
1762
1763        let tx1 = pool.begin("read_committed").await.unwrap();
1764        let tx2 = pool.begin("read_committed").await.unwrap();
1765
1766        tx1.execute_typed("UPDATE pgcon_deadlock_test SET value = 1::int8 WHERE id = 1::int8", &[])
1767            .await
1768            .unwrap();
1769        tx2.execute_typed("UPDATE pgcon_deadlock_test SET value = 2::int8 WHERE id = 2::int8", &[])
1770            .await
1771            .unwrap();
1772
1773        // Now each blocks on the row the other is holding — issue both
1774        // concurrently and let Postgres's deadlock detector break the tie.
1775        let (r1, r2) = tokio::join!(
1776            tx1.execute_typed("UPDATE pgcon_deadlock_test SET value = 3::int8 WHERE id = 2::int8", &[]),
1777            tx2.execute_typed("UPDATE pgcon_deadlock_test SET value = 4::int8 WHERE id = 1::int8", &[]),
1778        );
1779
1780        let results = [r1, r2];
1781        let deadlock_errors: Vec<_> = results
1782            .iter()
1783            .filter(|r| matches!(r, Err(e) if e.sqlstate() == Some(&tokio_postgres::error::SqlState::T_R_DEADLOCK_DETECTED)))
1784            .collect();
1785        assert_eq!(
1786            deadlock_errors.len(),
1787            1,
1788            "expected exactly one side to be aborted with 40P01, got {results:?}"
1789        );
1790    }
1791}
1792
1793#[cfg(test)]
1794mod error_message_tests {
1795    use super::*;
1796
1797    /// A server-side failure must render what the server said, not
1798    /// `tokio_postgres::Error`'s generic `"db error"` — the regression that
1799    /// made `IndexWorker(Vector): claim_batch failed: db error` impossible
1800    /// to act on.
1801    #[tokio::test]
1802    #[ignore = "requires a live database"]
1803    async fn a_server_error_renders_the_server_message() {
1804        let pool = PgPool::connect(&std::env::var("PYLON_PGCON_TEST_DSN").unwrap(), 2)
1805            .await
1806            .unwrap();
1807        // Deliberately needs no fixture: a missing relation is a
1808        // server-side error on any database, so this cannot rot when some
1809        // other test's table comes or goes.
1810        let err = pool
1811            .query_typed("SELECT 1 FROM pylon_no_such_table", &[], pool.types())
1812            .await
1813            .unwrap_err();
1814        let rendered = err.to_string();
1815        assert!(
1816            rendered.contains("pylon_no_such_table"),
1817            "expected the server's own message, got {rendered:?}"
1818        );
1819        assert_ne!(rendered, "db error");
1820    }
1821}