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();
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();
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}