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