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