Skip to main content

qail_pg/driver/
core.rs

1//! PgDriver — high-level async PostgreSQL driver combining the wire-protocol
2//! encoder with connection management (connect, fetch, execute, copy, pipeline, txn, RLS).
3
4use super::auth_types::*;
5use super::builder::PgDriverBuilder;
6use super::connection::PgConnection;
7use super::pool;
8use super::rls::RlsContext;
9use super::types::*;
10
11/// Combines the pure encoder (Layer 2) with async I/O (Layer 3).
12pub struct PgDriver {
13    pub(super) connection: PgConnection,
14    /// Current RLS context, if set. Used for multi-tenant data isolation.
15    pub(super) rls_context: Option<RlsContext>,
16}
17
18impl PgDriver {
19    /// Create a new driver with an existing connection.
20    pub fn new(connection: PgConnection) -> Self {
21        Self {
22            connection,
23            rls_context: None,
24        }
25    }
26
27    /// Builder pattern for ergonomic connection configuration.
28    /// # Example
29    /// ```ignore
30    /// let driver = PgDriver::builder()
31    ///     .host("localhost")
32    ///     .port(5432)
33    ///     .user("admin")
34    ///     .database("mydb")
35    ///     .password("secret")  // Optional
36    ///     .connect()
37    ///     .await?;
38    /// ```
39    pub fn builder() -> PgDriverBuilder {
40        PgDriverBuilder::new()
41    }
42
43    /// Connect to PostgreSQL and create a driver (trust mode, no password).
44    ///
45    /// # Arguments
46    ///
47    /// * `host` — PostgreSQL server hostname or IP.
48    /// * `port` — TCP port (typically 5432).
49    /// * `user` — PostgreSQL role name.
50    /// * `database` — Target database name.
51    pub async fn connect(host: &str, port: u16, user: &str, database: &str) -> PgResult<Self> {
52        let connection = PgConnection::connect(host, port, user, database).await?;
53        Ok(Self::new(connection))
54    }
55
56    /// Connect to PostgreSQL with password authentication.
57    /// Supports server-requested auth flow: cleartext, MD5, or SCRAM-SHA-256.
58    pub async fn connect_with_password(
59        host: &str,
60        port: u16,
61        user: &str,
62        database: &str,
63        password: &str,
64    ) -> PgResult<Self> {
65        let connection =
66            PgConnection::connect_with_password(host, port, user, database, Some(password)).await?;
67        Ok(Self::new(connection))
68    }
69
70    /// Connect with explicit security options.
71    pub async fn connect_with_options(
72        host: &str,
73        port: u16,
74        user: &str,
75        database: &str,
76        password: Option<&str>,
77        options: ConnectOptions,
78    ) -> PgResult<Self> {
79        let connection =
80            PgConnection::connect_with_options(host, port, user, database, password, options)
81                .await?;
82        Ok(Self::new(connection))
83    }
84
85    /// Connect in logical replication mode (`replication=database`).
86    ///
87    /// This enables replication commands such as `IDENTIFY_SYSTEM` and
88    /// `CREATE_REPLICATION_SLOT`.
89    pub async fn connect_logical_replication(
90        host: &str,
91        port: u16,
92        user: &str,
93        database: &str,
94        password: Option<&str>,
95    ) -> PgResult<Self> {
96        let options = ConnectOptions::default().with_logical_replication();
97        Self::connect_with_options(host, port, user, database, password, options).await
98    }
99
100    /// Connect with explicit options and force logical replication mode.
101    pub async fn connect_logical_replication_with_options(
102        host: &str,
103        port: u16,
104        user: &str,
105        database: &str,
106        password: Option<&str>,
107        options: ConnectOptions,
108    ) -> PgResult<Self> {
109        Self::connect_with_options(
110            host,
111            port,
112            user,
113            database,
114            password,
115            options.with_logical_replication(),
116        )
117        .await
118    }
119
120    /// Connect using DATABASE_URL environment variable.
121    ///
122    /// Parses the URL format: `postgresql://user:password@host:port/database`
123    /// or `postgres://user:password@host:port/database`
124    ///
125    /// # Example
126    /// ```ignore
127    /// // Set DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
128    /// let driver = PgDriver::connect_env().await?;
129    /// ```
130    pub async fn connect_env() -> PgResult<Self> {
131        let url = std::env::var("DATABASE_URL").map_err(|_| {
132            PgError::Connection("DATABASE_URL environment variable not set".to_string())
133        })?;
134        Self::connect_url(&url).await
135    }
136
137    /// Connect using a PostgreSQL connection URL.
138    ///
139    /// Parses the URL format: `postgresql://user:password@host:port/database?params`
140    /// or `postgres://user:password@host:port/database?params`
141    ///
142    /// Supports all enterprise query params (sslmode, auth_mode, gss_provider,
143    /// channel_binding, etc.) — same set as `PoolConfig::from_qail_config`.
144    ///
145    /// # Example
146    /// ```ignore
147    /// let driver = PgDriver::connect_url("postgresql://user:pass@localhost:5432/mydb?sslmode=require").await?;
148    /// ```
149    pub async fn connect_url(url: &str) -> PgResult<Self> {
150        let (host, port, user, database, password) = Self::parse_database_url(url)?;
151
152        // Parse enterprise query params using the shared helper from pool.rs.
153        let mut pool_cfg = pool::PoolConfig::new(&host, port, &user, &database);
154        if let Some(pw) = &password {
155            pool_cfg = pool_cfg.password(pw);
156        }
157        if let Some((_, query)) = url.split_once('?') {
158            pool::apply_url_query_params(&mut pool_cfg, query, &host)?;
159        }
160
161        let mut opts = ConnectOptions {
162            tls_mode: pool_cfg.tls_mode,
163            gss_enc_mode: pool_cfg.gss_enc_mode,
164            tls_ca_cert_pem: pool_cfg.tls_ca_cert_pem,
165            mtls: pool_cfg.mtls,
166            gss_token_provider: pool_cfg.gss_token_provider,
167            gss_token_provider_ex: pool_cfg.gss_token_provider_ex,
168            auth: pool_cfg.auth_settings,
169            io_uring: pool_cfg.io_uring,
170            startup_params: Vec::new(),
171        };
172
173        // Startup parameters not owned by PoolConfig parser.
174        if let Some((_, query)) = url.split_once('?') {
175            for pair in query.split('&') {
176                let mut kv = pair.splitn(2, '=');
177                let key = kv.next().unwrap_or_default().trim();
178                let value = kv.next().unwrap_or_default().trim();
179                if key.eq_ignore_ascii_case("replication") {
180                    let replication_mode = if value.eq_ignore_ascii_case("database") {
181                        "database"
182                    } else if value.eq_ignore_ascii_case("true")
183                        || value.eq_ignore_ascii_case("on")
184                        || value == "1"
185                    {
186                        // Canonicalize legacy truthy values to PostgreSQL's
187                        // logical-replication mode value.
188                        "database"
189                    } else {
190                        return Err(PgError::Connection(format!(
191                            "Invalid replication startup mode '{}': expected database|true|on|1",
192                            value
193                        )));
194                    };
195                    opts = opts.with_startup_param("replication", replication_mode);
196                }
197            }
198        }
199
200        Self::connect_with_options(&host, port, &user, &database, password.as_deref(), opts).await
201    }
202
203    /// Parse a PostgreSQL connection URL into components.
204    ///
205    /// Format: `postgresql://user:password@host:port/database`
206    /// or `postgres://user:password@host:port/database`
207    ///
208    /// URL percent-encoding is automatically decoded for user and password.
209    pub(crate) fn parse_database_url(
210        url: &str,
211    ) -> PgResult<(String, u16, String, String, Option<String>)> {
212        let after_scheme = if let Some(rest) = url.strip_prefix("postgres://") {
213            rest
214        } else if let Some(rest) = url.strip_prefix("postgresql://") {
215            rest
216        } else {
217            return Err(PgError::Connection(
218                "Invalid DATABASE_URL: expected postgres:// or postgresql://".to_string(),
219            ));
220        };
221
222        // Split into auth@host parts
223        let (auth_part, host_db_part) = if let Some(at_pos) = after_scheme.rfind('@') {
224            (Some(&after_scheme[..at_pos]), &after_scheme[at_pos + 1..])
225        } else {
226            (None, after_scheme)
227        };
228
229        // Parse auth (user:password)
230        let (user, password) = if let Some(auth) = auth_part {
231            if auth.is_empty() {
232                return Err(PgError::Connection(
233                    "Invalid DATABASE_URL: missing user".to_string(),
234                ));
235            }
236            let parts: Vec<&str> = auth.splitn(2, ':').collect();
237            if parts.len() == 2 {
238                // URL-decode both user and password
239                let user = Self::percent_decode(parts[0])?;
240                if user.is_empty() {
241                    return Err(PgError::Connection(
242                        "Invalid DATABASE_URL: missing user".to_string(),
243                    ));
244                }
245                (user, Some(Self::percent_decode(parts[1])?))
246            } else {
247                let user = Self::percent_decode(parts[0])?;
248                if user.is_empty() {
249                    return Err(PgError::Connection(
250                        "Invalid DATABASE_URL: missing user".to_string(),
251                    ));
252                }
253                (user, None)
254            }
255        } else {
256            ("postgres".to_string(), None)
257        };
258
259        // Parse host:port/database (strip query string if present)
260        let (host_port, database) = if let Some(slash_pos) = host_db_part.find('/') {
261            let raw_db = &host_db_part[slash_pos + 1..];
262            // Strip ?query params — they're handled separately by connect_url
263            let db = Self::percent_decode(raw_db.split('?').next().unwrap_or(raw_db))?;
264            if db.is_empty() {
265                return Err(PgError::Connection(
266                    "Invalid DATABASE_URL: missing database name".to_string(),
267                ));
268            }
269            (&host_db_part[..slash_pos], db)
270        } else {
271            return Err(PgError::Connection(
272                "Invalid DATABASE_URL: missing database name".to_string(),
273            ));
274        };
275
276        // Parse host:port
277        let (host, port) = if host_port.starts_with('[') {
278            let end = host_port.find(']').ok_or_else(|| {
279                PgError::Connection("Invalid DATABASE_URL: malformed IPv6 host".to_string())
280            })?;
281            let host = &host_port[..=end];
282            if host == "[]" {
283                return Err(PgError::Connection(
284                    "Invalid DATABASE_URL: missing host".to_string(),
285                ));
286            }
287            let suffix = &host_port[end + 1..];
288            let port = if suffix.is_empty() {
289                5432
290            } else if let Some(port_str) = suffix.strip_prefix(':') {
291                Self::parse_database_url_port(port_str)?
292            } else {
293                return Err(PgError::Connection(
294                    "Invalid DATABASE_URL: malformed IPv6 host".to_string(),
295                ));
296            };
297            (host.to_string(), port)
298        } else if let Some(colon_pos) = host_port.rfind(':') {
299            let port_str = &host_port[colon_pos + 1..];
300            let host = &host_port[..colon_pos];
301            if host.is_empty() {
302                return Err(PgError::Connection(
303                    "Invalid DATABASE_URL: missing host".to_string(),
304                ));
305            }
306            let port = Self::parse_database_url_port(port_str)?;
307            (host.to_string(), port)
308        } else {
309            if host_port.is_empty() {
310                return Err(PgError::Connection(
311                    "Invalid DATABASE_URL: missing host".to_string(),
312                ));
313            }
314            (host_port.to_string(), 5432) // Default PostgreSQL port
315        };
316
317        Ok((host, port, user, database, password))
318    }
319
320    fn parse_database_url_port(port_str: &str) -> PgResult<u16> {
321        if port_str.is_empty() {
322            return Err(PgError::Connection(
323                "Invalid DATABASE_URL: missing port after ':'".to_string(),
324            ));
325        }
326        let port = port_str
327            .parse::<u16>()
328            .map_err(|_| PgError::Connection(format!("Invalid port: {}", port_str)))?;
329        if port == 0 {
330            return Err(PgError::Connection(
331                "Invalid port: 0 (expected 1..=65535)".to_string(),
332            ));
333        }
334        Ok(port)
335    }
336
337    /// Decode URL percent-encoded string.
338    /// Handles common encodings: %20 (space), %2B (+), %3D (=), %40 (@), %2F (/), etc.
339    pub(crate) fn percent_decode(s: &str) -> PgResult<String> {
340        fn hex_value(byte: u8) -> Option<u8> {
341            match byte {
342                b'0'..=b'9' => Some(byte - b'0'),
343                b'a'..=b'f' => Some(byte - b'a' + 10),
344                b'A'..=b'F' => Some(byte - b'A' + 10),
345                _ => None,
346            }
347        }
348
349        let bytes = s.as_bytes();
350        let mut decoded = Vec::with_capacity(bytes.len());
351        let mut i = 0;
352
353        while i < bytes.len() {
354            if bytes[i] == b'%' {
355                if i + 2 >= bytes.len() {
356                    return Err(PgError::Connection(
357                        "Invalid DATABASE_URL percent-encoding: '%' must be followed by two hex digits"
358                            .to_string(),
359                    ));
360                }
361                let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
362                else {
363                    return Err(PgError::Connection(
364                        "Invalid DATABASE_URL percent-encoding: '%' must be followed by two hex digits"
365                            .to_string(),
366                    ));
367                };
368                decoded.push((hi << 4) | lo);
369                i += 3;
370            } else {
371                decoded.push(bytes[i]);
372                i += 1;
373            }
374        }
375
376        String::from_utf8(decoded).map_err(|_| {
377            PgError::Connection(
378                "Invalid DATABASE_URL percent-encoding: decoded value is not UTF-8".to_string(),
379            )
380        })
381    }
382
383    /// Connect to PostgreSQL with a connection timeout.
384    /// If the connection cannot be established within the timeout, returns an error.
385    /// # Example
386    /// ```ignore
387    /// use std::time::Duration;
388    /// let driver = PgDriver::connect_with_timeout(
389    ///     "localhost", 5432, "user", "db", "password",
390    ///     Duration::from_secs(5)
391    /// ).await?;
392    /// ```
393    pub async fn connect_with_timeout(
394        host: &str,
395        port: u16,
396        user: &str,
397        database: &str,
398        password: &str,
399        timeout: std::time::Duration,
400    ) -> PgResult<Self> {
401        tokio::time::timeout(
402            timeout,
403            Self::connect_with_password(host, port, user, database, password),
404        )
405        .await
406        .map_err(|_| PgError::Timeout(format!("connection after {:?}", timeout)))?
407    }
408    /// Clear the prepared statement cache.
409    /// Frees memory by removing all cached statements.
410    /// Note: Statements remain on the PostgreSQL server until connection closes.
411    pub fn clear_cache(&mut self) {
412        self.connection.clear_prepared_statement_state();
413    }
414
415    /// Get cache statistics.
416    /// Returns (current_size, max_capacity).
417    pub fn cache_stats(&self) -> (usize, usize) {
418        (
419            self.connection.stmt_cache.len(),
420            self.connection.stmt_cache.cap().get(),
421        )
422    }
423}