Skip to main content

oxisql_postgres/
connection.rs

1//! PostgreSQL connection implementing [`oxisql_core::Connection`].
2//!
3//! # Interior mutability
4//!
5//! `tokio_postgres::Client::transaction` requires `&mut self`.  Since the
6//! `Connection` trait only offers `&self`, we wrap the client in an
7//! `Arc<Mutex<Client>>` and acquire an `OwnedMutexGuard` for each transaction
8//! scope.  This matches the same pattern used by `oxisql-embedded`.
9//!
10//! # Transaction semantics
11//!
12//! `PgTransaction` holds an `Option<OwnedMutexGuard<Client>>` for the duration
13//! of the transaction.  Callers should call [`Transaction::commit`] or
14//! [`Transaction::rollback`] to terminate the transaction explicitly.
15//!
16//! ## Automatic rollback on Drop
17//!
18//! If a `PgTransaction` is dropped without an explicit `commit` or `rollback`,
19//! the `Drop` implementation attempts to schedule a `ROLLBACK` command on the
20//! active Tokio runtime.  When no runtime is reachable (e.g., in a sync
21//! context), the guard is simply released and the server will roll back the
22//! open transaction on the next connection reset.
23//!
24//! Explicit termination is still recommended for clarity and to capture errors:
25//! ```rust,no_run
26//! # async fn example(conn: &oxisql_postgres::PgConnection) -> Result<(), oxisql_core::OxiSqlError> {
27//! use oxisql_core::Connection;
28//! let txn = conn.transaction().await?;
29//! // ... operations ...
30//! txn.rollback().await?;  // or .commit()
31//! # Ok(())
32//! # }
33//! ```
34
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::Duration;
38
39use async_trait::async_trait;
40use tokio::sync::{Mutex, OwnedMutexGuard};
41use tokio_postgres::types::ToSql;
42use tokio_postgres::{NoTls, Statement};
43
44use oxisql_core::{
45    ColumnInfo, Connection, ForeignKeyInfo, IndexInfo, OxiSqlError, PreparedStatement, Row,
46    TableInfo, TableType, ToSqlValue, Transaction,
47};
48
49use crate::builder::TlsMode;
50use crate::copy;
51use crate::error::PgError;
52use crate::notify::{self, PgNotification};
53use crate::pipeline::PgPipeline;
54use crate::prepared::{PgPrepared, StmtCache};
55use crate::tls::PgTls;
56use crate::types::{pg_row_to_row, value_to_param, OwnedParam};
57
58// ── PgConnection ─────────────────────────────────────────────────────────────
59
60/// Metadata for a single output column from a prepared statement.
61///
62/// Obtained via [`PgConnection::describe`] — describes column names and
63/// PostgreSQL type names without executing the query.
64#[derive(Debug, Clone)]
65pub struct ColumnDescription {
66    /// The column name as reported by the server.
67    pub name: String,
68    /// The PostgreSQL type name (e.g. `"int4"`, `"text"`, `"uuid"`).
69    pub type_name: String,
70    /// Always `true`: PostgreSQL does not expose nullability in column
71    /// descriptions returned by the extended-query protocol.
72    pub nullable: bool,
73}
74
75/// An asynchronous PostgreSQL connection backed by `tokio-postgres` (Pure Rust,
76/// no `libpq`).
77///
78/// The inner `Client` is wrapped in `Arc<Mutex<_>>` to allow the `Connection`
79/// trait's `&self` transaction method to obtain exclusive mutable access.
80///
81/// `Clone` is cheap: both `Arc`s inside are reference-counted.
82///
83/// # Statement cache
84///
85/// A second `Arc<std::sync::Mutex<HashMap<…>>>` holds compiled
86/// `tokio_postgres::Statement` handles keyed on SQL text.  The
87/// `std::sync::Mutex` (not `tokio::sync::Mutex`) is intentional: we only hold
88/// the lock during purely synchronous HashMap lookups, never across an `await`.
89///
90/// # Reconnection
91///
92/// When a `PgConnection` is created via [`PgConnection::connect`], the original
93/// connection string and TLS mode are stored internally.  Call
94/// [`PgConnection::reconnect`] to obtain a brand-new `PgConnection` built from
95/// the same parameters — useful after a broken-pipe error.  The caller is
96/// responsible for replacing their handle with the returned value.
97#[derive(Clone)]
98pub struct PgConnection {
99    inner: Arc<Mutex<tokio_postgres::Client>>,
100    stmt_cache: StmtCache,
101    /// Original connection string passed to [`PgConnection::connect`].
102    ///
103    /// `None` when created via [`PgConnection::from_client`].
104    reconnect_uri: Option<String>,
105    /// TLS mode used for the initial connection.
106    ///
107    /// Stored alongside `reconnect_uri` so that [`PgConnection::reconnect`]
108    /// can re-establish an encrypted connection without silently downgrading
109    /// to plaintext.
110    reconnect_tls: TlsMode,
111    /// Sender side of the broadcast channel used for LISTEN/NOTIFY.
112    ///
113    /// `None` when the connection was created via [`PgConnection::from_client`]
114    /// (no `tokio_postgres::Connection` object was available to intercept
115    /// notifications).  `Some` when created via [`PgConnection::connect`].
116    notif_tx: Option<tokio::sync::broadcast::Sender<PgNotification>>,
117    /// PostgreSQL server version string reported during the connection
118    /// handshake.
119    ///
120    /// Captured from the `server_version` `ParameterStatus` message via
121    /// `tokio_postgres::Connection::parameter` in [`PgConnection::connect`],
122    /// *before* the connection driver is handed to
123    /// [`notify::spawn_connection_driver`] — `tokio_postgres::Client` itself
124    /// does not retain runtime parameters; only the (about-to-be-spawned)
125    /// `tokio_postgres::Connection` driver does, so this is the only point at
126    /// which it can be read. `None` when the connection was created via
127    /// [`PgConnection::from_client`] (no `Connection` driver was ever
128    /// available to this type) or in the practically-impossible case that the
129    /// server did not report the parameter.
130    server_version: Option<String>,
131}
132
133impl PgConnection {
134    /// Construct a `PgConnection` from an existing `tokio_postgres::Client`.
135    ///
136    /// Useful when the `Client` was obtained outside of OxiSQL (e.g., from a
137    /// custom connection pool or test harness).  A fresh, empty statement cache
138    /// is created automatically.
139    ///
140    /// # Example
141    ///
142    /// ```rust,no_run
143    /// # #[tokio::main]
144    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
145    /// let (client, conn) = tokio_postgres::connect(
146    ///     "host=localhost user=postgres",
147    ///     tokio_postgres::NoTls,
148    /// ).await?;
149    /// tokio::spawn(async move { let _: Result<_, _> = conn.await; });
150    ///
151    /// let pg_conn = oxisql_postgres::PgConnection::from_client(client);
152    /// # Ok(())
153    /// # }
154    /// ```
155    pub fn from_client(client: tokio_postgres::Client) -> Self {
156        Self {
157            inner: Arc::new(Mutex::new(client)),
158            stmt_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
159            reconnect_uri: None,
160            reconnect_tls: TlsMode::Disabled,
161            notif_tx: None,
162            server_version: None,
163        }
164    }
165
166    /// Connect to a PostgreSQL server using the given connection string.
167    ///
168    /// `conn_str` follows the `tokio-postgres` format, e.g.:
169    /// `"host=localhost port=5432 user=postgres password=secret dbname=mydb"`.
170    ///
171    /// # TLS
172    ///
173    /// Pass [`TlsMode::Disabled`] for a plain-text connection (suitable for
174    /// `localhost` or trusted network).  Pass [`TlsMode::Rustls`] with a
175    /// `ClientConfig` built from OxiTLS for encrypted connections.
176    pub async fn connect(conn_str: &str, tls: TlsMode) -> Result<Self, PgError> {
177        // Preserve the TLS mode for reconnect before moving it into the match.
178        let tls_clone = tls.clone();
179        let (client, notif_tx, server_version) = match tls {
180            TlsMode::Disabled => {
181                let (client, connection) = tokio_postgres::connect(conn_str, NoTls).await?;
182                // `Client` does not retain the handshake's `ParameterStatus`
183                // values — only `Connection` does — so `server_version` must
184                // be read here, before `connection` is handed off and spawned
185                // away as the notification-forwarding background driver.
186                let server_version = connection.parameter("server_version").map(str::to_string);
187                let tx = notify::spawn_connection_driver(connection);
188                (client, tx, server_version)
189            }
190            TlsMode::Rustls(cfg) => {
191                let tls_connector = PgTls::new(cfg);
192                let (client, connection) = tokio_postgres::connect(conn_str, tls_connector).await?;
193                let server_version = connection.parameter("server_version").map(str::to_string);
194                let tx = notify::spawn_connection_driver(connection);
195                (client, tx, server_version)
196            }
197        };
198        Ok(Self {
199            inner: Arc::new(Mutex::new(client)),
200            stmt_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
201            reconnect_uri: Some(conn_str.to_string()),
202            reconnect_tls: tls_clone,
203            notif_tx: Some(notif_tx),
204            server_version,
205        })
206    }
207
208    /// Connect to a PostgreSQL server with a maximum wait duration.
209    ///
210    /// Wraps [`PgConnection::connect`] with [`tokio::time::timeout`].  If the
211    /// connection is not established within `timeout`, returns
212    /// [`PgError::Timeout`] rather than hanging indefinitely.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`PgError::Timeout`] if `timeout` elapses before the server
217    /// acknowledges the connection.  All other error conditions are propagated
218    /// unchanged from [`PgConnection::connect`].
219    ///
220    /// # Example
221    ///
222    /// ```rust,no_run
223    /// # #[tokio::main]
224    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
225    /// use std::time::Duration;
226    /// use oxisql_postgres::{PgConnection, TlsMode};
227    ///
228    /// let conn = PgConnection::connect_with_timeout(
229    ///     "host=localhost user=postgres",
230    ///     TlsMode::Disabled,
231    ///     Duration::from_secs(5),
232    /// ).await?;
233    /// # Ok(())
234    /// # }
235    /// ```
236    pub async fn connect_with_timeout(
237        conn_str: &str,
238        tls: TlsMode,
239        timeout: Duration,
240    ) -> Result<Self, PgError> {
241        tokio::time::timeout(timeout, Self::connect(conn_str, tls))
242            .await
243            .map_err(|_| {
244                PgError::Timeout(format!(
245                    "connection timed out after {}ms",
246                    timeout.as_millis()
247                ))
248            })?
249    }
250
251    /// Connect with TLS that skips server certificate verification.
252    ///
253    /// # Security Warning
254    ///
255    /// This method accepts **any** server certificate without validation.  It
256    /// is vulnerable to man-in-the-middle attacks and should only be used in
257    /// development or testing environments.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`PgError::Tls`] if the TLS config cannot be built, or a
262    /// [`PgError::Postgres`] / [`PgError::Timeout`] on connection failure.
263    ///
264    /// # Example
265    ///
266    /// ```rust,no_run
267    /// # #[tokio::main]
268    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
269    /// let conn = oxisql_postgres::PgConnection::connect_skip_verify(
270    ///     "host=localhost user=postgres",
271    /// ).await?;
272    /// # Ok(())
273    /// # }
274    /// ```
275    pub async fn connect_skip_verify(conn_str: &str) -> Result<Self, PgError> {
276        Self::connect(conn_str, TlsMode::skip_verify()?).await
277    }
278
279    /// Connect with TLS using a custom CA certificate in PEM format.
280    ///
281    /// The provided `ca_pem` bytes are parsed as a PEM bundle and added to the
282    /// trust store (in addition to the Mozilla WebPKI root certificates).
283    ///
284    /// # Errors
285    ///
286    /// Returns [`PgError::Tls`] if the PEM cannot be parsed or is empty, or a
287    /// [`PgError::Postgres`] / [`PgError::Timeout`] on connection failure.
288    ///
289    /// # Example
290    ///
291    /// ```rust,no_run
292    /// # #[tokio::main]
293    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
294    /// let ca_pem = std::fs::read("/path/to/ca.crt")?;
295    /// let conn = oxisql_postgres::PgConnection::connect_with_ca(
296    ///     "host=db.example.com user=postgres",
297    ///     ca_pem,
298    /// ).await?;
299    /// # Ok(())
300    /// # }
301    /// ```
302    pub async fn connect_with_ca(conn_str: &str, ca_pem: Vec<u8>) -> Result<Self, PgError> {
303        Self::connect(conn_str, TlsMode::with_ca_pem(ca_pem)?).await
304    }
305
306    /// Return the PostgreSQL server version string reported during the
307    /// connection handshake, if known.
308    ///
309    /// This is the raw `server_version` runtime parameter as reported by the
310    /// server in a `ParameterStatus` message during startup (e.g.
311    /// `"16.4 (Debian 16.4-1.pgdg120+1)"`) — not a parsed/normalized version
312    /// number. It is captured once, in [`PgConnection::connect`], before the
313    /// `tokio_postgres::Connection` driver is spawned away; `tokio-postgres`
314    /// does not expose this parameter on `Client` itself.
315    ///
316    /// Returns `None` when this connection was created via
317    /// [`PgConnection::from_client`] (no `Connection` driver was available to
318    /// read the parameter from) or in the practically-impossible case that
319    /// the server did not report `server_version` at all.
320    ///
321    /// # Example
322    ///
323    /// ```rust,no_run
324    /// # #[tokio::main]
325    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
326    /// use oxisql_postgres::{PgConnection, TlsMode};
327    ///
328    /// let conn = PgConnection::connect("host=localhost user=postgres", TlsMode::Disabled).await?;
329    /// if let Some(version) = conn.server_version() {
330    ///     println!("connected to PostgreSQL {version}");
331    /// }
332    /// # Ok(())
333    /// # }
334    /// ```
335    #[must_use]
336    pub fn server_version(&self) -> Option<&str> {
337        self.server_version.as_deref()
338    }
339}
340
341// ── PgConnection — query analysis helpers ────────────────────────────────────
342
343impl PgConnection {
344    /// Return `true` if the SQL is a read-only query (SELECT, EXPLAIN, SHOW, …).
345    ///
346    /// Uses `oxisql_parse::parse_one` to parse the statement and
347    /// `oxisql_parse::is_read_only` for AST-level classification.  This can
348    /// be used to route read queries to a replica and write queries to a primary.
349    ///
350    /// Returns `false` when the SQL cannot be parsed or when the statement
351    /// modifies data or schema.
352    pub fn is_read_only_query(sql: &str) -> bool {
353        match oxisql_parse::parse_one(sql) {
354            Ok(stmt) => oxisql_parse::is_read_only(&stmt),
355            Err(_) => false,
356        }
357    }
358
359    /// Return the normalized canonical form of a SQL query for use as a cache key.
360    ///
361    /// Uses `oxisql_parse::normalize` to collapse whitespace, strip comments,
362    /// and upper-case keywords.  The result is stable across cosmetically
363    /// different but semantically equivalent SQL strings, making it suitable
364    /// as a prepared-statement cache key.
365    pub fn normalize_query(sql: &str) -> String {
366        oxisql_parse::normalize(sql)
367    }
368}
369
370// ── PgConnection — COPY / NOTIFY / pipeline methods ─────────────────────────
371
372impl PgConnection {
373    /// Bulk-insert rows into `table` using the PostgreSQL `COPY … FROM STDIN`
374    /// protocol (text/TSV format).
375    ///
376    /// Each element of `rows` must contain exactly `columns.len()` string
377    /// values.  Values are automatically escaped for the PostgreSQL text COPY
378    /// format.  Pass `"\\N"` (backslash-N) to represent a SQL `NULL`.
379    ///
380    /// Returns the number of rows inserted as reported by PostgreSQL.
381    ///
382    /// # Errors
383    ///
384    /// Returns [`PgError::Copy`] if `columns` is empty, if a row has the
385    /// wrong number of fields, or if the underlying COPY protocol fails.
386    pub async fn copy_in_text(
387        &self,
388        table: &str,
389        columns: &[&str],
390        rows: impl Iterator<Item = Vec<String>>,
391    ) -> Result<u64, PgError> {
392        copy::copy_in_text(&self.inner, table, columns, rows).await
393    }
394
395    /// Extract rows from `table` using the PostgreSQL `COPY … TO STDOUT`
396    /// protocol (text/TSV format).
397    ///
398    /// Returns a `Vec<Vec<String>>` where each inner vec contains the field
399    /// values for one row, with escape sequences decoded.  The literal
400    /// `"\\N"` in the returned data represents a SQL `NULL`.
401    ///
402    /// # Errors
403    ///
404    /// Returns [`PgError::Copy`] if `columns` is empty or if the underlying
405    /// COPY protocol fails.
406    pub async fn copy_out_text(
407        &self,
408        table: &str,
409        columns: &[&str],
410    ) -> Result<Vec<Vec<String>>, PgError> {
411        copy::copy_out_text(&self.inner, table, columns).await
412    }
413
414    /// Send a `NOTIFY` command to `channel` with an optional `payload`.
415    ///
416    /// The channel name must consist only of ASCII alphanumeric characters
417    /// and underscores.  The payload is automatically single-quote–escaped
418    /// before inclusion in the SQL string.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`PgError::Notify`] if the channel name is invalid or if
423    /// the underlying query fails.
424    pub async fn notify(&self, channel: &str, payload: &str) -> Result<(), PgError> {
425        notify::notify(&self.inner, channel, payload).await
426    }
427
428    /// Register `LISTEN` on `channel` and return a [`notify::NotificationStream`].
429    ///
430    /// Requires that this connection was created via [`PgConnection::connect`].
431    /// Connections obtained via [`PgConnection::from_client`] return
432    /// [`PgError::Notify`] because no `tokio_postgres::Connection` driver is
433    /// available to intercept incoming notifications.
434    ///
435    /// # Errors
436    ///
437    /// Returns [`PgError::Notify`] if the channel name is invalid, if this
438    /// connection was created via `from_client`, or if the `LISTEN` command
439    /// fails.
440    pub async fn listen(&self, channel: &str) -> Result<notify::NotificationStream, PgError> {
441        notify::listen(&self.inner, &self.notif_tx, channel).await
442    }
443
444    /// Create a new [`PgPipeline`] for batched query dispatch.
445    ///
446    /// Queue queries with [`PgPipeline::add_execute`] and
447    /// [`PgPipeline::add_query`], then send all at once with
448    /// [`PgPipeline::finish`].
449    pub fn pipeline(&self) -> PgPipeline {
450        PgPipeline::new(Arc::clone(&self.inner))
451    }
452
453    /// Return a [`PostgresCancelToken`] that can be used to interrupt the
454    /// currently running query on this connection.
455    ///
456    /// The token is `Send + Sync` and may be moved to another task.  Call
457    /// [`PostgresCancelToken::cancel_query`] with the appropriate TLS
458    /// connector to send a cancel request to the server.
459    ///
460    /// # Note
461    ///
462    /// Because `PgConnection` wraps the client in an `Arc<Mutex<_>>`,
463    /// acquiring the cancel token requires a momentary lock on the inner
464    /// mutex.  The lock is held only for the duration of the synchronous
465    /// `cancel_token()` call on the underlying `tokio_postgres::Client`,
466    /// never across any `await`.
467    ///
468    /// # Example
469    ///
470    /// ```rust,no_run
471    /// # #[tokio::main]
472    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
473    /// use oxisql_postgres::{PgConnection, TlsMode};
474    ///
475    /// let conn = PgConnection::connect("host=localhost user=postgres", TlsMode::Disabled).await?;
476    /// let token = conn.cancel_token().await;
477    /// // Spawn a task that cancels after a short delay:
478    /// tokio::spawn(async move {
479    ///     tokio::time::sleep(std::time::Duration::from_millis(200)).await;
480    ///     let _ = token.cancel_query(tokio_postgres::NoTls).await;
481    /// });
482    /// # Ok(())
483    /// # }
484    /// ```
485    pub async fn cancel_token(&self) -> PostgresCancelToken {
486        let client = self.inner.lock().await;
487        PostgresCancelToken {
488            inner: client.cancel_token(),
489        }
490    }
491
492    /// Establish a fresh connection using the same URI and TLS mode as this
493    /// connection.
494    ///
495    /// Returns a brand-new [`PgConnection`] with an empty statement cache.
496    /// The caller should replace their handle with the returned value after a
497    /// broken-pipe or connection-lost error.
498    ///
499    /// # Errors
500    ///
501    /// Returns [`PgError::Notify`] (repurposed as a "no URI" sentinel) when
502    /// this connection was created via [`PgConnection::from_client`] and no
503    /// original connection string was recorded.
504    ///
505    /// Returns [`PgError::Postgres`] on connection failure.
506    pub async fn reconnect(&self) -> Result<PgConnection, PgError> {
507        match &self.reconnect_uri {
508            None => Err(PgError::Notify(
509                "cannot reconnect: connection was created via from_client (no URI stored)"
510                    .to_string(),
511            )),
512            Some(uri) => PgConnection::connect(uri, self.reconnect_tls.clone()).await,
513        }
514    }
515
516    /// Execute a query using the binary protocol for better throughput.
517    ///
518    /// Uses `tokio-postgres`'s `query_typed`, which sends explicit Postgres type
519    /// annotations alongside each parameter value.  The server then returns
520    /// result columns in **binary format** (format code 1), bypassing the
521    /// text-serialization step used by [`Connection::query`].  This reduces
522    /// per-row encoding/decoding overhead on both client and server, improving
523    /// throughput for large result sets.
524    ///
525    /// Parameter types are inferred from the [`ToSqlValue`] variants:
526    ///
527    /// | OxiSQL variant | Declared Postgres type |
528    /// |---|---|
529    /// | `Bool` | `BOOL` |
530    /// | `I64` | `INT8` |
531    /// | `F64` | `FLOAT8` |
532    /// | `Text` | `TEXT` |
533    /// | `Blob` | `BYTEA` |
534    /// | `Null` / extended types | `UNKNOWN` (server infers from context) |
535    ///
536    /// **Note**: parameters are declared as INT8/FLOAT8/TEXT rather than
537    /// narrower types (INT4, FLOAT4, etc.).  If a column has a narrower type
538    /// and no implicit cast is available, prefer [`Connection::query`] which
539    /// lets the server apply type inference after `prepare`.
540    ///
541    /// # Errors
542    ///
543    /// Returns [`PgError::Postgres`] on connection or server-side errors.
544    /// Returns [`PgError::TypeConversion`] if a result column cannot be decoded.
545    ///
546    /// # Example
547    ///
548    /// ```rust,no_run
549    /// # #[tokio::main]
550    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
551    /// use oxisql_postgres::{PgConnection, TlsMode};
552    ///
553    /// let conn = PgConnection::connect("host=localhost user=postgres", TlsMode::Disabled).await?;
554    /// let rows = conn.query_binary("SELECT id, name FROM users WHERE id = $1", &[&42_i64]).await?;
555    /// # Ok(())
556    /// # }
557    /// ```
558    pub async fn query_binary(
559        &self,
560        sql: &str,
561        params: &[&dyn oxisql_core::ToSqlValue],
562    ) -> Result<Vec<Row>, PgError> {
563        use tokio_postgres::types::Type;
564
565        let client = self.inner.lock().await;
566        let owned = build_params(params);
567
568        // Build (ref, Type) pairs.  Type::UNKNOWN lets the server use context
569        // to resolve the type when we cannot determine it exactly.
570        let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned
571            .iter()
572            .map(|p| {
573                let ty = match p {
574                    OwnedParam::Bool(_) => Type::BOOL,
575                    OwnedParam::I64(_) => Type::INT8,
576                    OwnedParam::F64(_) => Type::FLOAT8,
577                    OwnedParam::Text(_) => Type::TEXT,
578                    OwnedParam::Blob(_) => Type::BYTEA,
579                    // NULL and extended types (sent as text): let the server infer.
580                    OwnedParam::Null => Type::UNKNOWN,
581                };
582                let dyn_ref: &(dyn ToSql + Sync) = p;
583                (dyn_ref, ty)
584            })
585            .collect();
586
587        let pg_rows = client
588            .query_typed(sql, typed_params.as_slice())
589            .await
590            .map_err(PgError::Postgres)?;
591
592        pg_rows
593            .into_iter()
594            .map(crate::types::pg_row_to_row)
595            .collect()
596    }
597
598    /// Return column metadata for the given SQL without executing it.
599    ///
600    /// Uses `client.prepare(sql)` to obtain the server-reported column
601    /// descriptions.  Each [`ColumnDescription`] carries the column name and
602    /// PostgreSQL type name (e.g. `"int4"`, `"text"`, `"uuid"`).
603    ///
604    /// `nullable` is always `true`; the PostgreSQL extended-query protocol
605    /// does not carry nullability information in statement descriptions.
606    ///
607    /// # Errors
608    ///
609    /// Returns [`PgError::Postgres`] if the server rejects the SQL (syntax
610    /// error, unknown table, etc.).
611    pub async fn describe(&self, sql: &str) -> Result<Vec<ColumnDescription>, PgError> {
612        let client = self.inner.lock().await;
613        let stmt = client.prepare(sql).await.map_err(PgError::Postgres)?;
614        Ok(stmt
615            .columns()
616            .iter()
617            .map(|col| ColumnDescription {
618                name: col.name().to_string(),
619                type_name: col.type_().name().to_string(),
620                nullable: true,
621            })
622            .collect())
623    }
624}
625
626// ── helper ────────────────────────────────────────────────────────────────────
627
628/// Convert `oxisql_core` params to `OwnedParam` values and return both vecs so
629/// the caller can build a `&[&(dyn ToSql + Sync)]` slice from the refs.
630fn build_params(params: &[&dyn ToSqlValue]) -> Vec<OwnedParam> {
631    params
632        .iter()
633        .map(|p| value_to_param(&p.to_value()))
634        .collect()
635}
636
637fn owned_refs(owned: &[OwnedParam]) -> Vec<&(dyn ToSql + Sync)> {
638    owned.iter().map(|p| p as &(dyn ToSql + Sync)).collect()
639}
640
641// ── Connection impl ───────────────────────────────────────────────────────────
642
643#[async_trait]
644impl Connection for PgConnection {
645    async fn execute(&self, sql: &str, params: &[&dyn ToSqlValue]) -> Result<u64, OxiSqlError> {
646        let client = self.inner.lock().await;
647        let owned = build_params(params);
648        let refs = owned_refs(&owned);
649        client
650            .execute(sql, refs.as_slice())
651            .await
652            .map_err(|e| OxiSqlError::Execution(e.to_string()))
653    }
654
655    async fn query(&self, sql: &str, params: &[&dyn ToSqlValue]) -> Result<Vec<Row>, OxiSqlError> {
656        let client = self.inner.lock().await;
657        let owned = build_params(params);
658        let refs = owned_refs(&owned);
659        let pg_rows = client
660            .query(sql, refs.as_slice())
661            .await
662            .map_err(|e| OxiSqlError::Execution(e.to_string()))?;
663        pg_rows
664            .into_iter()
665            .map(|r| pg_row_to_row(r).map_err(OxiSqlError::from))
666            .collect()
667    }
668
669    async fn transaction(&self) -> Result<Box<dyn Transaction + '_>, OxiSqlError> {
670        // Acquire an owned guard so `PgTransaction` can be `'static`.
671        let guard = Arc::clone(&self.inner).lock_owned().await;
672        // BEGIN is issued via batch_execute so we have explicit transaction control.
673        guard
674            .batch_execute("BEGIN")
675            .await
676            .map_err(|e| OxiSqlError::Execution(e.to_string()))?;
677        Ok(Box::new(PgTransaction {
678            guard: Some(guard),
679            done: false,
680        }))
681    }
682
683    async fn execute_batch(&self, sql: &str) -> Result<u64, OxiSqlError> {
684        let client = self.inner.lock().await;
685        client
686            .batch_execute(sql)
687            .await
688            .map_err(|e| OxiSqlError::Execution(e.to_string()))?;
689        // batch_execute does not return row counts
690        Ok(0)
691    }
692
693    async fn ping(&self) -> Result<(), OxiSqlError> {
694        let client = self.inner.lock().await;
695        client
696            .simple_query("")
697            .await
698            .map_err(|e| OxiSqlError::Other(e.to_string()))?;
699        Ok(())
700    }
701
702    async fn tables(&self) -> Result<Vec<TableInfo>, OxiSqlError> {
703        let rows = self
704            .query(
705                "SELECT table_name, table_schema, table_type \
706                 FROM information_schema.tables \
707                 WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
708                 ORDER BY table_schema, table_name",
709                &[],
710            )
711            .await?;
712        rows.into_iter()
713            .map(|row| {
714                let name = row
715                    .try_get::<String>("table_name")
716                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
717                let schema: Option<String> = row.try_get::<String>("table_schema").ok();
718                let type_str: String = row
719                    .try_get::<String>("table_type")
720                    .unwrap_or_else(|_| "TABLE".to_string());
721                Ok(TableInfo {
722                    name,
723                    schema,
724                    table_type: TableType::from(type_str.as_str()),
725                })
726            })
727            .collect()
728    }
729
730    async fn columns(&self, table: &str) -> Result<Vec<ColumnInfo>, OxiSqlError> {
731        let rows = self
732            .query(
733                "SELECT column_name, ordinal_position, data_type, is_nullable, column_default, \
734                        character_maximum_length, numeric_precision, numeric_scale \
735                 FROM information_schema.columns \
736                 WHERE table_name = $1 \
737                 ORDER BY ordinal_position",
738                &[&table],
739            )
740            .await?;
741        rows.into_iter()
742            .map(|row| {
743                let name = row
744                    .try_get::<String>("column_name")
745                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
746                let ordinal: i64 = row
747                    .try_get::<i64>("ordinal_position")
748                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
749                let data_type = row
750                    .try_get::<String>("data_type")
751                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
752                let nullable_str = row
753                    .try_get::<String>("is_nullable")
754                    .unwrap_or_else(|_| "YES".to_string());
755                let default = row.try_get::<String>("column_default").ok();
756                let max_len: Option<i64> = row.try_get::<i64>("character_maximum_length").ok();
757                let num_prec: Option<i64> = row.try_get::<i64>("numeric_precision").ok();
758                let num_scale: Option<i64> = row.try_get::<i64>("numeric_scale").ok();
759                Ok(ColumnInfo {
760                    name,
761                    ordinal_position: u32::try_from(ordinal).unwrap_or(0),
762                    data_type,
763                    nullable: !nullable_str.eq_ignore_ascii_case("NO"),
764                    default,
765                    max_length: max_len.and_then(|n| u64::try_from(n).ok()),
766                    numeric_precision: num_prec.and_then(|n| u32::try_from(n).ok()),
767                    numeric_scale: num_scale.and_then(|n| u32::try_from(n).ok()),
768                })
769            })
770            .collect()
771    }
772
773    async fn indexes(&self, table: &str) -> Result<Vec<IndexInfo>, OxiSqlError> {
774        let rows = self
775            .query(
776                "SELECT indexname, indexdef \
777                 FROM pg_indexes \
778                 WHERE tablename = $1 \
779                 ORDER BY indexname",
780                &[&table],
781            )
782            .await?;
783        rows.into_iter()
784            .map(|row| {
785                let name = row
786                    .try_get::<String>("indexname")
787                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
788                let def = row.try_get::<String>("indexdef").unwrap_or_default();
789                let unique = def.contains("UNIQUE");
790                let primary = name.ends_with("_pkey");
791                // Extract columns from the index definition: content between
792                // the last '(' and ')'.
793                let columns = if let (Some(start), Some(end)) = (def.rfind('('), def.rfind(')')) {
794                    def[start + 1..end]
795                        .split(',')
796                        .map(|s| s.trim().to_string())
797                        .collect()
798                } else {
799                    Vec::new()
800                };
801                Ok(IndexInfo {
802                    name,
803                    columns,
804                    unique,
805                    primary,
806                })
807            })
808            .collect()
809    }
810
811    async fn foreign_keys(&self, table: &str) -> Result<Vec<ForeignKeyInfo>, OxiSqlError> {
812        let rows = self
813            .query(
814                "SELECT tc.constraint_name, kcu.column_name, \
815                        ccu.table_name AS foreign_table, \
816                        ccu.column_name AS foreign_column \
817                 FROM information_schema.table_constraints tc \
818                 JOIN information_schema.key_column_usage kcu \
819                      ON tc.constraint_name = kcu.constraint_name \
820                      AND tc.table_schema = kcu.table_schema \
821                 JOIN information_schema.constraint_column_usage ccu \
822                      ON ccu.constraint_name = tc.constraint_name \
823                      AND ccu.table_schema = tc.table_schema \
824                 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1",
825                &[&table],
826            )
827            .await?;
828        rows.into_iter()
829            .map(|row| {
830                let constraint_name = row
831                    .try_get::<String>("constraint_name")
832                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
833                let column = row
834                    .try_get::<String>("column_name")
835                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
836                let foreign_table = row
837                    .try_get::<String>("foreign_table")
838                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
839                let foreign_column = row
840                    .try_get::<String>("foreign_column")
841                    .map_err(|e| OxiSqlError::Other(e.to_string()))?;
842                Ok(ForeignKeyInfo {
843                    constraint_name,
844                    column,
845                    foreign_table,
846                    foreign_column,
847                    ..Default::default()
848                })
849            })
850            .collect()
851    }
852
853    /// Stream query results row-by-row from PostgreSQL.
854    ///
855    /// Acquires a lock on the client, runs `client.query()` (which fetches all
856    /// rows in a single round-trip), then yields each converted row individually.
857    /// This is equivalent to the default trait implementation but avoids going
858    /// through `async_trait`'s boxing.
859    fn query_stream<'a>(
860        &'a self,
861        sql: &'a str,
862        params: &'a [&'a dyn oxisql_core::ToSqlValue],
863    ) -> std::pin::Pin<
864        Box<
865            dyn futures::Stream<Item = Result<oxisql_core::Row, oxisql_core::OxiSqlError>>
866                + Send
867                + 'a,
868        >,
869    > {
870        use futures::StreamExt;
871        let fut = self.query(sql, params);
872        Box::pin(futures::stream::once(fut).flat_map(|result| match result {
873            Ok(rows) => futures::stream::iter(rows.into_iter().map(Ok)).left_stream(),
874            Err(e) => futures::stream::once(async move { Err(e) }).right_stream(),
875        }))
876    }
877
878    /// Compile a SQL statement for repeated execution.
879    ///
880    /// The compiled [`Statement`] is cached by SQL text: subsequent calls with
881    /// the same SQL return a clone of the cached handle without any server
882    /// round-trip.
883    async fn prepare(&self, sql: &str) -> Result<Box<dyn PreparedStatement + '_>, OxiSqlError> {
884        // --- cache look-up (synchronous lock, never held across await) ---
885        {
886            let cache = self
887                .stmt_cache
888                .lock()
889                .map_err(|_| OxiSqlError::Other("statement cache lock poisoned".into()))?;
890            if let Some(stmt) = cache.get(sql) {
891                return Ok(Box::new(PgPrepared {
892                    client: Arc::clone(&self.inner),
893                    stmt: stmt.clone(),
894                    sql_text: sql.to_string(),
895                }));
896            }
897        } // lock released before any await
898
899        // --- cache miss: prepare on the server ---
900        let stmt: Statement = {
901            let client = self.inner.lock().await;
902            client
903                .prepare(sql)
904                .await
905                .map_err(|e| OxiSqlError::Execution(e.to_string()))?
906        }; // client lock released
907
908        // --- insert into cache ---
909        {
910            let mut cache = self
911                .stmt_cache
912                .lock()
913                .map_err(|_| OxiSqlError::Other("statement cache lock poisoned".into()))?;
914            cache.insert(sql.to_string(), stmt.clone());
915        }
916
917        Ok(Box::new(PgPrepared {
918            client: Arc::clone(&self.inner),
919            stmt,
920            sql_text: sql.to_string(),
921        }))
922    }
923}
924
925// ── PgTransaction ─────────────────────────────────────────────────────────────
926
927/// A PostgreSQL transaction that holds exclusive access to the client for the
928/// duration of the transaction scope.
929///
930/// # Preferred cleanup
931///
932/// Call [`Transaction::commit`] or [`Transaction::rollback`] to end the
933/// transaction explicitly.  If neither is called before this value is dropped,
934/// the `Drop` implementation attempts to schedule a `ROLLBACK` on the current
935/// Tokio runtime (best-effort).  If no runtime is reachable the connection is
936/// simply released; the server will clean up via connection reset.
937pub struct PgTransaction {
938    /// `Some` until `commit`, `rollback`, or `Drop` consumes the guard.
939    guard: Option<OwnedMutexGuard<tokio_postgres::Client>>,
940    /// `true` once `commit` or `rollback` has been called.
941    done: bool,
942}
943
944impl Drop for PgTransaction {
945    fn drop(&mut self) {
946        if let Some(guard) = self.guard.take() {
947            if !self.done {
948                // Best-effort: schedule ROLLBACK on the active Tokio runtime.
949                // If no runtime is reachable, the guard drops here, releasing
950                // the mutex so the connection becomes usable again.  The server
951                // will roll back automatically on connection reset.
952                match tokio::runtime::Handle::try_current() {
953                    Ok(handle) => {
954                        handle.spawn(async move {
955                            let _ = guard.batch_execute("ROLLBACK").await;
956                        });
957                    }
958                    Err(_) => {
959                        // Not in a Tokio context — guard is dropped silently.
960                        drop(guard);
961                    }
962                }
963            }
964        }
965    }
966}
967
968/// Helper: borrow the active guard or return a "transaction consumed" error.
969fn guard_ref(
970    guard: &mut Option<OwnedMutexGuard<tokio_postgres::Client>>,
971) -> Result<&mut OwnedMutexGuard<tokio_postgres::Client>, OxiSqlError> {
972    guard.as_mut().ok_or_else(|| {
973        OxiSqlError::Execution("transaction already consumed (committed or rolled back)".into())
974    })
975}
976
977#[async_trait]
978impl Transaction for PgTransaction {
979    async fn execute(&mut self, sql: &str, params: &[&dyn ToSqlValue]) -> Result<u64, OxiSqlError> {
980        let owned = build_params(params);
981        let refs = owned_refs(&owned);
982        guard_ref(&mut self.guard)?
983            .execute(sql, refs.as_slice())
984            .await
985            .map_err(|e| OxiSqlError::Execution(e.to_string()))
986    }
987
988    async fn query(
989        &mut self,
990        sql: &str,
991        params: &[&dyn ToSqlValue],
992    ) -> Result<Vec<Row>, OxiSqlError> {
993        let owned = build_params(params);
994        let refs = owned_refs(&owned);
995        let pg_rows = guard_ref(&mut self.guard)?
996            .query(sql, refs.as_slice())
997            .await
998            .map_err(|e| OxiSqlError::Execution(e.to_string()))?;
999        pg_rows
1000            .into_iter()
1001            .map(|r| pg_row_to_row(r).map_err(OxiSqlError::from))
1002            .collect()
1003    }
1004
1005    async fn commit(mut self: Box<Self>) -> Result<(), OxiSqlError> {
1006        self.done = true;
1007        let guard = self
1008            .guard
1009            .take()
1010            .ok_or_else(|| OxiSqlError::Execution("transaction already consumed".into()))?;
1011        guard
1012            .batch_execute("COMMIT")
1013            .await
1014            .map_err(|e| OxiSqlError::Execution(e.to_string()))
1015    }
1016
1017    async fn rollback(mut self: Box<Self>) -> Result<(), OxiSqlError> {
1018        self.done = true;
1019        let guard = self
1020            .guard
1021            .take()
1022            .ok_or_else(|| OxiSqlError::Execution("transaction already consumed".into()))?;
1023        guard
1024            .batch_execute("ROLLBACK")
1025            .await
1026            .map_err(|e| OxiSqlError::Execution(e.to_string()))
1027    }
1028
1029    async fn savepoint(&mut self, name: &str) -> Result<(), OxiSqlError> {
1030        validate_savepoint_name(name)?;
1031        guard_ref(&mut self.guard)?
1032            .batch_execute(&format!("SAVEPOINT {name}"))
1033            .await
1034            .map_err(|e| OxiSqlError::Execution(e.to_string()))
1035    }
1036
1037    async fn release_savepoint(&mut self, name: &str) -> Result<(), OxiSqlError> {
1038        validate_savepoint_name(name)?;
1039        guard_ref(&mut self.guard)?
1040            .batch_execute(&format!("RELEASE SAVEPOINT {name}"))
1041            .await
1042            .map_err(|e| OxiSqlError::Execution(e.to_string()))
1043    }
1044
1045    async fn rollback_to_savepoint(&mut self, name: &str) -> Result<(), OxiSqlError> {
1046        validate_savepoint_name(name)?;
1047        guard_ref(&mut self.guard)?
1048            .batch_execute(&format!("ROLLBACK TO SAVEPOINT {name}"))
1049            .await
1050            .map_err(|e| OxiSqlError::Execution(e.to_string()))
1051    }
1052}
1053
1054// ── PgTransaction — inherent savepoint/timeout API (returns PgError) ─────────
1055
1056impl PgTransaction {
1057    /// Create a savepoint with the given name.
1058    ///
1059    /// The name must consist only of ASCII alphanumeric characters and
1060    /// underscores (`[a-zA-Z0-9_]`).  This restriction prevents SQL injection
1061    /// because savepoint names cannot be parameterized in the PostgreSQL
1062    /// wire protocol.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns [`PgError::Notify`] if the name is empty or contains unsafe
1067    /// characters.  Returns [`PgError::Postgres`] on wire-level errors.
1068    pub async fn savepoint_pg(&mut self, name: &str) -> Result<(), PgError> {
1069        if !is_valid_savepoint_name(name) {
1070            return Err(PgError::Notify(format!(
1071                "invalid savepoint name {name:?}: only alphanumeric characters and underscores allowed"
1072            )));
1073        }
1074        let guard = self
1075            .guard
1076            .as_ref()
1077            .ok_or_else(|| PgError::Notify("transaction already consumed".into()))?;
1078        guard
1079            .batch_execute(&format!("SAVEPOINT {name}"))
1080            .await
1081            .map_err(PgError::Postgres)
1082    }
1083
1084    /// Roll back to a savepoint without releasing it.
1085    ///
1086    /// The savepoint remains active after this call; you can roll back to it
1087    /// again or release it with [`PgTransaction::release_savepoint_pg`].
1088    ///
1089    /// # Errors
1090    ///
1091    /// Returns [`PgError::Notify`] if the name is invalid or the transaction
1092    /// has already been consumed.  Returns [`PgError::Postgres`] on server
1093    /// errors (e.g. the named savepoint does not exist).
1094    pub async fn rollback_to_savepoint_pg(&mut self, name: &str) -> Result<(), PgError> {
1095        if !is_valid_savepoint_name(name) {
1096            return Err(PgError::Notify(format!(
1097                "invalid savepoint name {name:?}: only alphanumeric characters and underscores allowed"
1098            )));
1099        }
1100        let guard = self
1101            .guard
1102            .as_ref()
1103            .ok_or_else(|| PgError::Notify("transaction already consumed".into()))?;
1104        guard
1105            .batch_execute(&format!("ROLLBACK TO SAVEPOINT {name}"))
1106            .await
1107            .map_err(PgError::Postgres)
1108    }
1109
1110    /// Release (remove) a savepoint.
1111    ///
1112    /// All savepoints created after the named savepoint are also removed.
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns [`PgError::Notify`] if the name is invalid or the transaction
1117    /// has already been consumed.  Returns [`PgError::Postgres`] on server
1118    /// errors (e.g. the named savepoint does not exist).
1119    pub async fn release_savepoint_pg(&mut self, name: &str) -> Result<(), PgError> {
1120        if !is_valid_savepoint_name(name) {
1121            return Err(PgError::Notify(format!(
1122                "invalid savepoint name {name:?}: only alphanumeric characters and underscores allowed"
1123            )));
1124        }
1125        let guard = self
1126            .guard
1127            .as_ref()
1128            .ok_or_else(|| PgError::Notify("transaction already consumed".into()))?;
1129        guard
1130            .batch_execute(&format!("RELEASE SAVEPOINT {name}"))
1131            .await
1132            .map_err(PgError::Postgres)
1133    }
1134}
1135
1136/// Fast path: check if a savepoint name contains only safe SQL identifier chars.
1137///
1138/// Accepts `[a-zA-Z0-9_]` and rejects empty strings.
1139fn is_valid_savepoint_name(name: &str) -> bool {
1140    !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1141}
1142
1143/// Validate that a savepoint name contains only safe SQL identifier characters.
1144///
1145/// Accepts `[a-zA-Z0-9_]` — no spaces, no quotes, no SQL injection vectors.
1146fn validate_savepoint_name(name: &str) -> Result<(), OxiSqlError> {
1147    if name.is_empty() {
1148        return Err(OxiSqlError::Other(
1149            "savepoint name must not be empty".into(),
1150        ));
1151    }
1152    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1153        return Err(OxiSqlError::Other(format!(
1154            "invalid savepoint name {name:?}: only alphanumeric characters and underscores allowed"
1155        )));
1156    }
1157    Ok(())
1158}
1159
1160// ── PostgresCancelToken ───────────────────────────────────────────────────────
1161
1162/// A token that can be used to cancel a running query on a [`PgConnection`].
1163///
1164/// Obtained via [`PgConnection::cancel_token`].  Cancellation is a
1165/// **best-effort** signal: the server may have already finished processing the
1166/// query by the time the cancel request arrives, and the cancel request itself
1167/// is sent over a separate TCP connection with no acknowledgement.
1168///
1169/// This type is `Send + Sync` and can be passed to other tasks.
1170pub struct PostgresCancelToken {
1171    inner: tokio_postgres::CancelToken,
1172}
1173
1174impl PostgresCancelToken {
1175    /// Send a cancel request to the PostgreSQL server.
1176    ///
1177    /// Uses `tls` to open the temporary cancel connection.  Pass
1178    /// [`tokio_postgres::NoTls`] for unencrypted connections, or the same TLS
1179    /// connector used when establishing the main connection for encrypted ones.
1180    ///
1181    /// Returns `Ok(())` when the cancel request was sent (not when it was
1182    /// acted upon — there is no acknowledgement in the PostgreSQL wire
1183    /// protocol for cancel requests).
1184    ///
1185    /// # Errors
1186    ///
1187    /// Returns [`PgError::Connection`] if the cancel connection could not be
1188    /// established or the cancel packet could not be sent.
1189    ///
1190    /// # Example
1191    ///
1192    /// ```rust,no_run
1193    /// # #[tokio::main]
1194    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1195    /// use oxisql_postgres::{PgConnection, TlsMode};
1196    ///
1197    /// let conn = PgConnection::connect("host=localhost user=postgres", TlsMode::Disabled).await?;
1198    /// let token = conn.cancel_token().await;
1199    /// // In another task:
1200    /// token.cancel_query(tokio_postgres::NoTls).await?;
1201    /// # Ok(())
1202    /// # }
1203    /// ```
1204    pub async fn cancel_query<T>(&self, tls: T) -> Result<(), PgError>
1205    where
1206        T: tokio_postgres::tls::MakeTlsConnect<tokio_postgres::Socket> + Clone,
1207    {
1208        self.inner
1209            .cancel_query(tls)
1210            .await
1211            .map_err(|e| PgError::Connection(e.to_string()))
1212    }
1213}
1214
1215// ── PgConnParts / parse_pg_conn_str ───────────────────────────────────────────
1216
1217/// Parsed fields extracted from a PostgreSQL connection string.
1218///
1219/// Obtained via [`parse_pg_conn_str`].
1220#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct PgConnParts {
1222    /// The host name or IP address (defaults to `"localhost"`).
1223    pub host: String,
1224    /// The server port (defaults to `5432`).
1225    pub port: u16,
1226    /// The target database name, if present.
1227    pub dbname: Option<String>,
1228    /// The login user name, if present.
1229    pub user: Option<String>,
1230}
1231
1232/// Parse a PostgreSQL connection string into its constituent parts.
1233///
1234/// Accepts two common formats:
1235///
1236/// **Key-value format** (the `libpq`/`tokio-postgres` native format):
1237/// ```text
1238/// host=myhost port=5433 dbname=testdb user=admin password=secret
1239/// ```
1240/// Unknown keys are silently ignored.  Missing `host` defaults to
1241/// `"localhost"`; missing `port` defaults to `5432`.
1242///
1243/// **URI format**:
1244/// ```text
1245/// postgres://user:password@host:port/dbname
1246/// postgresql://host/dbname
1247/// ```
1248/// The scheme must be `postgres://` or `postgresql://`.
1249///
1250/// # Errors
1251///
1252/// Returns [`OxiSqlError::Other`] when:
1253/// - A non-empty `port` value cannot be parsed as a `u16`.
1254/// - The URI scheme is unrecognised (neither `postgres://` nor `postgresql://`).
1255///
1256/// # Examples
1257///
1258/// ```rust
1259/// use oxisql_postgres::parse_pg_conn_str;
1260///
1261/// let p = parse_pg_conn_str("host=myhost port=5433 dbname=testdb user=admin").unwrap();
1262/// assert_eq!(p.host, "myhost");
1263/// assert_eq!(p.port, 5433);
1264/// assert_eq!(p.dbname, Some("testdb".to_string()));
1265/// assert_eq!(p.user, Some("admin".to_string()));
1266/// ```
1267pub fn parse_pg_conn_str(conn_str: &str) -> Result<PgConnParts, OxiSqlError> {
1268    let trimmed = conn_str.trim();
1269
1270    if trimmed.starts_with("postgres://") || trimmed.starts_with("postgresql://") {
1271        parse_pg_uri(trimmed)
1272    } else {
1273        parse_pg_kv(trimmed)
1274    }
1275}
1276
1277/// Parse the `key=value …` form of a Postgres connection string.
1278fn parse_pg_kv(conn_str: &str) -> Result<PgConnParts, OxiSqlError> {
1279    let mut parts = PgConnParts {
1280        host: "localhost".to_string(),
1281        port: 5432,
1282        dbname: None,
1283        user: None,
1284    };
1285    for token in conn_str.split_whitespace() {
1286        if let Some((k, v)) = token.split_once('=') {
1287            match k {
1288                "host" | "hostaddr" => parts.host = v.to_string(),
1289                "port" if !v.is_empty() => {
1290                    parts.port = v.parse::<u16>().map_err(|_| {
1291                        OxiSqlError::Other(format!(
1292                            "invalid port value in connection string: {v:?}"
1293                        ))
1294                    })?;
1295                }
1296                "dbname" => parts.dbname = Some(v.to_string()),
1297                "user" => parts.user = Some(v.to_string()),
1298                _ => {}
1299            }
1300        }
1301    }
1302    Ok(parts)
1303}
1304
1305/// Parse the `postgres://[user[:pass]@]host[:port][/dbname]` URI form.
1306fn parse_pg_uri(uri: &str) -> Result<PgConnParts, OxiSqlError> {
1307    // Strip scheme.
1308    let rest = uri
1309        .strip_prefix("postgresql://")
1310        .or_else(|| uri.strip_prefix("postgres://"))
1311        .ok_or_else(|| {
1312            OxiSqlError::Other(format!("unrecognised connection string scheme: {uri:?}"))
1313        })?;
1314
1315    // rest = [user[:pass]@]host[:port][/dbname][?options]
1316    // Split off query string first.
1317    let (rest, _query) = rest.split_once('?').unwrap_or((rest, ""));
1318
1319    // Split user-info from host-info at the last '@'.
1320    let (user_info, host_part) = if let Some(at_pos) = rest.rfind('@') {
1321        (Some(&rest[..at_pos]), &rest[at_pos + 1..])
1322    } else {
1323        (None, rest)
1324    };
1325
1326    // Extract user from user_info (ignore password).
1327    let user = user_info.and_then(|ui| {
1328        let cred = ui.split_once(':').map(|(u, _)| u).unwrap_or(ui);
1329        if cred.is_empty() {
1330            None
1331        } else {
1332            Some(cred.to_string())
1333        }
1334    });
1335
1336    // Split path (dbname) from host[:port].
1337    let (authority, dbname) = if let Some(slash_pos) = host_part.find('/') {
1338        let db = &host_part[slash_pos + 1..];
1339        (
1340            &host_part[..slash_pos],
1341            if db.is_empty() {
1342                None
1343            } else {
1344                Some(db.to_string())
1345            },
1346        )
1347    } else {
1348        (host_part, None)
1349    };
1350
1351    // Split host and port.
1352    let (host, port) = if authority.starts_with('[') {
1353        // IPv6 literal: [::1]:5432
1354        if let Some(bracket_end) = authority.find(']') {
1355            let h = &authority[1..bracket_end];
1356            let port_str = authority[bracket_end + 1..].trim_start_matches(':');
1357            let p = if port_str.is_empty() {
1358                5432
1359            } else {
1360                port_str
1361                    .parse::<u16>()
1362                    .map_err(|_| OxiSqlError::Other(format!("invalid port in URI: {port_str:?}")))?
1363            };
1364            (h.to_string(), p)
1365        } else {
1366            (authority.to_string(), 5432)
1367        }
1368    } else if let Some(colon_pos) = authority.rfind(':') {
1369        let h = &authority[..colon_pos];
1370        let port_str = &authority[colon_pos + 1..];
1371        let p = if port_str.is_empty() {
1372            5432
1373        } else {
1374            port_str
1375                .parse::<u16>()
1376                .map_err(|_| OxiSqlError::Other(format!("invalid port in URI: {port_str:?}")))?
1377        };
1378        (h.to_string(), p)
1379    } else {
1380        (authority.to_string(), 5432)
1381    };
1382
1383    let host = if host.is_empty() {
1384        "localhost".to_string()
1385    } else {
1386        host
1387    };
1388
1389    Ok(PgConnParts {
1390        host,
1391        port,
1392        dbname,
1393        user,
1394    })
1395}
1396
1397// ── Cancel token tests ────────────────────────────────────────────────────────
1398
1399#[cfg(test)]
1400mod cancel_token_tests {
1401    use crate::PostgresCancelToken;
1402
1403    fn _assert_send_sync<T: Send + Sync>() {}
1404
1405    #[test]
1406    fn cancel_token_is_send_sync() {
1407        // PostgresCancelToken must be Send + Sync for async cancellation across tasks.
1408        _assert_send_sync::<PostgresCancelToken>();
1409    }
1410}