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