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