Skip to main content

bsql_driver_postgres/
types.rs

1//! Shared types used by both async `Connection` and sync `SyncConnection`.
2//!
3//! Extracted from `conn.rs` to avoid duplication between the async and sync
4//! code paths. Contains configuration, result types, row views, and helpers.
5
6use std::sync::Arc;
7
8use rapidhash::quality::RapidHasher;
9
10use crate::arena::Arena;
11use crate::DriverError;
12
13// ---------------------------------------------------------------------------
14// Config
15// ---------------------------------------------------------------------------
16
17/// Implements Drop to zeroize the password field, minimizing the
18/// window where plaintext credentials live in memory.
19#[derive(Clone)]
20pub struct Config {
21    pub host: String,
22    pub port: u16,
23    pub user: String,
24    pub password: String,
25    pub database: String,
26    pub ssl: SslMode,
27    /// PG-side statement timeout in seconds. Default: 30. 0 = no timeout.
28    ///
29    /// After connecting, the driver sends `SET statement_timeout = '{N}s'`.
30    /// If a query exceeds this duration, PostgreSQL kills it and returns an error.
31    pub statement_timeout_secs: u32,
32    /// Statement cache mode. Default: [`StatementCacheMode::Named`].
33    ///
34    /// Set to [`StatementCacheMode::Disabled`] for pgbouncer/PgCat transaction
35    /// pooling compatibility. When disabled, every query uses the unnamed
36    /// prepared statement — no server-side statement caching.
37    pub statement_cache_mode: StatementCacheMode,
38    /// Path to PEM file containing CA certificate(s) for server verification.
39    /// When set, these CAs are used instead of the system defaults.
40    pub ssl_root_cert: Option<String>,
41    /// Path to PEM file containing client certificate for mTLS.
42    pub ssl_cert: Option<String>,
43    /// Path to PEM file containing client private key for mTLS.
44    pub ssl_key: Option<String>,
45}
46
47/// Zeroize password on drop to minimize credential lifetime in memory.
48impl Drop for Config {
49    fn drop(&mut self) {
50        use zeroize::Zeroize;
51        self.password.zeroize();
52    }
53}
54
55/// Redact the password field in Debug output to prevent credential leaks
56/// in logs, error messages, and `{:?}` formatting.
57impl std::fmt::Debug for Config {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("Config")
60            .field("host", &self.host)
61            .field("port", &self.port)
62            .field("user", &self.user)
63            .field("password", &"[REDACTED]")
64            .field("database", &self.database)
65            .field("ssl", &self.ssl)
66            .field("statement_timeout_secs", &self.statement_timeout_secs)
67            .field("statement_cache_mode", &self.statement_cache_mode)
68            .field("ssl_root_cert", &self.ssl_root_cert)
69            .field("ssl_cert", &self.ssl_cert)
70            .field("ssl_key", &self.ssl_key)
71            .finish()
72    }
73}
74
75/// SSL/TLS connection mode.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SslMode {
78    /// Never use TLS.
79    Disable,
80    /// Try TLS, fall back to plain if server says 'N'.
81    Prefer,
82    /// Require TLS, fail if server says 'N'.
83    Require,
84}
85
86/// Statement cache behavior.
87///
88/// Controls whether the driver caches prepared statements with server-side
89/// names (`s_{hash}`) or uses unnamed statements for every query.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub enum StatementCacheMode {
92    /// Named prepared statements, cached and reused (default).
93    ///
94    /// Best performance for direct connections. Each unique SQL text is
95    /// prepared once and reused across subsequent calls.
96    #[default]
97    Named,
98    /// Unnamed statements only — compatible with pgbouncer transaction mode.
99    ///
100    /// Every query sends Parse+Bind+Execute with the unnamed statement (`""`).
101    /// Slightly slower (extra Parse per query) but works with connection
102    /// poolers that use transaction-level pooling (pgbouncer, PgCat).
103    Disabled,
104}
105
106impl Config {
107    /// Parse a PostgreSQL connection URL.
108    ///
109    /// Format: `postgres://user:password@host:port/database?sslmode=prefer`
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use bsql_driver_postgres::Config;
115    ///
116    /// let config = Config::from_url("postgres://alice:secret@db.example.com:5432/myapp").unwrap();
117    /// assert_eq!(config.user, "alice");
118    /// assert_eq!(config.host, "db.example.com");
119    /// assert_eq!(config.port, 5432);
120    /// assert_eq!(config.database, "myapp");
121    /// ```
122    ///
123    /// pgbouncer-compatible connection:
124    ///
125    /// ```
126    /// use bsql_driver_postgres::{Config, StatementCacheMode};
127    ///
128    /// let config = Config::from_url(
129    ///     "postgres://user:pass@pgbouncer:6432/mydb?statement_cache=disabled"
130    /// ).unwrap();
131    /// assert_eq!(config.statement_cache_mode, StatementCacheMode::Disabled);
132    /// ```
133    ///
134    /// # Unix domain sockets
135    ///
136    /// Use the `host` query parameter to specify a UDS directory (libpq convention):
137    /// ```text
138    /// postgres://user@localhost/dbname?host=/tmp
139    /// postgres:///dbname?host=/var/run/postgresql
140    /// ```
141    /// When `host` starts with `/`, the driver connects via Unix domain socket at
142    /// `{host}/.s.PGSQL.{port}` instead of TCP. TLS is skipped for UDS connections.
143    pub fn from_url(url: &str) -> Result<Self, DriverError> {
144        let url = url
145            .strip_prefix("postgres://")
146            .or_else(|| url.strip_prefix("postgresql://"))
147            .ok_or_else(|| DriverError::Protocol("URL must start with postgres://".into()))?;
148
149        // Split user:password@host:port/database
150        let (userinfo, rest) = url
151            .split_once('@')
152            .ok_or_else(|| DriverError::Protocol("missing @ in connection URL".into()))?;
153
154        let (user, password) = userinfo.split_once(':').unwrap_or((userinfo, ""));
155
156        // Split host:port/database?params
157        let (hostport, rest) = rest.split_once('/').unwrap_or((rest, ""));
158        let (database, params) = rest.split_once('?').unwrap_or((rest, ""));
159
160        let (host, port) = if let Some((h, p)) = hostport.split_once(':') {
161            let port = p
162                .parse::<u16>()
163                .map_err(|_| DriverError::Protocol(format!("invalid port: {p}")))?;
164            (h.to_owned(), port)
165        } else {
166            (hostport.to_owned(), 5432)
167        };
168
169        let mut ssl = SslMode::Prefer;
170        let mut statement_timeout_secs: u32 = 30;
171        let mut statement_cache_mode = StatementCacheMode::Named;
172        let mut host_override: Option<String> = None;
173        let mut ssl_root_cert: Option<String> = None;
174        let mut ssl_cert: Option<String> = None;
175        let mut ssl_key: Option<String> = None;
176        for param in params.split('&') {
177            if param.is_empty() {
178                continue;
179            }
180            if let Some(val) = param.strip_prefix("sslmode=") {
181                // A typo like "sslmode=require" (missing 'e') would go unencrypted.
182                ssl = match val {
183                    "disable" => SslMode::Disable,
184                    "prefer" => SslMode::Prefer,
185                    "require" => SslMode::Require,
186                    _ => {
187                        return Err(DriverError::Protocol(format!(
188                            "unknown sslmode: '{val}' (expected: disable, prefer, require)"
189                        )));
190                    }
191                };
192            } else if let Some(val) = param.strip_prefix("statement_timeout=") {
193                statement_timeout_secs = val.parse::<u32>().unwrap_or(30);
194            } else if let Some(val) = param.strip_prefix("statement_cache=") {
195                statement_cache_mode = match val {
196                    "named" => StatementCacheMode::Named,
197                    "disabled" => StatementCacheMode::Disabled,
198                    _ => {
199                        return Err(DriverError::Protocol(format!(
200                            "unknown statement_cache mode: '{val}' (expected: named, disabled)"
201                        )));
202                    }
203                };
204            } else if let Some(val) = param.strip_prefix("host=") {
205                host_override = Some(url_decode(val)?);
206            } else if let Some(val) = param.strip_prefix("sslrootcert=") {
207                ssl_root_cert = Some(url_decode(val)?);
208            } else if let Some(val) = param.strip_prefix("sslcert=") {
209                ssl_cert = Some(url_decode(val)?);
210            } else if let Some(val) = param.strip_prefix("sslkey=") {
211                ssl_key = Some(url_decode(val)?);
212            }
213        }
214
215        // If ?host=/path was specified, override the URL hostname with it.
216        // This follows the libpq convention: host=/tmp means UDS.
217        let final_host = if let Some(h) = host_override {
218            h
219        } else {
220            url_decode(&host)?
221        };
222
223        let config = Config {
224            host: final_host,
225            port,
226            user: url_decode(user)?,
227            password: url_decode(password)?,
228            database: if database.is_empty() {
229                url_decode(user)?
230            } else {
231                url_decode(database)?
232            },
233            ssl,
234            statement_timeout_secs,
235            statement_cache_mode,
236            ssl_root_cert,
237            ssl_cert,
238            ssl_key,
239        };
240        config.validate()?;
241        Ok(config)
242    }
243
244    /// Validate configuration fields before attempting a connection.
245    ///
246    /// Called automatically by `from_url()`. Call manually if constructing
247    /// a `Config` by hand.
248    pub fn validate(&self) -> Result<(), DriverError> {
249        if self.host.is_empty() {
250            return Err(DriverError::Protocol("host cannot be empty".into()));
251        }
252        if self.user.is_empty() {
253            return Err(DriverError::Protocol("user cannot be empty".into()));
254        }
255        if self.database.is_empty() {
256            return Err(DriverError::Protocol("database cannot be empty".into()));
257        }
258        Ok(())
259    }
260
261    /// Returns `true` if the host is a Unix domain socket directory path.
262    ///
263    /// libpq convention: if `host` starts with `/`, the connection uses a
264    /// Unix domain socket at `{host}/.s.PGSQL.{port}`.
265    pub fn host_is_uds(&self) -> bool {
266        self.host.starts_with('/')
267    }
268
269    /// Returns the Unix domain socket path: `{host}/.s.PGSQL.{port}`.
270    ///
271    /// Only meaningful when [`host_is_uds()`](Self::host_is_uds) returns `true`.
272    pub fn uds_path(&self) -> String {
273        format!("{}/.s.PGSQL.{}", self.host, self.port)
274    }
275}
276
277// ---------------------------------------------------------------------------
278// url_decode / hex_val
279// ---------------------------------------------------------------------------
280
281/// Minimal percent-decoding for connection URL components.
282///
283/// Decodes `%XX` hex sequences into raw bytes, then validates as UTF-8.
284/// This correctly handles multi-byte UTF-8 characters that are percent-encoded
285/// byte-by-byte (e.g. `%C3%A9` for 'e').
286fn url_decode(s: &str) -> Result<String, DriverError> {
287    let mut bytes = Vec::with_capacity(s.len());
288    let input = s.as_bytes();
289    let mut i = 0;
290    while i < input.len() {
291        if input[i] == b'%' {
292            if i + 2 >= input.len() {
293                return Err(DriverError::Protocol(format!(
294                    "malformed percent-encoding in URL: '{s}'"
295                )));
296            }
297            let hi = hex_val(input[i + 1]).ok_or_else(|| {
298                DriverError::Protocol(format!(
299                    "invalid hex digit '{}' in URL: '{s}'",
300                    input[i + 1] as char
301                ))
302            })?;
303            let lo = hex_val(input[i + 2]).ok_or_else(|| {
304                DriverError::Protocol(format!(
305                    "invalid hex digit '{}' in URL: '{s}'",
306                    input[i + 2] as char
307                ))
308            })?;
309            bytes.push(hi * 16 + lo);
310            i += 3;
311        } else {
312            bytes.push(input[i]);
313            i += 1;
314        }
315    }
316    String::from_utf8(bytes)
317        .map_err(|_| DriverError::Protocol(format!("invalid UTF-8 in URL: '{s}'")))
318}
319
320fn hex_val(b: u8) -> Option<u8> {
321    match b {
322        b'0'..=b'9' => Some(b - b'0'),
323        b'a'..=b'f' => Some(b - b'a' + 10),
324        b'A'..=b'F' => Some(b - b'A' + 10),
325        _ => None,
326    }
327}
328
329// ---------------------------------------------------------------------------
330// StartupAction
331// ---------------------------------------------------------------------------
332
333/// Owned action from a startup message, avoiding borrow conflicts with `self.read_buf`.
334pub(crate) enum StartupAction {
335    AuthOk,
336    AuthCleartext,
337    AuthMd5([u8; 4]),
338    AuthSasl(Vec<u8>),
339    ParameterStatus(Box<str>, Box<str>),
340    BackendKeyData(i32, i32),
341    ReadyForQuery(u8),
342    Error(String),
343    Notice,
344}
345
346// ---------------------------------------------------------------------------
347// ColumnDesc / PrepareResult / SimpleRow / Notification
348// ---------------------------------------------------------------------------
349
350/// Description of a result column.
351#[derive(Debug, Clone)]
352pub struct ColumnDesc {
353    /// Column name from the query.
354    pub name: Box<str>,
355    /// PostgreSQL type OID.
356    pub type_oid: u32,
357    /// OID of the source table (0 if not a table column, e.g. computed).
358    pub table_oid: u32,
359    /// Type size in bytes (-1 for variable-length).
360    pub type_size: i16,
361    /// Column number within the source table (0 if not a table column).
362    pub column_id: i16,
363}
364
365/// Result of a `prepare_describe` call -- column and parameter metadata
366/// without executing the query.
367#[derive(Debug, Clone)]
368pub struct PrepareResult {
369    /// Output columns (empty for INSERT/UPDATE/DELETE without RETURNING).
370    pub columns: Vec<ColumnDesc>,
371    /// PostgreSQL OIDs of the expected parameter types.
372    pub param_oids: Vec<u32>,
373}
374
375/// A single row of text values returned by `simple_query_rows`.
376///
377/// Each field is `None` for SQL NULL, `Some(text)` otherwise.
378/// Only used for compile-time schema introspection queries.
379pub type SimpleRow = Vec<Option<String>>;
380
381/// A notification received during normal query processing.
382///
383/// When the read loop encounters a NotificationResponse during queries,
384/// it is buffered here instead of being dropped. Call
385/// [`Connection::drain_notifications`] to retrieve and clear the buffer.
386#[derive(Debug, Clone)]
387pub struct Notification {
388    /// Backend process ID that sent the notification.
389    pub pid: i32,
390    /// Channel name.
391    pub channel: String,
392    /// Payload string (may be empty).
393    pub payload: String,
394}
395
396// ---------------------------------------------------------------------------
397// QueryResult
398// ---------------------------------------------------------------------------
399
400/// Collected result of a query: all rows' column offsets plus metadata.
401///
402/// Data lives in an [`Arena`]; this struct holds only the offset/length
403/// bookkeeping. Access rows via [`row()`](Self::row) or [`rows()`](Self::rows).
404///
405/// # Example
406///
407/// ```ignore
408/// for row in result.rows(&arena) {
409///     // Access columns by index
410/// }
411/// ```
412pub struct QueryResult {
413    /// All rows' column (offset, length) pairs, contiguous.
414    /// length = -1 means NULL. Offsets point into `data_buf` if present,
415    /// otherwise into the arena.
416    pub(crate) all_col_offsets: Vec<(usize, i32)>,
417    /// Number of columns per row.
418    pub(crate) num_cols: usize,
419    pub(crate) columns: Arc<[ColumnDesc]>,
420    pub(crate) affected_rows: u64,
421    /// Inline data buffer for non-streaming queries.
422    /// When present, column offsets point here instead of the arena.
423    /// This eliminates the final arena.alloc_copy for entire result sets.
424    pub(crate) data_buf: Option<Vec<u8>>,
425}
426
427impl QueryResult {
428    /// Construct a `QueryResult` from its constituent parts.
429    ///
430    /// Used by `bsql-core`'s streaming layer to assemble per-chunk results.
431    pub fn from_parts(
432        all_col_offsets: Vec<(usize, i32)>,
433        num_cols: usize,
434        columns: Arc<[ColumnDesc]>,
435        affected_rows: u64,
436    ) -> Self {
437        Self {
438            all_col_offsets,
439            num_cols,
440            columns,
441            affected_rows,
442            data_buf: None,
443        }
444    }
445
446    /// Construct with inline data buffer (zero-copy from wire).
447    pub fn from_parts_with_buf(
448        all_col_offsets: Vec<(usize, i32)>,
449        num_cols: usize,
450        columns: Arc<[ColumnDesc]>,
451        affected_rows: u64,
452        data_buf: Vec<u8>,
453    ) -> Self {
454        Self {
455            all_col_offsets,
456            num_cols,
457            columns,
458            affected_rows,
459            data_buf: if data_buf.is_empty() {
460                None
461            } else {
462                Some(data_buf)
463            },
464        }
465    }
466
467    /// Number of rows in the result.
468    pub fn len(&self) -> usize {
469        if self.num_cols == 0 {
470            return 0;
471        }
472        self.all_col_offsets.len() / self.num_cols
473    }
474
475    /// Whether the result set is empty.
476    pub fn is_empty(&self) -> bool {
477        self.all_col_offsets.is_empty()
478    }
479
480    /// Number of affected rows (for INSERT/UPDATE/DELETE).
481    pub fn affected_rows(&self) -> u64 {
482        self.affected_rows
483    }
484
485    /// Column descriptors.
486    pub fn columns(&self) -> &[ColumnDesc] {
487        &self.columns
488    }
489
490    /// Get a row by index. The returned `Row` borrows from the arena or
491    /// the inline data buffer.
492    pub fn row<'a>(&'a self, idx: usize, arena: &'a Arena) -> Row<'a> {
493        let start = idx * self.num_cols;
494        let end = start + self.num_cols;
495        Row {
496            data: self.data_buf.as_deref(),
497            arena,
498            col_offsets: &self.all_col_offsets[start..end],
499            columns: &self.columns,
500        }
501    }
502
503    /// Take the `col_offsets` vec out of this result, leaving it empty.
504    ///
505    /// Used by `QueryStream` to reclaim and reuse the allocation between chunks
506    /// instead of allocating a new `Vec` per chunk.
507    pub fn take_col_offsets(&mut self) -> Vec<(usize, i32)> {
508        std::mem::take(&mut self.all_col_offsets)
509    }
510
511    /// Take the data buffer for recycling. Returns None if no data_buf.
512    pub fn take_data_buf(&mut self) -> Option<Vec<u8>> {
513        self.data_buf.take()
514    }
515
516    /// Iterate over rows.
517    pub fn rows<'a>(&'a self, arena: &'a Arena) -> impl Iterator<Item = Row<'a>> {
518        let num_cols = self.num_cols;
519        let columns = &self.columns;
520        let data = self.data_buf.as_deref();
521        self.all_col_offsets
522            .chunks(num_cols.max(1))
523            .map(move |chunk| Row {
524                data,
525                arena,
526                col_offsets: chunk,
527                columns,
528            })
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Row
534// ---------------------------------------------------------------------------
535
536/// A view into a single result row, borrowing data from the arena.
537///
538/// Column values are accessed by index. NULL values return `None`.
539/// Decode errors (protocol violations from a malicious server) are treated
540/// as `None` rather than panicking -- a compliant PostgreSQL server always
541/// sends correctly-sized data for the declared type.
542pub struct Row<'a> {
543    /// Inline data buffer (when QueryResult has data_buf).
544    /// If present, column offsets point here. Otherwise they point into arena.
545    data: Option<&'a [u8]>,
546    arena: &'a Arena,
547    col_offsets: &'a [(usize, i32)],
548    columns: &'a [ColumnDesc],
549}
550
551impl<'a> Row<'a> {
552    /// Get the raw bytes for a column, or `None` if NULL.
553    #[inline]
554    pub fn get_raw(&self, idx: usize) -> Option<&'a [u8]> {
555        let (offset, len) = self.col_offsets[idx];
556        if len < 0 {
557            None
558        } else if let Some(buf) = self.data {
559            Some(&buf[offset..offset + len as usize])
560        } else {
561            Some(self.arena.get(offset, len as usize))
562        }
563    }
564
565    /// Whether a column is NULL.
566    #[inline]
567    pub fn is_null(&self, idx: usize) -> bool {
568        self.col_offsets[idx].1 < 0
569    }
570
571    /// Number of columns.
572    #[inline]
573    pub fn column_count(&self) -> usize {
574        self.col_offsets.len()
575    }
576
577    /// Get a boolean column value. Returns `None` on NULL or decode error.
578    #[inline]
579    pub fn get_bool(&self, idx: usize) -> Option<bool> {
580        self.get_raw(idx)
581            .and_then(|data| crate::codec::decode_bool(data).ok())
582    }
583
584    /// Get an i16 column value. Returns `None` on NULL or decode error.
585    #[inline]
586    pub fn get_i16(&self, idx: usize) -> Option<i16> {
587        self.get_raw(idx)
588            .and_then(|data| crate::codec::decode_i16(data).ok())
589    }
590
591    /// Get an i32 column value. Returns `None` on NULL or decode error.
592    #[inline]
593    pub fn get_i32(&self, idx: usize) -> Option<i32> {
594        self.get_raw(idx)
595            .and_then(|data| crate::codec::decode_i32(data).ok())
596    }
597
598    /// Get an i64 column value. Returns `None` on NULL or decode error.
599    #[inline]
600    pub fn get_i64(&self, idx: usize) -> Option<i64> {
601        self.get_raw(idx)
602            .and_then(|data| crate::codec::decode_i64(data).ok())
603    }
604
605    /// Get an f32 column value. Returns `None` on NULL or decode error.
606    #[inline]
607    pub fn get_f32(&self, idx: usize) -> Option<f32> {
608        self.get_raw(idx)
609            .and_then(|data| crate::codec::decode_f32(data).ok())
610    }
611
612    /// Get an f64 column value. Returns `None` on NULL or decode error.
613    #[inline]
614    pub fn get_f64(&self, idx: usize) -> Option<f64> {
615        self.get_raw(idx)
616            .and_then(|data| crate::codec::decode_f64(data).ok())
617    }
618
619    /// Get a string column value. Returns `None` on NULL or decode error.
620    #[inline]
621    pub fn get_str(&self, idx: usize) -> Option<&'a str> {
622        self.get_raw(idx)
623            .and_then(|data| crate::codec::decode_str(data).ok())
624    }
625
626    /// Get a byte slice column value.
627    #[inline]
628    pub fn get_bytes(&self, idx: usize) -> Option<&'a [u8]> {
629        self.get_raw(idx)
630    }
631
632    /// Get the column name by index.
633    #[inline]
634    pub fn column_name(&self, idx: usize) -> &str {
635        &self.columns[idx].name
636    }
637
638    /// Get the column type OID by index.
639    #[inline]
640    pub fn column_type_oid(&self, idx: usize) -> u32 {
641        self.columns[idx].type_oid
642    }
643}
644
645// ---------------------------------------------------------------------------
646// PgDataRow (zero-copy row view for for_each)
647// ---------------------------------------------------------------------------
648
649/// A temporary view of a single PostgreSQL DataRow message.
650///
651/// Reads columns directly from the wire buffer -- no arena copy.
652/// Column offsets are pre-computed on construction using a `SmallVec`
653/// that is stack-allocated for up to 16 columns (zero heap allocation
654/// for the common case).
655///
656/// Lifetime `'a` borrows from `Connection::read_buf`.
657pub struct PgDataRow<'a> {
658    data: &'a [u8],
659    /// Pre-scanned `(byte_offset, wire_len)` pairs for each column.
660    /// `wire_len = -1` means NULL.
661    offsets: smallvec::SmallVec<[(usize, i32); 16]>,
662}
663
664impl<'a> PgDataRow<'a> {
665    /// Parse column boundaries from a raw DataRow payload.
666    ///
667    /// `data` is the DataRow message payload (after the 'D' type byte and
668    /// 4-byte length prefix have been stripped by the framing layer).
669    pub fn new(data: &'a [u8]) -> Result<Self, DriverError> {
670        if data.len() < 2 {
671            return Err(DriverError::Protocol("DataRow too short".into()));
672        }
673        let num_cols = i16::from_be_bytes([data[0], data[1]]);
674        if num_cols < 0 {
675            return Err(DriverError::Protocol(
676                "DataRow: negative column count".into(),
677            ));
678        }
679        let num_cols = num_cols as usize;
680        let mut offsets = smallvec::SmallVec::<[(usize, i32); 16]>::with_capacity(num_cols);
681        let mut pos = 2usize;
682        for _ in 0..num_cols {
683            if pos + 4 > data.len() {
684                return Err(DriverError::Protocol("DataRow truncated".into()));
685            }
686            let col_len =
687                i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
688            pos += 4;
689            offsets.push((pos, col_len));
690            if col_len > 0 {
691                pos += col_len as usize;
692            }
693        }
694        Ok(Self { data, offsets })
695    }
696
697    /// Get the raw bytes for a column, or `None` if NULL.
698    #[inline]
699    pub fn get_raw(&self, idx: usize) -> Option<&'a [u8]> {
700        let (offset, len) = self.offsets[idx];
701        if len < 0 {
702            None
703        } else {
704            Some(&self.data[offset..offset + len as usize])
705        }
706    }
707
708    /// Whether a column is NULL.
709    #[inline]
710    pub fn is_null(&self, idx: usize) -> bool {
711        self.offsets[idx].1 < 0
712    }
713
714    /// Number of columns.
715    #[inline]
716    pub fn column_count(&self) -> usize {
717        self.offsets.len()
718    }
719
720    /// Get a boolean column value. Returns `None` on NULL or decode error.
721    #[inline]
722    pub fn get_bool(&self, idx: usize) -> Option<bool> {
723        self.get_raw(idx)
724            .and_then(|data| crate::codec::decode_bool(data).ok())
725    }
726
727    /// Get an i16 column value.
728    #[inline]
729    pub fn get_i16(&self, idx: usize) -> Option<i16> {
730        self.get_raw(idx)
731            .and_then(|data| crate::codec::decode_i16(data).ok())
732    }
733
734    /// Get an i32 column value.
735    #[inline]
736    pub fn get_i32(&self, idx: usize) -> Option<i32> {
737        self.get_raw(idx)
738            .and_then(|data| crate::codec::decode_i32(data).ok())
739    }
740
741    /// Get an i64 column value.
742    #[inline]
743    pub fn get_i64(&self, idx: usize) -> Option<i64> {
744        self.get_raw(idx)
745            .and_then(|data| crate::codec::decode_i64(data).ok())
746    }
747
748    /// Get an f32 column value.
749    #[inline]
750    pub fn get_f32(&self, idx: usize) -> Option<f32> {
751        self.get_raw(idx)
752            .and_then(|data| crate::codec::decode_f32(data).ok())
753    }
754
755    /// Get an f64 column value.
756    #[inline]
757    pub fn get_f64(&self, idx: usize) -> Option<f64> {
758        self.get_raw(idx)
759            .and_then(|data| crate::codec::decode_f64(data).ok())
760    }
761
762    /// Get a string column value (zero-copy borrow from the wire buffer).
763    #[inline]
764    pub fn get_str(&self, idx: usize) -> Option<&'a str> {
765        self.get_raw(idx)
766            .and_then(|data| crate::codec::decode_str(data).ok())
767    }
768
769    /// Get a byte slice column value (zero-copy borrow from the wire buffer).
770    #[inline]
771    pub fn get_bytes(&self, idx: usize) -> Option<&'a [u8]> {
772        self.get_raw(idx)
773    }
774}
775
776// ---------------------------------------------------------------------------
777// hash_sql
778// ---------------------------------------------------------------------------
779
780/// Compute a rapidhash of a SQL string.
781///
782/// Uses `str::hash()` via the `Hash` trait, matching `bsql_core::rapid_hash_str`.
783///
784/// ```
785/// let hash = bsql_driver_postgres::hash_sql("SELECT 1");
786/// assert_ne!(hash, 0);
787/// // Same SQL always produces the same hash
788/// assert_eq!(hash, bsql_driver_postgres::hash_sql("SELECT 1"));
789/// // Different SQL produces different hash
790/// assert_ne!(hash, bsql_driver_postgres::hash_sql("SELECT 2"));
791/// ```
792pub fn hash_sql(sql: &str) -> u64 {
793    use std::hash::{Hash, Hasher};
794    let mut hasher = RapidHasher::default();
795    sql.hash(&mut hasher);
796    hasher.finish()
797}
798
799// ---------------------------------------------------------------------------
800// Tests
801// ---------------------------------------------------------------------------
802
803#[cfg(test)]
804#[allow(clippy::approx_constant)]
805mod tests {
806    use super::*;
807
808    // ===================================================================
809    // Config tests
810    // ===================================================================
811
812    #[test]
813    fn config_parse_full_url() {
814        let cfg = Config::from_url("postgres://user:pass@localhost:5432/mydb").unwrap();
815        assert_eq!(cfg.user, "user");
816        assert_eq!(cfg.password, "pass");
817        assert_eq!(cfg.host, "localhost");
818        assert_eq!(cfg.port, 5432);
819        assert_eq!(cfg.database, "mydb");
820    }
821
822    #[test]
823    fn config_parse_default_port() {
824        let cfg = Config::from_url("postgres://user:pass@localhost/mydb").unwrap();
825        assert_eq!(cfg.port, 5432);
826    }
827
828    #[test]
829    fn config_parse_no_password() {
830        let cfg = Config::from_url("postgres://user@localhost/mydb").unwrap();
831        assert_eq!(cfg.user, "user");
832        assert_eq!(cfg.password, "");
833    }
834
835    #[test]
836    fn config_parse_empty_database() {
837        let cfg = Config::from_url("postgres://user:pass@localhost").unwrap();
838        // database defaults to user
839        assert_eq!(cfg.database, "user");
840    }
841
842    #[test]
843    fn config_parse_sslmode() {
844        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslmode=require").unwrap();
845        assert_eq!(cfg.ssl, SslMode::Require);
846    }
847
848    #[test]
849    fn config_parse_percent_encoding() {
850        let cfg = Config::from_url("postgres://user%40domain:p%40ss@localhost/db").unwrap();
851        assert_eq!(cfg.user, "user@domain");
852        assert_eq!(cfg.password, "p@ss");
853    }
854
855    #[test]
856    fn config_rejects_bad_scheme() {
857        let result = Config::from_url("mysql://user:pass@localhost/db");
858        assert!(result.is_err());
859    }
860
861    /// Unknown sslmode should error, not silently default to Prefer.
862    #[test]
863    fn config_rejects_unknown_sslmode() {
864        let result = Config::from_url("postgres://user:pass@localhost/db?sslmode=requre");
865        assert!(result.is_err(), "typo 'requre' should be rejected");
866        let result = Config::from_url("postgres://user:pass@localhost/db?sslmode=REQUIRE");
867        assert!(result.is_err(), "uppercase should be rejected");
868        let result = Config::from_url("postgres://user:pass@localhost/db?sslmode=bogus");
869        assert!(result.is_err(), "bogus value should be rejected");
870    }
871
872    /// Valid sslmodes should still work.
873    #[test]
874    fn config_accepts_valid_sslmodes() {
875        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslmode=disable").unwrap();
876        assert_eq!(cfg.ssl, SslMode::Disable);
877        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslmode=prefer").unwrap();
878        assert_eq!(cfg.ssl, SslMode::Prefer);
879        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslmode=require").unwrap();
880        assert_eq!(cfg.ssl, SslMode::Require);
881    }
882
883    // #68: Config with postgresql:// scheme
884    #[test]
885    fn config_parse_postgresql_scheme() {
886        let cfg = Config::from_url("postgresql://user:pass@localhost:5432/mydb").unwrap();
887        assert_eq!(cfg.user, "user");
888        assert_eq!(cfg.password, "pass");
889        assert_eq!(cfg.host, "localhost");
890        assert_eq!(cfg.port, 5432);
891        assert_eq!(cfg.database, "mydb");
892    }
893
894    // #69: Config URL without password
895    #[test]
896    fn config_parse_no_password_standalone() {
897        let cfg = Config::from_url("postgres://admin@db.example.com/myapp").unwrap();
898        assert_eq!(cfg.user, "admin");
899        assert_eq!(cfg.password, "");
900        assert_eq!(cfg.host, "db.example.com");
901        assert_eq!(cfg.database, "myapp");
902    }
903
904    // #70: Config URL with empty database (falls back to user)
905    #[test]
906    fn config_empty_database_falls_back_to_user() {
907        let cfg = Config::from_url("postgres://testuser:pass@localhost").unwrap();
908        assert_eq!(cfg.database, "testuser");
909    }
910
911    // #71: Config URL with unknown sslmode error
912    #[test]
913    fn config_unknown_sslmode_error() {
914        let result = Config::from_url("postgres://u:p@h/d?sslmode=verify-full");
915        assert!(result.is_err());
916        let err = result.unwrap_err().to_string();
917        assert!(
918            err.contains("unknown sslmode"),
919            "should describe unknown sslmode: {err}"
920        );
921    }
922
923    // #72: Config URL with multiple query params
924    #[test]
925    fn config_multiple_query_params() {
926        let cfg = Config::from_url(
927            "postgres://user:pass@localhost/db?sslmode=disable&statement_timeout=60",
928        )
929        .unwrap();
930        assert_eq!(cfg.ssl, SslMode::Disable);
931        assert_eq!(cfg.statement_timeout_secs, 60);
932    }
933
934    // Config validation: empty host
935    #[test]
936    fn config_validate_empty_host() {
937        let cfg = Config {
938            host: String::new(),
939            port: 5432,
940            user: "user".into(),
941            password: "pass".into(),
942            database: "db".into(),
943            ssl: SslMode::Disable,
944            statement_timeout_secs: 30,
945            statement_cache_mode: StatementCacheMode::Named,
946            ssl_root_cert: None,
947            ssl_cert: None,
948            ssl_key: None,
949        };
950        assert!(cfg.validate().is_err());
951    }
952
953    // Config validation: empty user
954    #[test]
955    fn config_validate_empty_user() {
956        let cfg = Config {
957            host: "localhost".into(),
958            port: 5432,
959            user: String::new(),
960            password: "pass".into(),
961            database: "db".into(),
962            ssl: SslMode::Disable,
963            statement_timeout_secs: 30,
964            statement_cache_mode: StatementCacheMode::Named,
965            ssl_root_cert: None,
966            ssl_cert: None,
967            ssl_key: None,
968        };
969        assert!(cfg.validate().is_err());
970    }
971
972    // Config validation: empty database
973    #[test]
974    fn config_validate_empty_database() {
975        let cfg = Config {
976            host: "localhost".into(),
977            port: 5432,
978            user: "user".into(),
979            password: "pass".into(),
980            database: String::new(),
981            ssl: SslMode::Disable,
982            statement_timeout_secs: 30,
983            statement_cache_mode: StatementCacheMode::Named,
984            ssl_root_cert: None,
985            ssl_cert: None,
986            ssl_key: None,
987        };
988        assert!(cfg.validate().is_err());
989    }
990
991    // Config missing @ in URL
992    #[test]
993    fn config_missing_at_sign() {
994        let result = Config::from_url("postgres://userpasslocalhost/db");
995        assert!(result.is_err());
996    }
997
998    // Config with custom port
999    #[test]
1000    fn config_custom_port() {
1001        let cfg = Config::from_url("postgres://user:pass@localhost:5433/db").unwrap();
1002        assert_eq!(cfg.port, 5433);
1003    }
1004
1005    // Config with invalid port
1006    #[test]
1007    fn config_invalid_port() {
1008        let result = Config::from_url("postgres://user:pass@localhost:notaport/db");
1009        assert!(result.is_err());
1010    }
1011
1012    // #76: Config SslMode::Require without tls feature
1013    #[cfg(not(feature = "tls"))]
1014    #[test]
1015    fn config_sslmode_require_without_tls_feature() {
1016        // The config parses fine, but validate doesn't check this.
1017        // The error occurs at connection time. Just verify parsing works.
1018        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslmode=require").unwrap();
1019        assert_eq!(cfg.ssl, SslMode::Require);
1020    }
1021
1022    #[test]
1023    fn config_statement_timeout_default() {
1024        let cfg = Config::from_url("postgres://user:pass@localhost/db").unwrap();
1025        assert_eq!(cfg.statement_timeout_secs, 30);
1026    }
1027
1028    #[test]
1029    fn config_statement_timeout_custom() {
1030        let cfg =
1031            Config::from_url("postgres://user:pass@localhost/db?statement_timeout=120").unwrap();
1032        assert_eq!(cfg.statement_timeout_secs, 120);
1033    }
1034
1035    #[test]
1036    fn config_statement_timeout_zero() {
1037        let cfg =
1038            Config::from_url("postgres://user:pass@localhost/db?statement_timeout=0").unwrap();
1039        assert_eq!(cfg.statement_timeout_secs, 0);
1040    }
1041
1042    #[test]
1043    fn config_statement_timeout_invalid_falls_back() {
1044        let cfg =
1045            Config::from_url("postgres://user:pass@localhost/db?statement_timeout=notanumber")
1046                .unwrap();
1047        assert_eq!(cfg.statement_timeout_secs, 30); // fallback
1048    }
1049
1050    // ===================================================================
1051    // Statement cache mode tests
1052    // ===================================================================
1053
1054    #[test]
1055    fn parse_statement_cache_default() {
1056        let cfg = Config::from_url("postgres://user:pass@localhost/db").unwrap();
1057        assert_eq!(cfg.statement_cache_mode, StatementCacheMode::Named);
1058    }
1059
1060    #[test]
1061    fn parse_statement_cache_named() {
1062        let cfg =
1063            Config::from_url("postgres://user:pass@localhost/db?statement_cache=named").unwrap();
1064        assert_eq!(cfg.statement_cache_mode, StatementCacheMode::Named);
1065    }
1066
1067    #[test]
1068    fn parse_statement_cache_disabled() {
1069        let cfg =
1070            Config::from_url("postgres://user:pass@localhost/db?statement_cache=disabled").unwrap();
1071        assert_eq!(cfg.statement_cache_mode, StatementCacheMode::Disabled);
1072    }
1073
1074    #[test]
1075    fn parse_statement_cache_invalid() {
1076        let result = Config::from_url("postgres://user:pass@localhost/db?statement_cache=off");
1077        assert!(result.is_err(), "invalid value 'off' should be rejected");
1078        let result = Config::from_url("postgres://user:pass@localhost/db?statement_cache=DISABLED");
1079        assert!(result.is_err(), "uppercase should be rejected");
1080        let result = Config::from_url("postgres://user:pass@localhost/db?statement_cache=bogus");
1081        assert!(result.is_err(), "bogus value should be rejected");
1082    }
1083
1084    #[test]
1085    fn parse_statement_cache_with_other_params() {
1086        let cfg = Config::from_url(
1087            "postgres://user:pass@localhost/db?sslmode=disable&statement_cache=disabled&statement_timeout=60",
1088        )
1089        .unwrap();
1090        assert_eq!(cfg.statement_cache_mode, StatementCacheMode::Disabled);
1091        assert_eq!(cfg.ssl, SslMode::Disable);
1092        assert_eq!(cfg.statement_timeout_secs, 60);
1093    }
1094
1095    #[test]
1096    fn statement_cache_mode_default_is_named() {
1097        assert_eq!(StatementCacheMode::default(), StatementCacheMode::Named);
1098    }
1099
1100    // ===================================================================
1101    // SSL certificate path parsing
1102    // ===================================================================
1103
1104    #[test]
1105    fn parse_ssl_root_cert() {
1106        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslrootcert=/path/to/ca.pem")
1107            .unwrap();
1108        assert_eq!(cfg.ssl_root_cert.as_deref(), Some("/path/to/ca.pem"));
1109        assert_eq!(cfg.ssl_cert, None);
1110        assert_eq!(cfg.ssl_key, None);
1111    }
1112
1113    #[test]
1114    fn parse_ssl_cert_and_key() {
1115        let cfg = Config::from_url(
1116            "postgres://user:pass@localhost/db?sslcert=/path/to/client.pem&sslkey=/path/to/client.key",
1117        )
1118        .unwrap();
1119        assert_eq!(cfg.ssl_root_cert, None);
1120        assert_eq!(cfg.ssl_cert.as_deref(), Some("/path/to/client.pem"));
1121        assert_eq!(cfg.ssl_key.as_deref(), Some("/path/to/client.key"));
1122    }
1123
1124    #[test]
1125    fn parse_ssl_all_tls_params() {
1126        let cfg = Config::from_url(
1127            "postgres://user:pass@localhost/db?sslmode=require&sslrootcert=/ca.pem&sslcert=/client.pem&sslkey=/client.key",
1128        )
1129        .unwrap();
1130        assert_eq!(cfg.ssl, SslMode::Require);
1131        assert_eq!(cfg.ssl_root_cert.as_deref(), Some("/ca.pem"));
1132        assert_eq!(cfg.ssl_cert.as_deref(), Some("/client.pem"));
1133        assert_eq!(cfg.ssl_key.as_deref(), Some("/client.key"));
1134    }
1135
1136    #[test]
1137    fn parse_ssl_paths_percent_encoded() {
1138        // %2F = '/'
1139        let cfg = Config::from_url("postgres://user:pass@localhost/db?sslrootcert=%2Ftmp%2Fca.pem")
1140            .unwrap();
1141        assert_eq!(cfg.ssl_root_cert.as_deref(), Some("/tmp/ca.pem"));
1142    }
1143
1144    #[test]
1145    fn parse_ssl_params_default_none() {
1146        let cfg = Config::from_url("postgres://user:pass@localhost/db").unwrap();
1147        assert_eq!(cfg.ssl_root_cert, None);
1148        assert_eq!(cfg.ssl_cert, None);
1149        assert_eq!(cfg.ssl_key, None);
1150    }
1151
1152    #[test]
1153    fn config_uds_path_format() {
1154        let cfg = Config::from_url("postgres://user@localhost/db?host=/tmp").unwrap();
1155        assert_eq!(cfg.uds_path(), "/tmp/.s.PGSQL.5432");
1156    }
1157
1158    #[test]
1159    fn config_uds_path_custom_port() {
1160        let cfg = Config::from_url("postgres://user@localhost:5433/db?host=/tmp").unwrap();
1161        assert_eq!(cfg.uds_path(), "/tmp/.s.PGSQL.5433");
1162    }
1163
1164    // ===================================================================
1165    // UDS (Unix domain socket) tests
1166    // ===================================================================
1167
1168    #[test]
1169    fn config_host_is_uds_absolute_path() {
1170        let cfg = Config {
1171            host: "/tmp".into(),
1172            port: 5432,
1173            user: "user".into(),
1174            password: "".into(),
1175            database: "db".into(),
1176            ssl: SslMode::Disable,
1177            statement_timeout_secs: 30,
1178            statement_cache_mode: StatementCacheMode::Named,
1179            ssl_root_cert: None,
1180            ssl_cert: None,
1181            ssl_key: None,
1182        };
1183        assert!(cfg.host_is_uds());
1184        assert_eq!(cfg.uds_path(), "/tmp/.s.PGSQL.5432");
1185    }
1186
1187    #[test]
1188    fn config_host_is_uds_var_run() {
1189        let cfg = Config {
1190            host: "/var/run/postgresql".into(),
1191            port: 5433,
1192            user: "user".into(),
1193            password: "".into(),
1194            database: "db".into(),
1195            ssl: SslMode::Disable,
1196            statement_timeout_secs: 30,
1197            statement_cache_mode: StatementCacheMode::Named,
1198            ssl_root_cert: None,
1199            ssl_cert: None,
1200            ssl_key: None,
1201        };
1202        assert!(cfg.host_is_uds());
1203        assert_eq!(cfg.uds_path(), "/var/run/postgresql/.s.PGSQL.5433");
1204    }
1205
1206    #[test]
1207    fn config_host_is_not_uds_for_hostname() {
1208        let cfg = Config {
1209            host: "localhost".into(),
1210            port: 5432,
1211            user: "user".into(),
1212            password: "".into(),
1213            database: "db".into(),
1214            ssl: SslMode::Disable,
1215            statement_timeout_secs: 30,
1216            statement_cache_mode: StatementCacheMode::Named,
1217            ssl_root_cert: None,
1218            ssl_cert: None,
1219            ssl_key: None,
1220        };
1221        assert!(!cfg.host_is_uds());
1222    }
1223
1224    #[test]
1225    fn config_host_is_not_uds_for_ip() {
1226        let cfg = Config {
1227            host: "127.0.0.1".into(),
1228            port: 5432,
1229            user: "user".into(),
1230            password: "".into(),
1231            database: "db".into(),
1232            ssl: SslMode::Disable,
1233            statement_timeout_secs: 30,
1234            statement_cache_mode: StatementCacheMode::Named,
1235            ssl_root_cert: None,
1236            ssl_cert: None,
1237            ssl_key: None,
1238        };
1239        assert!(!cfg.host_is_uds());
1240    }
1241
1242    #[test]
1243    fn config_parse_uds_host_query_param() {
1244        let cfg = Config::from_url("postgres://user@localhost/mydb?host=/tmp").unwrap();
1245        assert_eq!(cfg.host, "/tmp");
1246        assert!(cfg.host_is_uds());
1247        assert_eq!(cfg.uds_path(), "/tmp/.s.PGSQL.5432");
1248        assert_eq!(cfg.database, "mydb");
1249        assert_eq!(cfg.user, "user");
1250    }
1251
1252    #[test]
1253    fn config_parse_uds_host_query_param_custom_port() {
1254        let cfg = Config::from_url("postgres://user@localhost:5433/mydb?host=/var/run/postgresql")
1255            .unwrap();
1256        assert_eq!(cfg.host, "/var/run/postgresql");
1257        assert_eq!(cfg.port, 5433);
1258        assert_eq!(cfg.uds_path(), "/var/run/postgresql/.s.PGSQL.5433");
1259    }
1260
1261    #[test]
1262    fn config_parse_uds_host_with_other_params() {
1263        let cfg = Config::from_url(
1264            "postgres://user@localhost/db?host=/tmp&sslmode=disable&statement_timeout=60",
1265        )
1266        .unwrap();
1267        assert_eq!(cfg.host, "/tmp");
1268        assert!(cfg.host_is_uds());
1269        assert_eq!(cfg.ssl, SslMode::Disable);
1270        assert_eq!(cfg.statement_timeout_secs, 60);
1271    }
1272
1273    #[test]
1274    fn config_parse_uds_host_percent_encoded() {
1275        // %2F = '/'
1276        let cfg = Config::from_url("postgres://user@localhost/db?host=%2Ftmp").unwrap();
1277        assert_eq!(cfg.host, "/tmp");
1278        assert!(cfg.host_is_uds());
1279    }
1280
1281    #[test]
1282    fn config_parse_tcp_host_not_overridden_without_param() {
1283        // No ?host= param: hostname from URL is used (TCP)
1284        let cfg = Config::from_url("postgres://user@myserver/db").unwrap();
1285        assert_eq!(cfg.host, "myserver");
1286        assert!(!cfg.host_is_uds());
1287    }
1288
1289    #[test]
1290    fn config_parse_uds_host_overrides_url_hostname() {
1291        // ?host= overrides even an explicit hostname
1292        let cfg = Config::from_url("postgres://user@db.example.com/mydb?host=/var/run/postgresql")
1293            .unwrap();
1294        assert_eq!(cfg.host, "/var/run/postgresql");
1295        assert!(cfg.host_is_uds());
1296    }
1297
1298    #[test]
1299    fn config_parse_uds_empty_url_host() {
1300        // postgres:///dbname?host=/tmp -- empty hostname before /, host from param
1301        let cfg = Config::from_url("postgres://user@/mydb?host=/tmp").unwrap();
1302        assert_eq!(cfg.host, "/tmp");
1303        assert!(cfg.host_is_uds());
1304        assert_eq!(cfg.database, "mydb");
1305    }
1306
1307    // ===================================================================
1308    // url_decode tests
1309    // ===================================================================
1310
1311    #[test]
1312    fn url_decode_works() {
1313        assert_eq!(url_decode("hello%20world").unwrap(), "hello world");
1314        assert_eq!(url_decode("no%20escape").unwrap(), "no escape");
1315        assert_eq!(url_decode("plain").unwrap(), "plain");
1316        assert_eq!(url_decode("a%40b").unwrap(), "a@b");
1317    }
1318
1319    #[test]
1320    fn url_decode_malformed_percent_trailing() {
1321        // Truncated percent sequence at end of string
1322        let result = url_decode("abc%2");
1323        assert!(result.is_err(), "truncated %2 should error");
1324    }
1325
1326    #[test]
1327    fn url_decode_malformed_percent_no_digits() {
1328        // % followed by no digits at all
1329        let result = url_decode("abc%");
1330        assert!(result.is_err(), "bare % at end should error");
1331    }
1332
1333    #[test]
1334    fn url_decode_invalid_hex_digit() {
1335        // %GG -- 'G' is not a valid hex digit
1336        let result = url_decode("abc%GG");
1337        assert!(result.is_err(), "%GG should error");
1338    }
1339
1340    #[test]
1341    fn url_decode_invalid_hex_second_digit() {
1342        // %2Z -- 'Z' is not a valid hex digit
1343        let result = url_decode("abc%2Z");
1344        assert!(result.is_err(), "%2Z should error");
1345    }
1346
1347    /// url_decode with invalid UTF-8 from percent-decoded bytes
1348    #[test]
1349    fn url_decode_invalid_utf8_percent() {
1350        // %80%81 are not valid UTF-8 start bytes
1351        let result = url_decode("%80%81");
1352        assert!(result.is_err(), "invalid UTF-8 bytes should error");
1353    }
1354
1355    /// url_decode with percent-encoded chars in all positions
1356    #[test]
1357    fn url_decode_percent_everywhere() {
1358        assert_eq!(url_decode("%41%42%43").unwrap(), "ABC");
1359        assert_eq!(url_decode("%61").unwrap(), "a");
1360        assert_eq!(url_decode("x%2Fy%2Fz").unwrap(), "x/y/z");
1361    }
1362
1363    /// url_decode with bare percent at various positions
1364    #[test]
1365    fn url_decode_bare_percent_middle() {
1366        assert!(url_decode("a%b").is_err(), "bare % in middle should error");
1367    }
1368
1369    /// T-02: url_decode with multi-byte UTF-8 (%C3%A9 -> e with acute)
1370    #[test]
1371    fn url_decode_multibyte_utf8() {
1372        let result = url_decode("caf%C3%A9").unwrap();
1373        assert_eq!(result, "caf\u{00e9}"); // cafe with accent
1374    }
1375
1376    // #73: url_decode with invalid percent (%ZZ)
1377    #[test]
1378    fn url_decode_invalid_percent_zz() {
1379        let result = url_decode("abc%ZZ");
1380        assert!(result.is_err(), "%ZZ should error");
1381    }
1382
1383    // #74: url_decode with truncated percent (trailing %)
1384    #[test]
1385    fn url_decode_truncated_percent_trailing() {
1386        let result = url_decode("abc%");
1387        assert!(result.is_err(), "trailing % should error");
1388    }
1389
1390    // #75: url_decode producing invalid UTF-8
1391    #[test]
1392    fn url_decode_invalid_utf8() {
1393        // 0x80 alone is not valid UTF-8
1394        let result = url_decode("%80");
1395        assert!(result.is_err(), "invalid UTF-8 should error");
1396    }
1397
1398    #[test]
1399    fn url_decode_empty_string() {
1400        assert_eq!(url_decode("").unwrap(), "");
1401    }
1402
1403    #[test]
1404    fn url_decode_no_encoding() {
1405        assert_eq!(url_decode("hello").unwrap(), "hello");
1406    }
1407
1408    #[test]
1409    fn url_decode_all_ascii_hex() {
1410        // Uppercase hex
1411        assert_eq!(url_decode("%2F").unwrap(), "/");
1412        assert_eq!(url_decode("%2f").unwrap(), "/");
1413    }
1414
1415    // --- Config URL edge cases ---
1416
1417    // Unicode password: Cyrillic пароль (percent-encoded)
1418    #[test]
1419    fn config_unicode_password() {
1420        // "пароль" in UTF-8 is D0 BF D0 B0 D1 80 D0 BE D0 BB D1 8C
1421        let cfg =
1422            Config::from_url("postgres://user:%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8C@localhost/db")
1423                .unwrap();
1424        assert_eq!(cfg.user, "user");
1425        assert_eq!(
1426            cfg.password,
1427            "\u{043F}\u{0430}\u{0440}\u{043E}\u{043B}\u{044C}"
1428        ); // пароль
1429        assert_eq!(cfg.host, "localhost");
1430        assert_eq!(cfg.database, "db");
1431    }
1432
1433    // Port 0 (edge of u16 range)
1434    #[test]
1435    fn config_port_zero() {
1436        let cfg = Config::from_url("postgres://user:pass@localhost:0/db").unwrap();
1437        assert_eq!(cfg.port, 0);
1438    }
1439
1440    // Port 65535 (max u16)
1441    #[test]
1442    fn config_port_max() {
1443        let cfg = Config::from_url("postgres://user:pass@localhost:65535/db").unwrap();
1444        assert_eq!(cfg.port, 65535);
1445    }
1446
1447    // Port 65536 (overflow, should error)
1448    #[test]
1449    fn config_port_overflow() {
1450        let result = Config::from_url("postgres://user:pass@localhost:65536/db");
1451        assert!(result.is_err(), "port 65536 exceeds u16 max");
1452    }
1453
1454    // Unknown query parameter should be silently ignored
1455    #[test]
1456    fn config_unknown_param_ignored() {
1457        let cfg = Config::from_url(
1458            "postgres://user:pass@localhost/db?application_name=myapp&connect_timeout=10",
1459        )
1460        .unwrap();
1461        // Should parse without error, ignoring unknown params
1462        assert_eq!(cfg.user, "user");
1463        assert_eq!(cfg.host, "localhost");
1464        assert_eq!(cfg.database, "db");
1465        // Default values for known params should be unaffected
1466        assert_eq!(cfg.statement_timeout_secs, 30);
1467        assert_eq!(cfg.ssl, SslMode::Prefer);
1468    }
1469
1470    // Double percent encoding: %2525 should decode to %25
1471    #[test]
1472    fn url_decode_double_percent_encoding() {
1473        // %25 decodes to '%', so %2525 decodes to '%25'
1474        assert_eq!(url_decode("%2525").unwrap(), "%25");
1475    }
1476
1477    // URL with empty password field (explicit colon, empty password)
1478    #[test]
1479    fn config_explicit_empty_password() {
1480        let cfg = Config::from_url("postgres://user:@localhost/db").unwrap();
1481        assert_eq!(cfg.user, "user");
1482        assert_eq!(cfg.password, "");
1483    }
1484
1485    // URL with special characters in user and database
1486    #[test]
1487    fn config_special_chars_in_user() {
1488        let cfg = Config::from_url("postgres://my%2Fuser:pass@localhost/my%2Fdb").unwrap();
1489        assert_eq!(cfg.user, "my/user");
1490        assert_eq!(cfg.database, "my/db");
1491    }
1492
1493    // url_decode with plus sign (should be literal, not space -- this is not form encoding)
1494    #[test]
1495    fn url_decode_plus_is_literal() {
1496        assert_eq!(url_decode("a+b").unwrap(), "a+b");
1497    }
1498
1499    // Config with only host, port, and user (minimal valid URL)
1500    #[test]
1501    fn config_minimal_valid_url() {
1502        let cfg = Config::from_url("postgres://user@localhost/db").unwrap();
1503        assert_eq!(cfg.user, "user");
1504        assert_eq!(cfg.password, "");
1505        assert_eq!(cfg.host, "localhost");
1506        assert_eq!(cfg.port, 5432);
1507        assert_eq!(cfg.database, "db");
1508    }
1509
1510    // Multiple ampersands and empty param segments
1511    #[test]
1512    fn config_empty_param_segments() {
1513        let cfg =
1514            Config::from_url("postgres://user:pass@localhost/db?&&statement_timeout=60&&").unwrap();
1515        assert_eq!(cfg.statement_timeout_secs, 60);
1516    }
1517
1518    // ===================================================================
1519    // hash_sql tests
1520    // ===================================================================
1521
1522    #[test]
1523    fn hash_sql_deterministic() {
1524        let h1 = hash_sql("SELECT 1");
1525        let h2 = hash_sql("SELECT 1");
1526        assert_eq!(h1, h2);
1527    }
1528
1529    #[test]
1530    fn hash_sql_different_queries() {
1531        let h1 = hash_sql("SELECT 1");
1532        let h2 = hash_sql("SELECT 2");
1533        assert_ne!(h1, h2);
1534    }
1535
1536    #[test]
1537    fn hash_sql_empty() {
1538        let _h = hash_sql(""); // should not panic
1539    }
1540
1541    #[test]
1542    fn hash_sql_whitespace_only() {
1543        let h = hash_sql("   ");
1544        assert_ne!(h, hash_sql(""));
1545    }
1546
1547    #[test]
1548    fn hash_sql_very_long() {
1549        let long_sql = "SELECT ".to_string() + &"x".repeat(10_000);
1550        let h = hash_sql(&long_sql);
1551        assert_eq!(h, hash_sql(&long_sql));
1552    }
1553
1554    #[test]
1555    fn hash_sql_unicode() {
1556        let h = hash_sql("SELECT '\u{1F600}'");
1557        assert_ne!(h, hash_sql("SELECT 'x'"));
1558    }
1559
1560    // ===================================================================
1561    // Notification tests
1562    // ===================================================================
1563
1564    #[test]
1565    fn notification_struct_fields() {
1566        let n = Notification {
1567            pid: 42,
1568            channel: "test_chan".to_owned(),
1569            payload: "hello".to_owned(),
1570        };
1571        assert_eq!(n.pid, 42);
1572        assert_eq!(n.channel, "test_chan");
1573        assert_eq!(n.payload, "hello");
1574    }
1575
1576    #[test]
1577    fn notification_clone() {
1578        let n = Notification {
1579            pid: 1,
1580            channel: "c".to_owned(),
1581            payload: "p".to_owned(),
1582        };
1583        let n2 = n.clone();
1584        assert_eq!(n2.pid, 1);
1585        assert_eq!(n2.channel, "c");
1586    }
1587
1588    #[test]
1589    fn notification_debug() {
1590        let n = Notification {
1591            pid: 1,
1592            channel: "c".to_owned(),
1593            payload: "p".to_owned(),
1594        };
1595        let dbg = format!("{n:?}");
1596        assert!(dbg.contains("Notification"));
1597    }
1598
1599    // ===================================================================
1600    // QueryResult tests
1601    // ===================================================================
1602
1603    #[test]
1604    fn query_result_empty() {
1605        let result = QueryResult {
1606            all_col_offsets: vec![],
1607            num_cols: 0,
1608            columns: Arc::from(Vec::new()),
1609            affected_rows: 0,
1610            data_buf: None,
1611        };
1612        assert!(result.is_empty());
1613        assert_eq!(result.len(), 0);
1614    }
1615
1616    #[test]
1617    fn query_result_from_parts() {
1618        let result = QueryResult::from_parts(vec![(0, 4), (0, -1)], 2, Arc::from(Vec::new()), 5);
1619        assert_eq!(result.len(), 1);
1620        assert_eq!(result.num_cols, 2);
1621        assert_eq!(result.affected_rows, 5);
1622    }
1623
1624    #[test]
1625    fn query_result_affected_rows() {
1626        let result = QueryResult {
1627            all_col_offsets: vec![],
1628            num_cols: 0,
1629            columns: Arc::from(Vec::new()),
1630            affected_rows: 42,
1631            data_buf: None,
1632        };
1633        assert_eq!(result.affected_rows, 42);
1634        assert!(result.is_empty());
1635    }
1636
1637    // ===================================================================
1638    // PgDataRow tests
1639    // ===================================================================
1640
1641    /// Build a DataRow payload: [i16 num_cols] ([i32 len] [bytes])...
1642    /// len = -1 for NULL
1643    fn make_data_row(columns: &[Option<&[u8]>]) -> Vec<u8> {
1644        let mut buf = Vec::new();
1645        buf.extend_from_slice(&(columns.len() as i16).to_be_bytes());
1646        for col in columns {
1647            match col {
1648                Some(data) => {
1649                    buf.extend_from_slice(&(data.len() as i32).to_be_bytes());
1650                    buf.extend_from_slice(data);
1651                }
1652                None => {
1653                    buf.extend_from_slice(&(-1i32).to_be_bytes());
1654                }
1655            }
1656        }
1657        buf
1658    }
1659
1660    #[test]
1661    fn pg_data_row_get_i32() {
1662        let data = make_data_row(&[Some(&42i32.to_be_bytes())]);
1663        let row = PgDataRow::new(&data).unwrap();
1664        assert_eq!(row.get_i32(0), Some(42));
1665        assert_eq!(row.column_count(), 1);
1666    }
1667
1668    #[test]
1669    fn pg_data_row_get_i64() {
1670        let data = make_data_row(&[Some(&12345i64.to_be_bytes())]);
1671        let row = PgDataRow::new(&data).unwrap();
1672        assert_eq!(row.get_i64(0), Some(12345));
1673    }
1674
1675    #[test]
1676    fn pg_data_row_get_str() {
1677        let data = make_data_row(&[Some(b"hello")]);
1678        let row = PgDataRow::new(&data).unwrap();
1679        assert_eq!(row.get_str(0), Some("hello"));
1680    }
1681
1682    #[test]
1683    fn pg_data_row_get_bytes() {
1684        let data = make_data_row(&[Some(&[0xDE, 0xAD, 0xBE, 0xEF])]);
1685        let row = PgDataRow::new(&data).unwrap();
1686        assert_eq!(row.get_bytes(0), Some(&[0xDE, 0xAD, 0xBE, 0xEF][..]));
1687    }
1688
1689    #[test]
1690    fn pg_data_row_get_bool() {
1691        let data = make_data_row(&[Some(&[1u8])]);
1692        let row = PgDataRow::new(&data).unwrap();
1693        assert_eq!(row.get_bool(0), Some(true));
1694
1695        let data = make_data_row(&[Some(&[0u8])]);
1696        let row = PgDataRow::new(&data).unwrap();
1697        assert_eq!(row.get_bool(0), Some(false));
1698    }
1699
1700    #[test]
1701    fn pg_data_row_get_f64() {
1702        let data = make_data_row(&[Some(&3.14f64.to_be_bytes())]);
1703        let row = PgDataRow::new(&data).unwrap();
1704        assert!((row.get_f64(0).unwrap() - 3.14).abs() < 1e-10);
1705    }
1706
1707    #[test]
1708    fn pg_data_row_null_column() {
1709        let data = make_data_row(&[None]);
1710        let row = PgDataRow::new(&data).unwrap();
1711        assert!(row.is_null(0));
1712        assert_eq!(row.get_i32(0), None);
1713        assert_eq!(row.get_str(0), None);
1714    }
1715
1716    #[test]
1717    fn pg_data_row_multiple_columns() {
1718        let data = make_data_row(&[
1719            Some(&42i32.to_be_bytes()),
1720            Some(b"alice"),
1721            Some(b"alice@example.com"),
1722            Some(&[1u8]),
1723            Some(&3.14f64.to_be_bytes()),
1724        ]);
1725        let row = PgDataRow::new(&data).unwrap();
1726        assert_eq!(row.column_count(), 5);
1727        assert_eq!(row.get_i32(0), Some(42));
1728        assert_eq!(row.get_str(1), Some("alice"));
1729        assert_eq!(row.get_str(2), Some("alice@example.com"));
1730        assert_eq!(row.get_bool(3), Some(true));
1731        assert!((row.get_f64(4).unwrap() - 3.14).abs() < 1e-10);
1732    }
1733
1734    #[test]
1735    fn pg_data_row_mixed_null() {
1736        let data = make_data_row(&[Some(&42i32.to_be_bytes()), None, Some(b"text")]);
1737        let row = PgDataRow::new(&data).unwrap();
1738        assert_eq!(row.get_i32(0), Some(42));
1739        assert!(row.is_null(1));
1740        assert_eq!(row.get_str(1), None);
1741        assert_eq!(row.get_str(2), Some("text"));
1742    }
1743
1744    #[test]
1745    fn pg_data_row_empty() {
1746        let data = make_data_row(&[]);
1747        let row = PgDataRow::new(&data).unwrap();
1748        assert_eq!(row.column_count(), 0);
1749    }
1750
1751    #[test]
1752    fn pg_data_row_too_short() {
1753        let data = vec![0u8]; // only 1 byte, need at least 2
1754        assert!(PgDataRow::new(&data).is_err());
1755    }
1756
1757    #[test]
1758    fn pg_data_row_truncated() {
1759        // Declare 2 columns but only include 1
1760        let mut data = Vec::new();
1761        data.extend_from_slice(&2i16.to_be_bytes());
1762        data.extend_from_slice(&4i32.to_be_bytes());
1763        data.extend_from_slice(&42i32.to_be_bytes());
1764        // Missing second column
1765        assert!(PgDataRow::new(&data).is_err());
1766    }
1767
1768    #[test]
1769    fn pg_data_row_get_i16() {
1770        let data = make_data_row(&[Some(&7i16.to_be_bytes())]);
1771        let row = PgDataRow::new(&data).unwrap();
1772        assert_eq!(row.get_i16(0), Some(7));
1773    }
1774
1775    #[test]
1776    fn pg_data_row_get_f32() {
1777        let data = make_data_row(&[Some(&2.5f32.to_be_bytes())]);
1778        let row = PgDataRow::new(&data).unwrap();
1779        assert!((row.get_f32(0).unwrap() - 2.5).abs() < 1e-6);
1780    }
1781
1782    #[test]
1783    fn pg_data_row_get_raw_null() {
1784        let data = make_data_row(&[None]);
1785        let row = PgDataRow::new(&data).unwrap();
1786        assert_eq!(row.get_raw(0), None);
1787    }
1788
1789    #[test]
1790    fn pg_data_row_get_raw_data() {
1791        let data = make_data_row(&[Some(&[1, 2, 3])]);
1792        let row = PgDataRow::new(&data).unwrap();
1793        assert_eq!(row.get_raw(0), Some(&[1u8, 2, 3][..]));
1794    }
1795
1796    #[test]
1797    fn pg_data_row_stack_alloc_16_columns() {
1798        // SmallVec<16> should not heap-allocate for <= 16 columns
1799        let cols: Vec<Option<&[u8]>> = (0..16).map(|_| Some(&[0u8][..])).collect();
1800        let data = make_data_row(&cols);
1801        let row = PgDataRow::new(&data).unwrap();
1802        assert_eq!(row.column_count(), 16);
1803        // All columns should be accessible
1804        for i in 0..16 {
1805            assert_eq!(row.get_raw(i), Some(&[0u8][..]));
1806        }
1807    }
1808
1809    // --- Inline sequential decode tests (validates the raw-bytes pattern) ---
1810
1811    /// Validate inline sequential decode of a 5-column DataRow
1812    /// (i32, str, str, bool, f64) -- the same pattern the generated code uses.
1813    #[test]
1814    fn inline_sequential_decode_five_columns() {
1815        let data = make_data_row(&[
1816            Some(&42i32.to_be_bytes()),
1817            Some(b"alice"),
1818            Some(b"alice@example.com"),
1819            Some(&[1u8]),
1820            Some(&3.14f64.to_be_bytes()),
1821        ]);
1822
1823        // Simulate generated inline decode
1824        let mut pos: usize = 2; // skip i16 num_cols
1825
1826        // Column 0: i32
1827        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1828        pos += 4;
1829        assert_eq!(len, 4);
1830        let id = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1831        pos += len as usize;
1832        assert_eq!(id, 42);
1833
1834        // Column 1: str
1835        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1836        pos += 4;
1837        assert_eq!(len, 5);
1838        let name = std::str::from_utf8(&data[pos..pos + len as usize]).unwrap();
1839        pos += len as usize;
1840        assert_eq!(name, "alice");
1841
1842        // Column 2: str
1843        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1844        pos += 4;
1845        let email = std::str::from_utf8(&data[pos..pos + len as usize]).unwrap();
1846        pos += len as usize;
1847        assert_eq!(email, "alice@example.com");
1848
1849        // Column 3: bool
1850        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1851        pos += 4;
1852        assert_eq!(len, 1);
1853        let active = data[pos] != 0;
1854        pos += len as usize;
1855        assert!(active);
1856
1857        // Column 4: f64
1858        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1859        pos += 4;
1860        assert_eq!(len, 8);
1861        let score = f64::from_be_bytes([
1862            data[pos],
1863            data[pos + 1],
1864            data[pos + 2],
1865            data[pos + 3],
1866            data[pos + 4],
1867            data[pos + 5],
1868            data[pos + 6],
1869            data[pos + 7],
1870        ]);
1871        pos += len as usize;
1872        assert!((score - 3.14).abs() < 1e-10);
1873        assert_eq!(pos, data.len());
1874    }
1875
1876    /// Validate inline decode with NULL columns.
1877    #[test]
1878    fn inline_sequential_decode_with_nulls() {
1879        let data = make_data_row(&[
1880            Some(&42i32.to_be_bytes()),
1881            None, // NULL name
1882            Some(b"text"),
1883        ]);
1884
1885        let mut pos: usize = 2;
1886
1887        // Column 0: i32 NOT NULL
1888        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1889        pos += 4;
1890        let id = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1891        pos += len as usize;
1892        assert_eq!(id, 42);
1893
1894        // Column 1: str NULLABLE -> None
1895        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1896        pos += 4;
1897        let name: Option<&str> = if len < 0 {
1898            None
1899        } else {
1900            let s = std::str::from_utf8(&data[pos..pos + len as usize]).unwrap();
1901            pos += len as usize;
1902            Some(s)
1903        };
1904        assert!(name.is_none());
1905
1906        // Column 2: str NOT NULL
1907        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1908        pos += 4;
1909        let txt = std::str::from_utf8(&data[pos..pos + len as usize]).unwrap();
1910        pos += len as usize;
1911        assert_eq!(txt, "text");
1912        assert_eq!(pos, data.len());
1913    }
1914
1915    /// Validate inline decode with all supported scalar types.
1916    #[test]
1917    fn inline_sequential_decode_all_scalar_types() {
1918        let data = make_data_row(&[
1919            Some(&[1u8]),                  // bool
1920            Some(&7i16.to_be_bytes()),     // i16
1921            Some(&42i32.to_be_bytes()),    // i32
1922            Some(&12345i64.to_be_bytes()), // i64
1923            Some(&2.5f32.to_be_bytes()),   // f32
1924            Some(&3.14f64.to_be_bytes()),  // f64
1925        ]);
1926
1927        let mut pos: usize = 2;
1928
1929        // bool
1930        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1931        pos += 4;
1932        let v_bool = data[pos] != 0;
1933        pos += len as usize;
1934        assert!(v_bool);
1935
1936        // i16
1937        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1938        pos += 4;
1939        let v_i16 = i16::from_be_bytes([data[pos], data[pos + 1]]);
1940        pos += len as usize;
1941        assert_eq!(v_i16, 7);
1942
1943        // i32
1944        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1945        pos += 4;
1946        let v_i32 = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1947        pos += len as usize;
1948        assert_eq!(v_i32, 42);
1949
1950        // i64
1951        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1952        pos += 4;
1953        let v_i64 = i64::from_be_bytes([
1954            data[pos],
1955            data[pos + 1],
1956            data[pos + 2],
1957            data[pos + 3],
1958            data[pos + 4],
1959            data[pos + 5],
1960            data[pos + 6],
1961            data[pos + 7],
1962        ]);
1963        pos += len as usize;
1964        assert_eq!(v_i64, 12345);
1965
1966        // f32
1967        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1968        pos += 4;
1969        let v_f32 = f32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1970        pos += len as usize;
1971        assert!((v_f32 - 2.5).abs() < 1e-6);
1972
1973        // f64
1974        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
1975        pos += 4;
1976        let v_f64 = f64::from_be_bytes([
1977            data[pos],
1978            data[pos + 1],
1979            data[pos + 2],
1980            data[pos + 3],
1981            data[pos + 4],
1982            data[pos + 5],
1983            data[pos + 6],
1984            data[pos + 7],
1985        ]);
1986        pos += len as usize;
1987        assert!((v_f64 - 3.14).abs() < 1e-10);
1988        assert_eq!(pos, data.len());
1989    }
1990
1991    /// Validate PgDataRow::new is public (callable from external code).
1992    #[test]
1993    fn pg_data_row_new_is_public() {
1994        let data = make_data_row(&[Some(&42i32.to_be_bytes())]);
1995        // This compiles because PgDataRow::new is pub.
1996        let row = PgDataRow::new(&data).unwrap();
1997        assert_eq!(row.get_i32(0), Some(42));
1998    }
1999
2000    /// Inline decode produces identical results to PgDataRow for mixed data.
2001    #[test]
2002    fn inline_decode_matches_pgdatarow() {
2003        let data = make_data_row(&[
2004            Some(&99i32.to_be_bytes()),
2005            Some(b"hello world"),
2006            None,
2007            Some(&[0u8]),
2008            Some(&1.23f64.to_be_bytes()),
2009        ]);
2010
2011        // PgDataRow results
2012        let row = PgDataRow::new(&data).unwrap();
2013        let dr_i32 = row.get_i32(0);
2014        let dr_str = row.get_str(1);
2015        let dr_null = row.get_str(2);
2016        let dr_bool = row.get_bool(3);
2017        let dr_f64 = row.get_f64(4);
2018
2019        // Inline results
2020        let mut pos: usize = 2;
2021
2022        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2023        pos += 4;
2024        let in_i32 = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2025        pos += len as usize;
2026
2027        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2028        pos += 4;
2029        let in_str = std::str::from_utf8(&data[pos..pos + len as usize]).unwrap();
2030        pos += len as usize;
2031
2032        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2033        pos += 4;
2034        let in_null: Option<&str> = if len < 0 { None } else { unreachable!() };
2035
2036        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2037        pos += 4;
2038        let in_bool = data[pos] != 0;
2039        pos += len as usize;
2040
2041        let len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
2042        pos += 4;
2043        let in_f64 = f64::from_be_bytes([
2044            data[pos],
2045            data[pos + 1],
2046            data[pos + 2],
2047            data[pos + 3],
2048            data[pos + 4],
2049            data[pos + 5],
2050            data[pos + 6],
2051            data[pos + 7],
2052        ]);
2053        pos += len as usize;
2054
2055        // Both paths must produce identical results
2056        assert_eq!(dr_i32, Some(in_i32));
2057        assert_eq!(dr_str, Some(in_str));
2058        assert_eq!(dr_null, in_null);
2059        assert_eq!(dr_bool, Some(in_bool));
2060        assert!((dr_f64.unwrap() - in_f64).abs() < 1e-15);
2061        assert_eq!(pos, data.len());
2062    }
2063
2064    // ===================================================================
2065    // PgDataRow -- comprehensive tests
2066    // ===================================================================
2067
2068    #[test]
2069    fn pg_data_row_all_null_columns() {
2070        let data = make_data_row(&[None, None, None, None, None]);
2071        let row = PgDataRow::new(&data).unwrap();
2072        assert_eq!(row.column_count(), 5);
2073        for i in 0..5 {
2074            assert!(row.is_null(i), "column {i} should be null");
2075            assert_eq!(row.get_raw(i), None);
2076            assert_eq!(row.get_i32(i), None);
2077            assert_eq!(row.get_i64(i), None);
2078            assert_eq!(row.get_str(i), None);
2079            assert_eq!(row.get_bool(i), None);
2080            assert_eq!(row.get_f64(i), None);
2081        }
2082    }
2083
2084    #[test]
2085    fn pg_data_row_very_long_text() {
2086        let long_text = "x".repeat(2048);
2087        let data = make_data_row(&[Some(long_text.as_bytes())]);
2088        let row = PgDataRow::new(&data).unwrap();
2089        assert_eq!(row.get_str(0), Some(long_text.as_str()));
2090    }
2091
2092    #[test]
2093    fn pg_data_row_empty_text() {
2094        let data = make_data_row(&[Some(b"")]);
2095        let row = PgDataRow::new(&data).unwrap();
2096        assert!(!row.is_null(0));
2097        assert_eq!(row.get_str(0), Some(""));
2098        assert_eq!(row.get_bytes(0), Some(&[][..]));
2099    }
2100
2101    #[test]
2102    fn pg_data_row_20_columns_exceeds_inline() {
2103        let col_data: Vec<[u8; 4]> = (0..20).map(|i: i32| i.to_be_bytes()).collect();
2104        let cols: Vec<Option<&[u8]>> = col_data.iter().map(|b| Some(b.as_slice())).collect();
2105        let data = make_data_row(&cols);
2106        let row = PgDataRow::new(&data).unwrap();
2107        assert_eq!(row.column_count(), 20);
2108        for i in 0..20 {
2109            assert_eq!(row.get_i32(i), Some(i as i32));
2110        }
2111    }
2112
2113    #[test]
2114    fn pg_data_row_is_null_each_position() {
2115        // 3 columns: data, null, data
2116        let data = make_data_row(&[Some(&1i32.to_be_bytes()), None, Some(&3i32.to_be_bytes())]);
2117        let row = PgDataRow::new(&data).unwrap();
2118        assert!(!row.is_null(0));
2119        assert!(row.is_null(1));
2120        assert!(!row.is_null(2));
2121    }
2122
2123    #[test]
2124    fn pg_data_row_negative_column_count() {
2125        let data = (-1i16).to_be_bytes();
2126        assert!(PgDataRow::new(&data).is_err());
2127    }
2128
2129    #[test]
2130    fn pg_data_row_get_str_invalid_utf8() {
2131        let invalid_utf8 = &[0xFF, 0xFE, 0x80];
2132        let data = make_data_row(&[Some(invalid_utf8)]);
2133        let row = PgDataRow::new(&data).unwrap();
2134        // get_str returns None for invalid UTF-8, but get_bytes returns the raw data
2135        assert_eq!(row.get_str(0), None);
2136        assert_eq!(row.get_bytes(0), Some(&[0xFF, 0xFE, 0x80][..]));
2137    }
2138
2139    #[test]
2140    fn pg_data_row_get_i32_wrong_length() {
2141        // i32 needs exactly 4 bytes; give it 2
2142        let data = make_data_row(&[Some(&7i16.to_be_bytes())]);
2143        let row = PgDataRow::new(&data).unwrap();
2144        assert_eq!(row.get_i32(0), None); // 2 bytes != 4 bytes
2145        assert_eq!(row.get_i16(0), Some(7)); // but i16 works
2146    }
2147
2148    #[test]
2149    fn pg_data_row_get_i64_wrong_length() {
2150        // i64 needs 8 bytes; give it 4
2151        let data = make_data_row(&[Some(&42i32.to_be_bytes())]);
2152        let row = PgDataRow::new(&data).unwrap();
2153        assert_eq!(row.get_i64(0), None);
2154    }
2155
2156    #[test]
2157    fn pg_data_row_get_f64_wrong_length() {
2158        let data = make_data_row(&[Some(&2.5f32.to_be_bytes())]);
2159        let row = PgDataRow::new(&data).unwrap();
2160        assert_eq!(row.get_f64(0), None); // 4 bytes != 8 bytes
2161    }
2162
2163    #[test]
2164    fn pg_data_row_get_f32_wrong_length() {
2165        let data = make_data_row(&[Some(&3.14f64.to_be_bytes())]);
2166        let row = PgDataRow::new(&data).unwrap();
2167        assert_eq!(row.get_f32(0), None); // 8 bytes != 4 bytes
2168    }
2169
2170    #[test]
2171    fn pg_data_row_get_bool_wrong_length() {
2172        // bool needs 1 byte; give it 4
2173        let data = make_data_row(&[Some(&42i32.to_be_bytes())]);
2174        let row = PgDataRow::new(&data).unwrap();
2175        assert_eq!(row.get_bool(0), None);
2176    }
2177
2178    #[test]
2179    fn pg_data_row_unicode_text() {
2180        let texts = [
2181            "\u{1F600}\u{1F4A9}\u{1F680}", // emoji
2182            "\u{4e16}\u{754c}",            // CJK
2183            "\u{0645}\u{0631}\u{062D}",    // Arabic
2184            "\u{1F468}\u{200D}\u{1F469}",  // ZWJ
2185        ];
2186        for text in &texts {
2187            let data = make_data_row(&[Some(text.as_bytes())]);
2188            let row = PgDataRow::new(&data).unwrap();
2189            assert_eq!(row.get_str(0), Some(*text));
2190        }
2191    }
2192
2193    #[test]
2194    fn pg_data_row_i32_boundary_values() {
2195        for &val in &[i32::MIN, -1, 0, 1, i32::MAX] {
2196            let data = make_data_row(&[Some(&val.to_be_bytes())]);
2197            let row = PgDataRow::new(&data).unwrap();
2198            assert_eq!(row.get_i32(0), Some(val), "failed for {val}");
2199        }
2200    }
2201
2202    #[test]
2203    fn pg_data_row_i64_boundary_values() {
2204        for &val in &[i64::MIN, -1, 0, 1, i64::MAX] {
2205            let data = make_data_row(&[Some(&val.to_be_bytes())]);
2206            let row = PgDataRow::new(&data).unwrap();
2207            assert_eq!(row.get_i64(0), Some(val), "failed for {val}");
2208        }
2209    }
2210
2211    #[test]
2212    fn pg_data_row_f64_special_values() {
2213        let data = make_data_row(&[Some(&f64::INFINITY.to_be_bytes())]);
2214        let row = PgDataRow::new(&data).unwrap();
2215        assert_eq!(row.get_f64(0), Some(f64::INFINITY));
2216
2217        let data = make_data_row(&[Some(&f64::NEG_INFINITY.to_be_bytes())]);
2218        let row = PgDataRow::new(&data).unwrap();
2219        assert_eq!(row.get_f64(0), Some(f64::NEG_INFINITY));
2220
2221        let data = make_data_row(&[Some(&f64::NAN.to_be_bytes())]);
2222        let row = PgDataRow::new(&data).unwrap();
2223        assert!(row.get_f64(0).unwrap().is_nan());
2224    }
2225
2226    #[test]
2227    fn pg_data_row_f32_special_values() {
2228        let data = make_data_row(&[Some(&f32::INFINITY.to_be_bytes())]);
2229        let row = PgDataRow::new(&data).unwrap();
2230        assert_eq!(row.get_f32(0), Some(f32::INFINITY));
2231
2232        let data = make_data_row(&[Some(&f32::NAN.to_be_bytes())]);
2233        let row = PgDataRow::new(&data).unwrap();
2234        assert!(row.get_f32(0).unwrap().is_nan());
2235    }
2236
2237    #[test]
2238    fn pg_data_row_i16_boundary_values() {
2239        for &val in &[i16::MIN, -1, 0, 1, i16::MAX] {
2240            let data = make_data_row(&[Some(&val.to_be_bytes())]);
2241            let row = PgDataRow::new(&data).unwrap();
2242            assert_eq!(row.get_i16(0), Some(val));
2243        }
2244    }
2245
2246    mod proptest_fuzz {
2247        use super::*;
2248        use proptest::prelude::*;
2249
2250        proptest! {
2251            #[test]
2252            fn config_from_url_never_panics(url in ".*") {
2253                let _ = Config::from_url(&url);
2254            }
2255
2256            #[test]
2257            fn url_decode_never_panics(s in ".*") {
2258                let _ = url_decode(&s);
2259            }
2260
2261            #[test]
2262            fn pg_data_row_new_never_panics(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
2263                let _ = PgDataRow::new(&data);
2264            }
2265
2266            #[test]
2267            fn hash_sql_never_panics(sql in ".*") {
2268                let _ = hash_sql(&sql);
2269            }
2270        }
2271    }
2272}