Skip to main content

oracledb/
sql_convert.rs

1//! Typed conversions between Rust values and Oracle wire values.
2//!
3//! This module is the ergonomic read/write surface that makes the driver feel
4//! native to Rust:
5//!
6//! - [`FromSql`] converts a fetched [`QueryValue`] into a concrete Rust type
7//!   (`row.get::<i64>(0, 1)?`), so callers stop matching the full value enum
8//!   and unwrapping `Option`s by hand.
9//! - [`ToSql`] converts a Rust value into a [`BindValue`] for a placeholder
10//!   bind, and the [`params!`](crate::params) macro / tuple impls let
11//!   `(40, "alice")` and `params!{ ":id" => 40 }` flow straight into the
12//!   execute helpers.
13//!
14//! Core scalar conversions (`i64`/`i32`/`u32`/`f64`/`f32`/`bool`/`String`/
15//! `Vec<u8>`) are always available. Conversions for `chrono`, `uuid`,
16//! `serde_json`, `rust_decimal`, and vector element vectors (`Vec<f32>` /
17//! `Vec<f64>`) sit behind the matching crate feature so the default build pulls
18//! in no extra dependencies.
19//!
20//! # Lossless NUMBER
21//!
22//! Oracle NUMBER is carried losslessly as an inline `{ i128 coefficient, i16
23//! scale }` form (see [`oracledb_protocol::thin::OracleNumber`]); its canonical
24//! decimal text is synthesized on demand by a single shared formatter. The
25//! `rust_decimal::Decimal` conversion builds *directly* from the inline
26//! coefficient/scale when it fits, so a value round-trips *exactly* to the full
27//! precision `rust_decimal::Decimal` can represent (~28-29 significant digits) —
28//! no float rounding anywhere on the path. `i64`/`i128` reconstruct directly
29//! from the coefficient with no string parse. python-oracledb hands you a lossy
30//! `float` (~15-17 digits) unless you opt into `decimal.Decimal` per column. For
31//! values exceeding `Decimal`'s range, read the canonical text with
32//! [`QueryValue::as_number_text`](oracledb_protocol::thin::QueryValue::as_number_text)
33//! and bind it back as `BindValue::Number`, which carries Oracle's full digits.
34
35use std::borrow::Cow;
36
37use oracledb_protocol::sql;
38use oracledb_protocol::thin::{BindValue, ColumnMetadata, QueryResult, QueryValue};
39
40/// Why a typed [`FromSql`] conversion could not be performed.
41///
42/// Surfaced as [`crate::Error::Conversion`]. The variants distinguish the three
43/// failure shapes a caller actually wants to branch on: the cell was SQL
44/// `NULL`, the Oracle type does not map to the requested Rust type, or the
45/// value was the right type but out of the Rust type's range / unparseable.
46#[derive(Clone, Debug, Eq, PartialEq)]
47#[non_exhaustive]
48pub enum ConversionError {
49    /// The cell was SQL `NULL` but the requested type is not nullable. Convert
50    /// into `Option<T>` to accept nulls.
51    UnexpectedNull,
52    /// The Oracle value's variant has no conversion to the requested Rust type
53    /// (e.g. asking for `i64` from a `RAW` column).
54    TypeMismatch {
55        /// The Rust type that was requested.
56        expected: &'static str,
57        /// A short description of the Oracle value that was present.
58        found: &'static str,
59    },
60    /// The value was of a convertible variant but did not fit the target type
61    /// (out of range, non-integral where an integer was asked, bad UTF-8, an
62    /// unparseable NUMBER, a vector of the wrong element format, ...).
63    OutOfRange {
64        /// The Rust type that was requested.
65        expected: &'static str,
66        /// What went wrong.
67        detail: String,
68    },
69}
70
71impl std::fmt::Display for ConversionError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            ConversionError::UnexpectedNull => {
75                write!(f, "value is SQL NULL but the target type is not Option<_>")
76            }
77            ConversionError::TypeMismatch { expected, found } => {
78                write!(f, "cannot convert Oracle {found} into {expected}")
79            }
80            ConversionError::OutOfRange { expected, detail } => {
81                write!(f, "value does not fit {expected}: {detail}")
82            }
83        }
84    }
85}
86
87impl std::error::Error for ConversionError {}
88
89impl From<ConversionError> for crate::Error {
90    fn from(err: ConversionError) -> Self {
91        crate::Error::Conversion(err)
92    }
93}
94
95/// Why a bind payload could not be matched to a SQL statement before the wire
96/// round trip.
97#[derive(Clone, Debug, Eq, PartialEq)]
98#[non_exhaustive]
99pub enum BindError {
100    /// The SQL tokenizer could not safely scan placeholders.
101    Sql(sql::SqlError),
102    /// Positional bind count does not match the placeholders the statement uses.
103    PositionalCountMismatch { expected: usize, actual: usize },
104    /// A named bind placeholder in the SQL has no supplied value.
105    MissingNamedBind { name: String },
106    /// A supplied named bind is not referenced by the SQL.
107    ExtraNamedBind { name: String },
108    /// Execute-many bind rows are not rectangular.
109    BatchRowWidthMismatch {
110        row_index: usize,
111        expected: usize,
112        actual: usize,
113    },
114    /// Execute-many bind rows disagree on a column's type. Array DML binds a
115    /// single type per column: the first row that supplies a typed (non-`NULL`)
116    /// value at a position establishes that position's bind type, and the wire
117    /// metadata writer types every row's value under it. A later row whose value
118    /// is an incompatible type cannot ride the same bind — the server would
119    /// coerce it under the established type and raise a cryptic `ORA-01722` /
120    /// `ORA-01858`. Caught up front here, mirroring the reference `DPY-2006`.
121    BatchColumnTypeMismatch {
122        /// Zero-based index of the offending row.
123        row_index: usize,
124        /// Zero-based bind position (column) whose type disagrees.
125        column_index: usize,
126        /// Public Oracle db-type name established by the first typed row.
127        expected: &'static str,
128        /// Public Oracle db-type name supplied by the offending row.
129        actual: &'static str,
130    },
131}
132
133impl std::fmt::Display for BindError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            BindError::Sql(err) => write!(f, "SQL bind scan failed: {err}"),
137            BindError::PositionalCountMismatch { expected, actual } => write!(
138                f,
139                "SQL expects {expected} bind values but {actual} were supplied"
140            ),
141            BindError::MissingNamedBind { name } => {
142                write!(f, "missing value for named bind :{name}")
143            }
144            BindError::ExtraNamedBind { name } => {
145                write!(f, "named bind {name} is not referenced by the SQL")
146            }
147            BindError::BatchRowWidthMismatch {
148                row_index,
149                expected,
150                actual,
151            } => write!(
152                f,
153                "batch row {row_index} has {actual} bind values; expected {expected}"
154            ),
155            BindError::BatchColumnTypeMismatch {
156                row_index,
157                column_index,
158                expected,
159                actual,
160            } => write!(
161                f,
162                "batch row {row_index} binds {actual} at position {column_index}, but an \
163                 earlier row established {expected} there; every row must supply the same \
164                 type for a given bind"
165            ),
166        }
167    }
168}
169
170impl std::error::Error for BindError {
171    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
172        match self {
173            BindError::Sql(err) => Some(err),
174            _ => None,
175        }
176    }
177}
178
179impl From<sql::SqlError> for BindError {
180    fn from(err: sql::SqlError) -> Self {
181        BindError::Sql(err)
182    }
183}
184
185/// A short, stable description of a [`QueryValue`]'s Oracle type, for error
186/// messages.
187fn value_kind(value: &QueryValue) -> &'static str {
188    match value {
189        QueryValue::Text(_) => "VARCHAR2/CHAR text",
190        QueryValue::TextRaw { .. } => "undecodable character data",
191        QueryValue::Raw(_) => "RAW",
192        QueryValue::Rowid(_) => "ROWID",
193        QueryValue::BinaryDouble(_) => "BINARY_DOUBLE/BINARY_FLOAT",
194        QueryValue::IntervalDS { .. } => "INTERVAL DAY TO SECOND",
195        QueryValue::IntervalYM { .. } => "INTERVAL YEAR TO MONTH",
196        QueryValue::Number(_) => "NUMBER",
197        QueryValue::Boolean(_) => "BOOLEAN",
198        QueryValue::Cursor(_) => "REF CURSOR",
199        QueryValue::DateTime { .. } => "DATE/TIMESTAMP",
200        QueryValue::Object(_) => "object/ADT",
201        QueryValue::Lob(_) => "LOB locator",
202        QueryValue::Vector(_) => "VECTOR",
203        QueryValue::Json(_) => "JSON",
204        QueryValue::Array(_) => "collection",
205        // `QueryValue` is `#[non_exhaustive]`: a future Oracle type still yields a
206        // sensible (if generic) description for the `TypeMismatch` message.
207        _ => "Oracle value",
208    }
209}
210
211fn mismatch<T>(expected: &'static str, value: &QueryValue) -> Result<T, ConversionError> {
212    Err(ConversionError::TypeMismatch {
213        expected,
214        found: value_kind(value),
215    })
216}
217
218/// Convert a fetched Oracle [`QueryValue`] into a concrete Rust type.
219///
220/// Implemented for the core scalars unconditionally and for `chrono`, `uuid`,
221/// `serde_json`, `rust_decimal`, and vector element vectors behind their crate
222/// features. Use it through [`QueryResultExt::get`] /
223/// [`QueryResultExt::get_by_name`] rather than calling [`FromSql::from_sql`]
224/// directly in most code.
225///
226/// `Option<T>` is implemented for every `T: FromSql`, so a nullable column maps
227/// to `Option<T>` and a `NULL` cell yields `None`.
228pub trait FromSql: Sized {
229    /// Convert `value` into `Self`, or fail with a [`ConversionError`].
230    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError>;
231}
232
233// ---------------------------------------------------------------------------
234// Core scalar conversions (always available)
235// ---------------------------------------------------------------------------
236
237impl FromSql for i64 {
238    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
239        match value {
240            // Exact: reconstruct directly from the inline coefficient/scale (no
241            // string parse) when the value is an integer that fits i64.
242            QueryValue::Number(num) => num.to_i64().ok_or_else(|| ConversionError::OutOfRange {
243                expected: "i64",
244                detail: format!(
245                    "NUMBER {:?} is not an integer that fits i64",
246                    num.to_canonical_string()
247                ),
248            }),
249            QueryValue::Boolean(b) => Ok(i64::from(*b)),
250            other => mismatch("i64", other),
251        }
252    }
253}
254
255impl FromSql for i128 {
256    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
257        match value {
258            // Exact i128 reconstruct from the inline coefficient/scale.
259            QueryValue::Number(num) => num.to_i128().ok_or_else(|| ConversionError::OutOfRange {
260                expected: "i128",
261                detail: format!(
262                    "NUMBER {:?} is not an integer that fits i128",
263                    num.to_canonical_string()
264                ),
265            }),
266            QueryValue::Boolean(b) => Ok(i128::from(*b)),
267            other => mismatch("i128", other),
268        }
269    }
270}
271
272impl FromSql for i32 {
273    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
274        let wide = i64::from_sql(value)?;
275        i32::try_from(wide).map_err(|_| ConversionError::OutOfRange {
276            expected: "i32",
277            detail: format!("{wide} is out of range for i32"),
278        })
279    }
280}
281
282impl FromSql for u32 {
283    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
284        let wide = i64::from_sql(value)?;
285        u32::try_from(wide).map_err(|_| ConversionError::OutOfRange {
286            expected: "u32",
287            detail: format!("{wide} is out of range for u32"),
288        })
289    }
290}
291
292impl FromSql for f64 {
293    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
294        match value {
295            QueryValue::Number(num) => {
296                let text = num.to_canonical_string();
297                text.parse::<f64>()
298                    .map_err(|_| ConversionError::OutOfRange {
299                        expected: "f64",
300                        detail: format!("{text:?} is not a finite f64"),
301                    })
302            }
303            QueryValue::BinaryDouble(text) => {
304                text.trim()
305                    .parse::<f64>()
306                    .map_err(|_| ConversionError::OutOfRange {
307                        expected: "f64",
308                        detail: format!("{text:?} is not a finite f64"),
309                    })
310            }
311            other => mismatch("f64", other),
312        }
313    }
314}
315
316impl FromSql for f32 {
317    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
318        let wide = f64::from_sql(value)?;
319        let narrowed = wide as f32;
320        if !narrowed.is_finite() {
321            return Err(ConversionError::OutOfRange {
322                expected: "f32",
323                detail: format!("{wide:?} is out of range for f32"),
324            });
325        }
326        Ok(narrowed)
327    }
328}
329
330impl FromSql for bool {
331    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
332        match value {
333            QueryValue::Boolean(b) => Ok(*b),
334            // A NUMBER(1) flag column round-trips as 0/1.
335            QueryValue::Number(num) => match num.to_i64() {
336                Some(0) => Ok(false),
337                Some(1) => Ok(true),
338                _ => Err(ConversionError::OutOfRange {
339                    expected: "bool",
340                    detail: format!("NUMBER {:?} is neither 0 nor 1", num.to_canonical_string()),
341                }),
342            },
343            other => mismatch("bool", other),
344        }
345    }
346}
347
348impl FromSql for String {
349    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
350        match value {
351            QueryValue::Text(s) => Ok(s.clone()),
352            QueryValue::Rowid(s) => Ok(s.clone()),
353            QueryValue::Number(num) => Ok(num.to_canonical_string()),
354            QueryValue::BinaryDouble(text) => Ok(text.clone()),
355            other => mismatch("String", other),
356        }
357    }
358}
359
360impl FromSql for Vec<u8> {
361    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
362        match value {
363            QueryValue::Raw(bytes) => Ok(bytes.clone()),
364            QueryValue::TextRaw { bytes, .. } => Ok(bytes.clone()),
365            other => mismatch("Vec<u8>", other),
366        }
367    }
368}
369
370impl<T: FromSql> FromSql for Option<T> {
371    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
372        T::from_sql(value).map(Some)
373    }
374}
375
376// ---------------------------------------------------------------------------
377// chrono (feature-gated)
378// ---------------------------------------------------------------------------
379
380#[cfg(feature = "chrono")]
381mod chrono_impls {
382    use super::{mismatch, ConversionError, FromSql};
383    use chrono::{
384        DateTime, Duration as ChronoDuration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime,
385        TimeZone, Utc,
386    };
387    use oracledb_protocol::thin::QueryValue;
388
389    fn naive_from_components(
390        year: i32,
391        month: u8,
392        day: u8,
393        hour: u8,
394        minute: u8,
395        second: u8,
396        nanosecond: u32,
397    ) -> Result<NaiveDateTime, ConversionError> {
398        let date =
399            NaiveDate::from_ymd_opt(year, u32::from(month), u32::from(day)).ok_or_else(|| {
400                ConversionError::OutOfRange {
401                    expected: "chrono::NaiveDateTime",
402                    detail: format!("invalid date {year:04}-{month:02}-{day:02}"),
403                }
404            })?;
405        let time = NaiveTime::from_hms_nano_opt(
406            u32::from(hour),
407            u32::from(minute),
408            u32::from(second),
409            nanosecond,
410        )
411        .ok_or_else(|| ConversionError::OutOfRange {
412            expected: "chrono::NaiveDateTime",
413            detail: format!("invalid time {hour:02}:{minute:02}:{second:02}.{nanosecond:09}"),
414        })?;
415        Ok(NaiveDateTime::new(date, time))
416    }
417
418    impl FromSql for NaiveDateTime {
419        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
420            match value {
421                QueryValue::DateTime {
422                    year,
423                    month,
424                    day,
425                    hour,
426                    minute,
427                    second,
428                    nanosecond,
429                } => {
430                    naive_from_components(*year, *month, *day, *hour, *minute, *second, *nanosecond)
431                }
432                QueryValue::TimestampTz {
433                    year,
434                    month,
435                    day,
436                    hour,
437                    minute,
438                    second,
439                    nanosecond,
440                    offset_minutes,
441                } => {
442                    let naive = naive_from_components(
443                        *year,
444                        *month,
445                        *day,
446                        *hour,
447                        *minute,
448                        *second,
449                        *nanosecond,
450                    )?;
451                    naive
452                        .checked_add_signed(ChronoDuration::minutes(i64::from(*offset_minutes)))
453                        .ok_or_else(|| ConversionError::OutOfRange {
454                            expected: "chrono::NaiveDateTime",
455                            detail: "TIMESTAMP WITH TIME ZONE offset adjustment overflow"
456                                .to_string(),
457                        })
458                }
459                other => mismatch("chrono::NaiveDateTime", other),
460            }
461        }
462    }
463
464    impl FromSql for NaiveDate {
465        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
466            match value {
467                QueryValue::DateTime {
468                    year, month, day, ..
469                } => NaiveDate::from_ymd_opt(*year, u32::from(*month), u32::from(*day)).ok_or_else(
470                    || ConversionError::OutOfRange {
471                        expected: "chrono::NaiveDate",
472                        detail: format!("invalid date {year:04}-{month:02}-{day:02}"),
473                    },
474                ),
475                QueryValue::TimestampTz { .. } => {
476                    let datetime = NaiveDateTime::from_sql(value)?;
477                    Ok(datetime.date())
478                }
479                other => mismatch("chrono::NaiveDate", other),
480            }
481        }
482    }
483
484    fn fixed_offset_from_minutes(offset_minutes: i32) -> Result<FixedOffset, ConversionError> {
485        let seconds =
486            offset_minutes
487                .checked_mul(60)
488                .ok_or_else(|| ConversionError::OutOfRange {
489                    expected: "chrono::FixedOffset",
490                    detail: format!("offset minutes {offset_minutes} overflow seconds"),
491                })?;
492        FixedOffset::east_opt(seconds).ok_or_else(|| ConversionError::OutOfRange {
493            expected: "chrono::FixedOffset",
494            detail: format!("offset minutes {offset_minutes} out of chrono range"),
495        })
496    }
497
498    impl FromSql for DateTime<FixedOffset> {
499        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
500            match value {
501                QueryValue::TimestampTz {
502                    year,
503                    month,
504                    day,
505                    hour,
506                    minute,
507                    second,
508                    nanosecond,
509                    offset_minutes,
510                } => {
511                    // The wire (and `QueryValue::TimestampTz`) fields are UTC;
512                    // the offset is the display timezone (reference
513                    // decoders.pyx stores the raw fields, converters.pyx adds
514                    // the offset to render the wall clock). Attach the offset
515                    // to the UTC instant — do NOT reinterpret the UTC fields
516                    // as local wall time (bead rust-oracledb-97cj: doing so
517                    // shifted the instant by exactly the offset).
518                    let naive_utc = naive_from_components(
519                        *year,
520                        *month,
521                        *day,
522                        *hour,
523                        *minute,
524                        *second,
525                        *nanosecond,
526                    )?;
527                    Ok(fixed_offset_from_minutes(*offset_minutes)?.from_utc_datetime(&naive_utc))
528                }
529                QueryValue::DateTime {
530                    year,
531                    month,
532                    day,
533                    hour,
534                    minute,
535                    second,
536                    nanosecond,
537                } => {
538                    let naive = naive_from_components(
539                        *year,
540                        *month,
541                        *day,
542                        *hour,
543                        *minute,
544                        *second,
545                        *nanosecond,
546                    )?;
547                    fixed_offset_from_minutes(0)?
548                        .from_local_datetime(&naive)
549                        .single()
550                        .ok_or_else(|| ConversionError::OutOfRange {
551                            expected: "chrono::DateTime<FixedOffset>",
552                            detail: "invalid UTC local datetime".to_string(),
553                        })
554                }
555                other => mismatch("chrono::DateTime<FixedOffset>", other),
556            }
557        }
558    }
559
560    impl FromSql for DateTime<Utc> {
561        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
562            DateTime::<FixedOffset>::from_sql(value).map(|datetime| datetime.with_timezone(&Utc))
563        }
564    }
565}
566
567// ---------------------------------------------------------------------------
568// uuid (feature-gated): from RAW(16) or canonical / hyphenated text
569// ---------------------------------------------------------------------------
570
571#[cfg(feature = "uuid")]
572mod uuid_impls {
573    use super::{mismatch, ConversionError, FromSql};
574    use oracledb_protocol::thin::QueryValue;
575    use uuid::Uuid;
576
577    impl FromSql for Uuid {
578        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
579            match value {
580                QueryValue::Raw(bytes) => {
581                    let array: [u8; 16] =
582                        bytes
583                            .as_slice()
584                            .try_into()
585                            .map_err(|_| ConversionError::OutOfRange {
586                                expected: "uuid::Uuid",
587                                detail: format!("RAW length {} is not 16 bytes", bytes.len()),
588                            })?;
589                    Ok(Uuid::from_bytes(array))
590                }
591                QueryValue::Text(text) => {
592                    Uuid::parse_str(text.trim()).map_err(|err| ConversionError::OutOfRange {
593                        expected: "uuid::Uuid",
594                        detail: format!("text {text:?} is not a UUID: {err}"),
595                    })
596                }
597                other => mismatch("uuid::Uuid", other),
598            }
599        }
600    }
601}
602
603// ---------------------------------------------------------------------------
604// serde_json (feature-gated): from the eager OSON tree (near-free, lossless)
605// ---------------------------------------------------------------------------
606
607#[cfg(feature = "serde_json")]
608mod serde_json_impls {
609    use super::{mismatch, ConversionError, FromSql};
610    use oracledb_protocol::oson::OsonValue;
611    use oracledb_protocol::thin::QueryValue;
612    use serde_json::{Map, Number, Value};
613
614    /// Convert one OSON node into a `serde_json::Value`. Exact integers remain
615    /// JSON numbers; higher-precision decimal NUMBER text falls back to a JSON
616    /// string so no digits are silently rounded away.
617    fn oson_to_json(node: &OsonValue) -> Value {
618        match node {
619            OsonValue::Null => Value::Null,
620            OsonValue::Bool(b) => Value::Bool(*b),
621            OsonValue::Number(text) => number_to_json(text),
622            OsonValue::BinaryFloat(v) => f64_to_json(f64::from(*v)),
623            OsonValue::BinaryDouble(v) => f64_to_json(*v),
624            OsonValue::String(s) => Value::String(s.clone()),
625            OsonValue::Raw(bytes) => Value::String(hex_encode(bytes)),
626            OsonValue::DateTime {
627                year,
628                month,
629                day,
630                hour,
631                minute,
632                second,
633                nanosecond,
634            } => Value::String(format!(
635                "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanosecond:09}"
636            )),
637            OsonValue::IntervalDS {
638                days,
639                hours,
640                minutes,
641                seconds,
642                fseconds,
643            } => Value::String(format!(
644                "P{days}DT{hours}H{minutes}M{seconds}.{fseconds:09}S"
645            )),
646            OsonValue::Vector(_) => Value::String("<vector>".to_string()),
647            OsonValue::Array(items) => Value::Array(items.iter().map(oson_to_json).collect()),
648            OsonValue::Object(entries) => {
649                let mut map = Map::with_capacity(entries.len());
650                for (key, val) in entries {
651                    map.insert(key.clone(), oson_to_json(val));
652                }
653                Value::Object(map)
654            }
655        }
656    }
657
658    fn number_to_json(text: &str) -> Value {
659        let trimmed = text.trim();
660        if let Ok(i) = trimmed.parse::<i64>() {
661            return Value::Number(Number::from(i));
662        }
663        if let Ok(u) = trimmed.parse::<u64>() {
664            return Value::Number(Number::from(u));
665        }
666        if significant_digit_count(trimmed) <= 15 {
667            if let Ok(f) = trimmed.parse::<f64>() {
668                if let Some(n) = Number::from_f64(f) {
669                    return Value::Number(n);
670                }
671            }
672        }
673        // Preserve the exact text rather than lose precision.
674        Value::String(trimmed.to_string())
675    }
676
677    fn significant_digit_count(text: &str) -> usize {
678        let mantissa = text
679            .split_once(['e', 'E'])
680            .map_or(text, |(mantissa, _)| mantissa)
681            .trim_start_matches(['+', '-']);
682        let mut seen_non_zero = false;
683        let mut count = 0usize;
684        for ch in mantissa.chars().filter(|ch| ch.is_ascii_digit()) {
685            if ch != '0' || seen_non_zero {
686                seen_non_zero = true;
687                count += 1;
688            }
689        }
690        count
691    }
692
693    fn f64_to_json(v: f64) -> Value {
694        Number::from_f64(v).map_or_else(|| Value::String(v.to_string()), Value::Number)
695    }
696
697    fn hex_encode(bytes: &[u8]) -> String {
698        let mut out = String::with_capacity(bytes.len() * 2);
699        for byte in bytes {
700            out.push_str(&format!("{byte:02x}"));
701        }
702        out
703    }
704
705    impl FromSql for Value {
706        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
707            match value {
708                QueryValue::Json(oson) => Ok(oson_to_json(oson)),
709                // A JSON document stored in a VARCHAR2/CLOB column comes back as
710                // text; parse it so callers get a real Value either way.
711                QueryValue::Text(text) => {
712                    serde_json::from_str(text).map_err(|err| ConversionError::OutOfRange {
713                        expected: "serde_json::Value",
714                        detail: format!("text is not valid JSON: {err}"),
715                    })
716                }
717                other => mismatch("serde_json::Value", other),
718            }
719        }
720    }
721}
722
723// ---------------------------------------------------------------------------
724// rust_decimal (feature-gated): LOSSLESS from the text-NUMBER carrier
725// ---------------------------------------------------------------------------
726
727#[cfg(feature = "rust_decimal")]
728mod rust_decimal_impls {
729    use super::{mismatch, ConversionError, FromSql};
730    use oracledb_protocol::thin::QueryValue;
731    use rust_decimal::Decimal;
732    use std::str::FromStr;
733
734    impl FromSql for Decimal {
735        fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
736            match value {
737                QueryValue::Number(num) => {
738                    // EXACT path: build directly from the inline coefficient and a
739                    // non-negative scale that `rust_decimal` accepts (0..=28). This
740                    // avoids a string round-trip and is lossless for the full
741                    // 28-significant-digit domain `Decimal` can hold.
742                    if let (Some(coefficient), Some(scale)) = (num.coefficient(), num.scale()) {
743                        if (0..=28).contains(&scale) {
744                            if let Ok(dec) = Decimal::try_from_i128_with_scale(
745                                coefficient,
746                                u32::from(scale as u16),
747                            ) {
748                                return Ok(dec);
749                            }
750                        }
751                    }
752                    // Fallback for negative scale / out-of-range / boxed-text: go
753                    // through the canonical text (still lossless for valid values).
754                    let text = num.to_canonical_string();
755                    Decimal::from_str(&text).or_else(|_| {
756                        Decimal::from_scientific(&text).map_err(|err| ConversionError::OutOfRange {
757                            expected: "rust_decimal::Decimal",
758                            detail: format!("NUMBER {text:?} does not fit Decimal: {err}"),
759                        })
760                    })
761                }
762                other => mismatch("rust_decimal::Decimal", other),
763            }
764        }
765    }
766}
767
768// ---------------------------------------------------------------------------
769// Vector element vectors (feature-gated under their own logic — no extra dep)
770// ---------------------------------------------------------------------------
771
772use oracledb_protocol::vector::{Vector, VectorValues};
773
774impl FromSql for Vec<f32> {
775    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
776        match value {
777            QueryValue::Vector(vector) => vector_to_f32(vector),
778            other => mismatch("Vec<f32>", other),
779        }
780    }
781}
782
783impl FromSql for Vec<f64> {
784    fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
785        match value {
786            QueryValue::Vector(vector) => vector_to_f64(vector),
787            other => mismatch("Vec<f64>", other),
788        }
789    }
790}
791
792fn dense_values(vector: &Vector) -> Result<&VectorValues, ConversionError> {
793    match vector {
794        Vector::Dense(values) => Ok(values),
795        Vector::Sparse { .. } => Err(ConversionError::OutOfRange {
796            expected: "Vec<element>",
797            detail: "sparse VECTOR cannot be read as a dense Vec".to_string(),
798        }),
799    }
800}
801
802fn vector_to_f32(vector: &Vector) -> Result<Vec<f32>, ConversionError> {
803    match dense_values(vector)? {
804        VectorValues::Float32(v) => Ok(v.clone()),
805        VectorValues::Float64(v) => Ok(v.iter().map(|x| *x as f32).collect()),
806        VectorValues::Int8(v) => Ok(v.iter().map(|x| f32::from(*x)).collect()),
807        VectorValues::Binary(_) => Err(ConversionError::OutOfRange {
808            expected: "Vec<f32>",
809            detail: "BINARY-format VECTOR has no float elements".to_string(),
810        }),
811    }
812}
813
814fn vector_to_f64(vector: &Vector) -> Result<Vec<f64>, ConversionError> {
815    match dense_values(vector)? {
816        VectorValues::Float64(v) => Ok(v.clone()),
817        VectorValues::Float32(v) => Ok(v.iter().map(|x| f64::from(*x)).collect()),
818        VectorValues::Int8(v) => Ok(v.iter().map(|x| f64::from(*x)).collect()),
819        VectorValues::Binary(_) => Err(ConversionError::OutOfRange {
820            expected: "Vec<f64>",
821            detail: "BINARY-format VECTOR has no float elements".to_string(),
822        }),
823    }
824}
825
826// ---------------------------------------------------------------------------
827// Typed access onto a fetched QueryResult
828// ---------------------------------------------------------------------------
829
830/// Typed accessors layered onto [`QueryResult`] so callers can pull a cell out
831/// as a concrete Rust type by index or by column name.
832///
833/// This is an extension trait (the [`QueryResult`] type lives in the protocol
834/// crate, which stays dependency-lean) — bring it into scope with
835/// `use oracledb::QueryResultExt;`.
836pub trait QueryResultExt {
837    /// Convert the cell at `(row, col)` into `T`. A SQL `NULL` cell yields a
838    /// [`ConversionError::UnexpectedNull`] unless `T` is `Option<_>`; an
839    /// out-of-range index yields [`ConversionError::OutOfRange`].
840    fn get<T: FromSql>(&self, row: usize, col: usize) -> crate::Result<T>;
841
842    /// Convert the cell at `(row, column_name)` into `T`, resolving the column
843    /// name case-insensitively (Oracle folds unquoted identifiers to upper
844    /// case). An unknown column name yields [`ConversionError::OutOfRange`].
845    fn get_by_name<T: FromSql>(&self, row: usize, name: &str) -> crate::Result<T>;
846
847    /// Borrow row `row` as a [`TypedRow`] for repeated typed `get` calls
848    /// without re-passing the row index.
849    fn typed_row(&self, row: usize) -> TypedRow<'_>;
850
851    /// Map **every** fetched row into a value of `T` (a struct deriving
852    /// [`FromRow`]), returning them in order.
853    ///
854    /// Each row is converted through `T::from_row`, which goes through the real
855    /// [`FromSql`] conversion per field. A conversion failure on any row aborts
856    /// with that row's [`ConversionError`] (surfaced as [`crate::Error`]).
857    ///
858    /// ```no_run
859    /// use oracledb::{FromRow, QueryResultExt};
860    /// # use oracledb::protocol::thin::QueryResult;
861    ///
862    /// #[derive(FromRow)]
863    /// struct Emp { id: i64, name: String, hired: Option<String> }
864    ///
865    /// # fn demo(result: QueryResult) -> oracledb::Result<()> {
866    /// let emps: Vec<Emp> = result.rows_as::<Emp>()?;
867    /// # let _ = emps;
868    /// # Ok(())
869    /// # }
870    /// ```
871    fn rows_as<T: FromRow>(&self) -> crate::Result<Vec<T>>;
872}
873
874fn convert_cell<T: FromSql>(cell: Option<&Option<QueryValue>>, what: String) -> crate::Result<T> {
875    convert_cell_ce(cell, what).map_err(crate::Error::Conversion)
876}
877
878/// Like [`convert_cell`] but yields the bare [`ConversionError`] rather than
879/// wrapping it in [`crate::Error`]. The `#[derive(FromRow)]` code maps each
880/// **non-nullable** field through this, so a `NULL` cell is a hard
881/// [`ConversionError::UnexpectedNull`]. `Option<T>` fields take the dedicated
882/// NULL-tolerant path below instead.
883fn convert_cell_ce<T: FromSql>(
884    cell: Option<&Option<QueryValue>>,
885    what: String,
886) -> Result<T, ConversionError> {
887    match cell {
888        None => Err(ConversionError::OutOfRange {
889            expected: std::any::type_name::<T>(),
890            detail: what,
891        }),
892        Some(None) => Err(ConversionError::UnexpectedNull),
893        Some(Some(value)) => T::from_sql(value),
894    }
895}
896
897/// Like [`convert_cell_ce`] but turns a SQL `NULL` cell into `None` rather than
898/// erroring. This is the path `#[derive(FromRow)]` takes for `Option<T>` fields,
899/// so a nullable column maps to `Option<T>` with `NULL` -> `None`. A *missing*
900/// column is still an error (a mistyped `#[oracledb(column = ...)]` should not
901/// silently become `None`).
902fn convert_cell_opt_ce<T: FromSql>(
903    cell: Option<&Option<QueryValue>>,
904    what: String,
905) -> Result<Option<T>, ConversionError> {
906    match cell {
907        None => Err(ConversionError::OutOfRange {
908            expected: std::any::type_name::<Option<T>>(),
909            detail: what,
910        }),
911        Some(None) => Ok(None),
912        Some(Some(value)) => T::from_sql(value).map(Some),
913    }
914}
915
916impl QueryResultExt for QueryResult {
917    fn get<T: FromSql>(&self, row: usize, col: usize) -> crate::Result<T> {
918        let cell = self.rows.get(row).and_then(|r| r.get(col));
919        convert_cell(cell, format!("no cell at (row {row}, col {col})"))
920    }
921
922    fn get_by_name<T: FromSql>(&self, row: usize, name: &str) -> crate::Result<T> {
923        match self.column_index(name) {
924            Some(col) => self.get(row, col),
925            None => Err(crate::Error::Conversion(ConversionError::OutOfRange {
926                expected: std::any::type_name::<T>(),
927                detail: format!("no column named {name:?}"),
928            })),
929        }
930    }
931
932    fn typed_row(&self, row: usize) -> TypedRow<'_> {
933        let cells = self.rows.get(row).map_or(&[][..], Vec::as_slice);
934        TypedRow::new(&self.columns, cells, row)
935    }
936
937    fn rows_as<T: FromRow>(&self) -> crate::Result<Vec<T>> {
938        let mut out = Vec::with_capacity(self.rows.len());
939        for row in 0..self.rows.len() {
940            out.push(T::from_row(&self.typed_row(row))?);
941        }
942        Ok(out)
943    }
944}
945
946/// A borrowed view of one row of a [`QueryResult`] that converts cells to typed
947/// Rust values. Obtain it with [`QueryResultExt::typed_row`].
948#[derive(Clone, Copy)]
949pub struct TypedRow<'a> {
950    columns: &'a [ColumnMetadata],
951    cells: &'a [Option<QueryValue>],
952    row: usize,
953}
954
955impl TypedRow<'_> {
956    pub(crate) fn new<'a>(
957        columns: &'a [ColumnMetadata],
958        cells: &'a [Option<QueryValue>],
959        row: usize,
960    ) -> TypedRow<'a> {
961        TypedRow {
962            columns,
963            cells,
964            row,
965        }
966    }
967
968    /// Convert the cell in this row at column index `col` into `T`.
969    pub fn get<T: FromSql>(&self, col: usize) -> crate::Result<T> {
970        convert_cell(
971            self.cell_at(col),
972            format!("no cell at (row {}, col {col})", self.row),
973        )
974    }
975
976    /// Convert the cell in this row at column `name` (case-insensitive) into
977    /// `T`.
978    pub fn get_by_name<T: FromSql>(&self, name: &str) -> crate::Result<T> {
979        match column_index(self.columns, name) {
980            Some(col) => self.get(col),
981            None => Err(crate::Error::Conversion(ConversionError::OutOfRange {
982                expected: std::any::type_name::<T>(),
983                detail: format!("no column named {name:?}"),
984            })),
985        }
986    }
987
988    /// Borrow the raw cell at column index `col` of this row: `None` if the
989    /// index is out of range, `Some(None)` for a SQL `NULL`, `Some(Some(v))`
990    /// for a present value.
991    fn cell_at(&self, col: usize) -> Option<&Option<QueryValue>> {
992        self.cells.get(col)
993    }
994
995    /// Convert the cell in this row at column index `col` into `T`, yielding the
996    /// bare [`ConversionError`] on failure (unlike [`TypedRow::get`], which
997    /// wraps it in [`crate::Error`]). A SQL `NULL` cell is rejected with
998    /// [`ConversionError::UnexpectedNull`]; use [`TypedRow::try_get_opt`] for a
999    /// nullable column.
1000    ///
1001    /// This is the accessor the `#[derive(FromRow)]`-generated code uses for
1002    /// non-`Option` tuple-struct fields. It is `pub` so generated code can call
1003    /// it; hand-written code usually wants [`TypedRow::get`].
1004    pub fn try_get<T: FromSql>(&self, col: usize) -> Result<T, ConversionError> {
1005        convert_cell_ce(
1006            self.cell_at(col),
1007            format!("no cell at (row {}, col {col})", self.row),
1008        )
1009    }
1010
1011    /// Like [`TypedRow::try_get`] but for an `Option<T>` field: a SQL `NULL`
1012    /// cell becomes `None`. The accessor `#[derive(FromRow)]` uses for
1013    /// `Option<T>` tuple-struct fields.
1014    pub fn try_get_opt<T: FromSql>(&self, col: usize) -> Result<Option<T>, ConversionError> {
1015        convert_cell_opt_ce(
1016            self.cell_at(col),
1017            format!("no cell at (row {}, col {col})", self.row),
1018        )
1019    }
1020
1021    /// Convert the cell in this row at column `name` (case-insensitive) into
1022    /// `T`, yielding the bare [`ConversionError`] on failure (unlike
1023    /// [`TypedRow::get_by_name`], which wraps it in [`crate::Error`]). A SQL
1024    /// `NULL` cell is rejected with [`ConversionError::UnexpectedNull`]; use
1025    /// [`TypedRow::try_get_by_name_opt`] for a nullable column.
1026    ///
1027    /// This is the accessor the `#[derive(FromRow)]`-generated code uses for
1028    /// non-`Option` named-field structs. It is `pub` so generated code can call
1029    /// it; hand-written code usually wants [`TypedRow::get_by_name`].
1030    pub fn try_get_by_name<T: FromSql>(&self, name: &str) -> Result<T, ConversionError> {
1031        match column_index(self.columns, name) {
1032            Some(col) => self.try_get(col),
1033            None => Err(ConversionError::OutOfRange {
1034                expected: std::any::type_name::<T>(),
1035                detail: format!("no column named {name:?}"),
1036            }),
1037        }
1038    }
1039
1040    /// Like [`TypedRow::try_get_by_name`] but for an `Option<T>` field: a SQL
1041    /// `NULL` cell becomes `None`. The accessor `#[derive(FromRow)]` uses for
1042    /// `Option<T>` named-field structs.
1043    pub fn try_get_by_name_opt<T: FromSql>(
1044        &self,
1045        name: &str,
1046    ) -> Result<Option<T>, ConversionError> {
1047        match column_index(self.columns, name) {
1048            Some(col) => self.try_get_opt(col),
1049            None => Err(ConversionError::OutOfRange {
1050                expected: std::any::type_name::<Option<T>>(),
1051                detail: format!("no column named {name:?}"),
1052            }),
1053        }
1054    }
1055}
1056
1057fn column_index(columns: &[ColumnMetadata], name: &str) -> Option<usize> {
1058    columns
1059        .iter()
1060        .position(|col| col.name().eq_ignore_ascii_case(name))
1061}
1062
1063// ---------------------------------------------------------------------------
1064// FromRow: a whole row -> a user struct (bead 4bv, the #[derive(FromRow)] target)
1065// ---------------------------------------------------------------------------
1066
1067/// Map a fetched query row into a concrete Rust struct, with compile-time
1068/// checked field types.
1069///
1070/// You almost never implement this by hand. Instead derive it:
1071///
1072/// ```no_run
1073/// use oracledb::FromRow;
1074///
1075/// #[derive(FromRow)]
1076/// struct Emp {
1077///     id: i64,
1078///     name: String,
1079///     // a nullable column maps to Option<T>; a NULL cell becomes None
1080///     manager_id: Option<i64>,
1081/// }
1082/// ```
1083///
1084/// The derive maps each field **by column name** (the field name by default;
1085/// override per field with `#[oracledb(column = "...")]` or rename the whole
1086/// struct with `#[oracledb(rename_all = "...")]`), pulling it out through the
1087/// real [`FromSql`] conversion. Tuple structs map their fields **by position**.
1088/// Then [`QueryResultExt::rows_as`] turns a whole result set into a `Vec<T>`:
1089///
1090/// ```no_run
1091/// # use oracledb::{FromRow, QueryResultExt};
1092/// # use oracledb::protocol::thin::QueryResult;
1093/// # #[derive(FromRow)]
1094/// # struct Emp { id: i64, name: String }
1095/// # fn demo(result: QueryResult) -> oracledb::Result<()> {
1096/// let emps: Vec<Emp> = result.rows_as::<Emp>()?;
1097/// # let _ = emps;
1098/// # Ok(())
1099/// # }
1100/// ```
1101pub trait FromRow: Sized {
1102    /// Build `Self` from one borrowed [`TypedRow`], or fail with a
1103    /// [`ConversionError`].
1104    fn from_row(row: &TypedRow<'_>) -> Result<Self, ConversionError>;
1105}
1106
1107// ===========================================================================
1108// ToSql: Rust value -> BindValue (feature 3, bead zjd)
1109// ===========================================================================
1110
1111/// Convert a Rust value into an Oracle [`BindValue`] for a placeholder bind.
1112///
1113/// Implemented for the same scalar set as [`FromSql`] (core unconditionally;
1114/// `chrono`/`uuid`/`serde_json`/`rust_decimal`/`Vec<f32>` behind their
1115/// features). Each impl is a 1:1 map to an existing [`BindValue`] variant, so
1116/// `(40, "alice")` and [`params!`](crate::params) flow straight into the
1117/// execute helpers. `Option<T>` binds `None` as SQL `NULL`.
1118pub trait ToSql {
1119    /// Produce the [`BindValue`] this value binds as.
1120    fn to_sql(&self) -> BindValue;
1121
1122    /// Try to produce the [`BindValue`] this value binds as without losing
1123    /// information.
1124    ///
1125    /// Most supported Rust values always have an exact bind representation, so
1126    /// the default delegates to [`ToSql::to_sql`]. Types with values Oracle
1127    /// cannot encode exactly override this method with a
1128    /// [`ConversionError`]. Callers that accept values from outside their own
1129    /// control should prefer this fallible form over the legacy infallible
1130    /// [`ToSql::to_sql`] convenience method.
1131    fn try_to_sql(&self) -> Result<BindValue, ConversionError> {
1132        Ok(self.to_sql())
1133    }
1134}
1135
1136impl ToSql for i64 {
1137    fn to_sql(&self) -> BindValue {
1138        BindValue::Number(self.to_string())
1139    }
1140}
1141
1142impl ToSql for i32 {
1143    fn to_sql(&self) -> BindValue {
1144        BindValue::Number(self.to_string())
1145    }
1146}
1147
1148impl ToSql for u32 {
1149    fn to_sql(&self) -> BindValue {
1150        BindValue::Number(self.to_string())
1151    }
1152}
1153
1154impl ToSql for f64 {
1155    fn to_sql(&self) -> BindValue {
1156        BindValue::BinaryDouble(*self)
1157    }
1158}
1159
1160impl ToSql for f32 {
1161    fn to_sql(&self) -> BindValue {
1162        BindValue::BinaryFloat(f64::from(*self))
1163    }
1164}
1165
1166impl ToSql for bool {
1167    fn to_sql(&self) -> BindValue {
1168        BindValue::Boolean(*self)
1169    }
1170}
1171
1172impl ToSql for str {
1173    fn to_sql(&self) -> BindValue {
1174        BindValue::Text(self.to_string())
1175    }
1176}
1177
1178impl ToSql for String {
1179    fn to_sql(&self) -> BindValue {
1180        BindValue::Text(self.clone())
1181    }
1182}
1183
1184impl ToSql for [u8] {
1185    fn to_sql(&self) -> BindValue {
1186        BindValue::Raw(self.to_vec())
1187    }
1188}
1189
1190impl ToSql for Vec<u8> {
1191    fn to_sql(&self) -> BindValue {
1192        BindValue::Raw(self.clone())
1193    }
1194}
1195
1196impl<T: ToSql + ?Sized> ToSql for &T {
1197    fn to_sql(&self) -> BindValue {
1198        (**self).to_sql()
1199    }
1200
1201    fn try_to_sql(&self) -> Result<BindValue, ConversionError> {
1202        (**self).try_to_sql()
1203    }
1204}
1205
1206impl<T: ToSql> ToSql for Option<T> {
1207    fn to_sql(&self) -> BindValue {
1208        match self {
1209            Some(value) => value.to_sql(),
1210            None => BindValue::Null,
1211        }
1212    }
1213
1214    fn try_to_sql(&self) -> Result<BindValue, ConversionError> {
1215        match self {
1216            Some(value) => value.try_to_sql(),
1217            None => Ok(BindValue::Null),
1218        }
1219    }
1220}
1221
1222#[cfg(feature = "chrono")]
1223mod chrono_to_sql {
1224    use super::{ConversionError, ToSql};
1225    use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, Timelike, Utc};
1226    use oracledb_protocol::thin::BindValue;
1227
1228    impl ToSql for NaiveDateTime {
1229        fn to_sql(&self) -> BindValue {
1230            BindValue::Timestamp {
1231                // DB_TYPE_TIMESTAMP
1232                ora_type_num: 180,
1233                year: self.year(),
1234                month: self.month() as u8,
1235                day: self.day() as u8,
1236                hour: self.hour() as u8,
1237                minute: self.minute() as u8,
1238                second: self.second() as u8,
1239                nanosecond: self.nanosecond(),
1240            }
1241        }
1242    }
1243
1244    impl ToSql for NaiveDate {
1245        fn to_sql(&self) -> BindValue {
1246            BindValue::DateTime {
1247                year: self.year(),
1248                month: self.month() as u8,
1249                day: self.day() as u8,
1250                hour: 0,
1251                minute: 0,
1252                second: 0,
1253            }
1254        }
1255    }
1256
1257    impl ToSql for DateTime<FixedOffset> {
1258        fn to_sql(&self) -> BindValue {
1259            self.try_to_sql().unwrap_or_else(|error| {
1260                panic!(
1261                    "cannot convert chrono::DateTime<FixedOffset> to an Oracle TIMESTAMP WITH TIME ZONE without losing offset precision: {error}; use ToSql::try_to_sql to handle this conversion error"
1262                )
1263            })
1264        }
1265
1266        fn try_to_sql(&self) -> Result<BindValue, ConversionError> {
1267            // BindValue::TimestampTz fields must be UTC (the wire stores UTC +
1268            // display offset; see bead rust-oracledb-97cj). Emit the UTC
1269            // components, not the local wall clock.
1270            //
1271            // Oracle's TSTZ offset is minute-granularity. Chrono `FixedOffset`
1272            // can carry residual seconds, which must be rejected rather than
1273            // rounded into a different display offset.
1274            let utc = self.naive_utc();
1275            let offset_seconds = self.offset().local_minus_utc();
1276            let residual_seconds = offset_seconds.rem_euclid(60);
1277            if residual_seconds != 0 {
1278                return Err(ConversionError::OutOfRange {
1279                    expected: "Oracle TIMESTAMP WITH TIME ZONE offset",
1280                    detail: format!(
1281                        "UTC offset {offset_seconds:+} seconds contains {residual_seconds} sub-minute second(s) that Oracle TIMESTAMP WITH TIME ZONE cannot represent"
1282                    ),
1283                });
1284            }
1285
1286            Ok(BindValue::TimestampTz {
1287                year: utc.year(),
1288                month: utc.month() as u8,
1289                day: utc.day() as u8,
1290                hour: utc.hour() as u8,
1291                minute: utc.minute() as u8,
1292                second: utc.second() as u8,
1293                nanosecond: utc.nanosecond(),
1294                offset_minutes: offset_seconds / 60,
1295            })
1296        }
1297    }
1298
1299    impl ToSql for DateTime<Utc> {
1300        fn to_sql(&self) -> BindValue {
1301            BindValue::TimestampTz {
1302                year: self.year(),
1303                month: self.month() as u8,
1304                day: self.day() as u8,
1305                hour: self.hour() as u8,
1306                minute: self.minute() as u8,
1307                second: self.second() as u8,
1308                nanosecond: self.nanosecond(),
1309                offset_minutes: 0,
1310            }
1311        }
1312    }
1313}
1314
1315#[cfg(feature = "uuid")]
1316mod uuid_to_sql {
1317    use super::ToSql;
1318    use oracledb_protocol::thin::BindValue;
1319    use uuid::Uuid;
1320
1321    impl ToSql for Uuid {
1322        fn to_sql(&self) -> BindValue {
1323            BindValue::Raw(self.as_bytes().to_vec())
1324        }
1325    }
1326}
1327
1328#[cfg(feature = "serde_json")]
1329mod serde_json_to_sql {
1330    use super::ToSql;
1331    use oracledb_protocol::thin::BindValue;
1332    use serde_json::Value;
1333
1334    impl ToSql for Value {
1335        fn to_sql(&self) -> BindValue {
1336            // Bind a JSON document as text; callers wanting native DB_TYPE_JSON
1337            // can encode OSON and bind BindValue::Json directly.
1338            BindValue::Text(self.to_string())
1339        }
1340    }
1341}
1342
1343#[cfg(feature = "rust_decimal")]
1344mod rust_decimal_to_sql {
1345    use super::ToSql;
1346    use oracledb_protocol::thin::BindValue;
1347    use rust_decimal::Decimal;
1348
1349    impl ToSql for Decimal {
1350        fn to_sql(&self) -> BindValue {
1351            // LOSSLESS: the canonical decimal text carries Decimal's full
1352            // ~28-digit precision straight into Oracle's text-NUMBER path with
1353            // no float rounding.
1354            BindValue::Number(self.to_string())
1355        }
1356    }
1357}
1358
1359impl ToSql for Vec<f32> {
1360    fn to_sql(&self) -> BindValue {
1361        BindValue::Vector(Vector::Dense(VectorValues::Float32(self.clone())))
1362    }
1363}
1364
1365impl ToSql for [f32] {
1366    fn to_sql(&self) -> BindValue {
1367        BindValue::Vector(Vector::Dense(VectorValues::Float32(self.to_vec())))
1368    }
1369}
1370
1371// ---------------------------------------------------------------------------
1372// Single-row bind payload: positional or named
1373// ---------------------------------------------------------------------------
1374
1375/// Single-row bind payload for the operation-family APIs.
1376///
1377/// `Params` keeps named binds first-class while preserving the existing
1378/// positional [`IntoBinds`] and [`params!`](crate::params) conveniences:
1379/// tuples/arrays/`Vec<T: ToSql>` become [`Params::Positional`], the named
1380/// `params!` arm becomes [`Params::Named`], and raw `BindValue` slices can be
1381/// borrowed without moving.
1382#[derive(Clone, Debug, Default, PartialEq)]
1383pub enum Params<'a> {
1384    /// No binds.
1385    #[default]
1386    None,
1387    /// Positional binds (`:1`, `:2`, ...).
1388    Positional(Cow<'a, [BindValue]>),
1389    /// Named binds; execution reorders these to SQL placeholder first-use.
1390    Named(Cow<'a, [(String, BindValue)]>),
1391}
1392
1393impl<'a, T: IntoBinds> From<T> for Params<'a> {
1394    fn from(value: T) -> Self {
1395        Params::Positional(Cow::Owned(value.into_binds()))
1396    }
1397}
1398
1399impl<'a> From<&'a [BindValue]> for Params<'a> {
1400    fn from(value: &'a [BindValue]) -> Self {
1401        Params::Positional(Cow::Borrowed(value))
1402    }
1403}
1404
1405impl<'a> From<&'a Vec<BindValue>> for Params<'a> {
1406    fn from(value: &'a Vec<BindValue>) -> Self {
1407        Params::from(value.as_slice())
1408    }
1409}
1410
1411impl<'a> From<Vec<(String, BindValue)>> for Params<'a> {
1412    fn from(value: Vec<(String, BindValue)>) -> Self {
1413        Params::Named(Cow::Owned(value))
1414    }
1415}
1416
1417impl<'a> From<&'a [(String, BindValue)]> for Params<'a> {
1418    fn from(value: &'a [(String, BindValue)]) -> Self {
1419        Params::Named(Cow::Borrowed(value))
1420    }
1421}
1422
1423impl<'a> From<&'a Vec<(String, BindValue)>> for Params<'a> {
1424    fn from(value: &'a Vec<(String, BindValue)>) -> Self {
1425        Params::from(value.as_slice())
1426    }
1427}
1428
1429// ---------------------------------------------------------------------------
1430// Positional bind sources: tuples, arrays, Vec
1431// ---------------------------------------------------------------------------
1432
1433/// A source of positional binds (`:1`, `:2`, ...) for the ergonomic execute
1434/// helpers. Implemented for tuples up to arity 12, for `[T]` / `Vec<T>` of a
1435/// single [`ToSql`] type, and for `Vec<BindValue>` (the raw form).
1436pub trait IntoBinds {
1437    /// Materialize the positional bind values in order.
1438    fn into_binds(self) -> Vec<BindValue>;
1439}
1440
1441impl IntoBinds for Vec<BindValue> {
1442    fn into_binds(self) -> Vec<BindValue> {
1443        self
1444    }
1445}
1446
1447impl IntoBinds for () {
1448    fn into_binds(self) -> Vec<BindValue> {
1449        Vec::new()
1450    }
1451}
1452
1453impl<T: ToSql> IntoBinds for Vec<T> {
1454    fn into_binds(self) -> Vec<BindValue> {
1455        self.iter().map(ToSql::to_sql).collect()
1456    }
1457}
1458
1459impl<T: ToSql, const N: usize> IntoBinds for [T; N] {
1460    fn into_binds(self) -> Vec<BindValue> {
1461        self.iter().map(ToSql::to_sql).collect()
1462    }
1463}
1464
1465macro_rules! impl_into_binds_tuple {
1466    ($($name:ident),+) => {
1467        impl<$($name: ToSql),+> IntoBinds for ($($name,)+) {
1468            fn into_binds(self) -> Vec<BindValue> {
1469                #[allow(non_snake_case)]
1470                let ($($name,)+) = self;
1471                vec![$($name.to_sql()),+]
1472            }
1473        }
1474    };
1475}
1476
1477impl_into_binds_tuple!(A);
1478impl_into_binds_tuple!(A, B);
1479impl_into_binds_tuple!(A, B, C);
1480impl_into_binds_tuple!(A, B, C, D);
1481impl_into_binds_tuple!(A, B, C, D, E);
1482impl_into_binds_tuple!(A, B, C, D, E, F);
1483impl_into_binds_tuple!(A, B, C, D, E, F, G);
1484impl_into_binds_tuple!(A, B, C, D, E, F, G, H);
1485impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I);
1486impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J);
1487impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J, K);
1488impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J, K, L);
1489
1490/// Build a positional bind list from a heterogeneous set of [`ToSql`] values.
1491///
1492/// `params![40, "alice", true]` is sugar for an [`IntoBinds`] list and produces
1493/// a `Vec<BindValue>` directly. For *named* binds, pass `name => value` pairs;
1494/// this produces a `Vec<(String, BindValue)>` for the named-execute helpers.
1495///
1496/// ```
1497/// use oracledb::params;
1498/// // positional
1499/// let binds = params![40, "alice"];
1500/// assert_eq!(binds.len(), 2);
1501/// // named
1502/// let named = params!{ ":id" => 40, ":name" => "alice" };
1503/// assert_eq!(named.len(), 2);
1504/// ```
1505#[macro_export]
1506macro_rules! params {
1507    // named form: ":name" => value, ...
1508    ($($name:expr => $value:expr),+ $(,)?) => {{
1509        let binds: ::std::vec::Vec<(::std::string::String, $crate::protocol::thin::BindValue)> =
1510            ::std::vec![$(
1511                (::std::string::String::from($name), $crate::ToSql::to_sql(&$value))
1512            ),+];
1513        binds
1514    }};
1515    // positional form: value, ...
1516    ($($value:expr),+ $(,)?) => {{
1517        let binds: ::std::vec::Vec<$crate::protocol::thin::BindValue> =
1518            ::std::vec![$( $crate::ToSql::to_sql(&$value) ),+];
1519        binds
1520    }};
1521}
1522
1523/// Order a named-bind list into the positional order the driver expects.
1524///
1525/// Oracle placeholders fill positionally in *first-appearance* order in the SQL
1526/// text, so `params!{ ":b" => 2, ":a" => 1 }` against `... :a ... :b ...` must
1527/// be reordered to `[1, 2]`. This scans the SQL for `:name` placeholders
1528/// (ignoring literals and comments), then emits each supplied bind value once,
1529/// in the order its name first appears.
1530pub(crate) fn order_named_binds(
1531    sql: &str,
1532    named: Vec<(String, BindValue)>,
1533) -> Result<Vec<BindValue>, BindError> {
1534    if !bind_shape_validation_enabled(sql) {
1535        return Ok(named.into_iter().map(|(_, value)| value).collect());
1536    }
1537
1538    let order = sql::unique_bind_names(sql)?;
1539    let mut remaining = named;
1540    let mut out = Vec::with_capacity(remaining.len());
1541    for placeholder in &order {
1542        if let Some(pos) = remaining
1543            .iter()
1544            .position(|(name, _)| sql::bind_name_matches_key(placeholder, name))
1545        {
1546            let (_, value) = remaining.remove(pos);
1547            out.push(value);
1548        } else {
1549            return Err(BindError::MissingNamedBind {
1550                name: placeholder.clone(),
1551            });
1552        }
1553    }
1554    if let Some((name, _)) = remaining.first() {
1555        return Err(BindError::ExtraNamedBind { name: name.clone() });
1556    }
1557    Ok(out)
1558}
1559
1560/// Resolve a public [`Params`] payload into the positional bind vector the
1561/// current wire path expects.
1562pub(crate) fn resolve_params(sql: &str, params: Params<'_>) -> Result<Vec<BindValue>, BindError> {
1563    match params {
1564        Params::None => {
1565            validate_positional_bind_count(sql, 0)?;
1566            Ok(Vec::new())
1567        }
1568        Params::Positional(binds) => {
1569            let binds = binds.into_owned();
1570            validate_positional_bind_count(sql, binds.len())?;
1571            Ok(binds)
1572        }
1573        Params::Named(named) => order_named_binds(sql, named.into_owned()),
1574    }
1575}
1576
1577pub(crate) fn validate_bind_rows_shape(
1578    _sql: &str,
1579    bind_rows: &[Vec<BindValue>],
1580) -> Result<(), BindError> {
1581    // Only the *structural* shape is knowable at this raw execute layer: it does
1582    // not know whether the caller supplied binds by name or by position, so it
1583    // cannot validate the SQL placeholder *occurrence* count. A repeated named
1584    // bind (e.g. `:v` used three times) is satisfied by a single supplied value,
1585    // exactly as python-oracledb binds the same var to every occurrence
1586    // (impl/thin/var.pyx _bind), and a parse-only describe supplies no binds at
1587    // all (impl/thin/messages/execute.pyx sets the bind count to zero). True
1588    // positional-count validation (DPY-4009 equivalent) belongs in the
1589    // style-aware `resolve_params(Params::Positional)` path, where the driver
1590    // knows the binds are positional. Here we only reject ragged batch rows,
1591    // which is a genuine structural error in array DML.
1592    let Some(first_row) = bind_rows.first() else {
1593        return Ok(());
1594    };
1595    let expected = first_row.len();
1596    for (row_index, row) in bind_rows.iter().enumerate().skip(1) {
1597        if row.len() != expected {
1598            return Err(BindError::BatchRowWidthMismatch {
1599                row_index,
1600                expected,
1601                actual: row.len(),
1602            });
1603        }
1604    }
1605    Ok(())
1606}
1607
1608pub(crate) fn validate_positional_bind_count(sql: &str, actual: usize) -> Result<(), BindError> {
1609    if !bind_shape_validation_enabled(sql) {
1610        return Ok(());
1611    }
1612    let expected = sql::bind_names_per_occurrence(sql)?.len();
1613    if expected != actual {
1614        return Err(BindError::PositionalCountMismatch { expected, actual });
1615    }
1616    Ok(())
1617}
1618
1619fn bind_shape_validation_enabled(sql: &str) -> bool {
1620    !sql::statement_is_ddl(sql)
1621}
1622
1623/// First-execute bind TYPE validation for array DML / execute-many.
1624///
1625/// Array DML binds a single type per column. The wire metadata writer
1626/// (`write_bind_metadata_for_rows`) types each position from the FIRST row that
1627/// supplies a typed (non-`NULL`) value and writes *every* row's value under that
1628/// one type; a later row whose value is an incompatible type is silently dropped
1629/// from that inference and then serialized under the wrong type, so the server
1630/// raises a cryptic `ORA-01722`/`ORA-01858` instead of a clear client error.
1631///
1632/// This checks the same per-column rule the writer relies on and surfaces a
1633/// precise [`BindError::BatchColumnTypeMismatch`] up front — the reference's
1634/// `DPY-2006`. Two [`BindValue`]s are considered the same bind type when they
1635/// fold to the same wire type family (CHAR/VARCHAR/LONG are interchangeable, as
1636/// are RAW/LONG_RAW — [`crate::bind_type_family`], the same fold the writer's
1637/// `bind_metadata_types_are_compatible` uses) *and* share a character-set form.
1638///
1639/// An untyped `BindValue::Null` is a wildcard: it carries no type and
1640/// null-converts to any parsed type server-side, so it neither establishes a
1641/// column's type nor conflicts with it. `bind_value_type_info` returns `None`
1642/// for exactly `BindValue::Null`, mirroring the writer's own skip, so the two
1643/// stay in lockstep. A single-row execute (or fewer) can never conflict with
1644/// itself and is accepted without inspection.
1645pub(crate) fn validate_bind_rows_types(bind_rows: &[Vec<BindValue>]) -> Result<(), BindError> {
1646    use oracledb_protocol::thin::{bind_value_type_info, public_dbtype_name_from_bind};
1647
1648    let Some(first_row) = bind_rows.first() else {
1649        return Ok(());
1650    };
1651    if bind_rows.len() < 2 {
1652        return Ok(());
1653    }
1654    for column_index in 0..first_row.len() {
1655        // (family, csfrm, public type name) of the first typed value in the column.
1656        let mut established: Option<(u8, u8, &'static str)> = None;
1657        for (row_index, row) in bind_rows.iter().enumerate() {
1658            let Some(value) = row.get(column_index) else {
1659                // Ragged rows are rejected earlier by `validate_bind_rows_shape`.
1660                continue;
1661            };
1662            let Some(info) = bind_value_type_info(value) else {
1663                continue; // untyped NULL wildcard
1664            };
1665            let family = crate::bind_type_family(info.ora_type_num);
1666            match established {
1667                None => {
1668                    established = Some((family, info.csfrm, public_dbtype_name_from_bind(value)));
1669                }
1670                Some((established_family, established_csfrm, expected)) => {
1671                    if family != established_family || info.csfrm != established_csfrm {
1672                        return Err(BindError::BatchColumnTypeMismatch {
1673                            row_index,
1674                            column_index,
1675                            expected,
1676                            actual: public_dbtype_name_from_bind(value),
1677                        });
1678                    }
1679                }
1680            }
1681        }
1682    }
1683    Ok(())
1684}
1685
1686/// The number of positional bind values a `statement` declares, counted with the
1687/// driver's real SQL tokenizer — placeholders inside string/quoted literals,
1688/// `--`/`/* */` comments and `q'…'` quoted strings are ignored, and a repeated
1689/// placeholder in plain SQL counts once per occurrence (PL/SQL coalesces
1690/// duplicates), exactly as execution binds them.
1691///
1692/// This is the same count [`crate::Connection::execute`] validates against
1693/// before the wire round trip; expose it so a caller (a workbench, a linter, an
1694/// MCP tool) can pre-flight a `(sql, values)` pair with no database connection
1695/// and catch a mismatch as an [`enum@crate::Error`] instead of a cryptic
1696/// `ORA-01008`/`ORA-01745`.
1697pub fn declared_bind_count(statement: &str) -> Result<usize, BindError> {
1698    Ok(sql::bind_names_per_occurrence(statement)?.len())
1699}
1700
1701/// Pre-flight the positional bind COUNT for `statement` against the number of
1702/// values a caller intends to supply, with no database round trip. Returns
1703/// [`BindError::PositionalCountMismatch`] when they differ. This is precisely the
1704/// check [`crate::Connection::execute`] runs for positional binds, hoisted so it
1705/// can be run early (e.g. in a test or before building the bind vector). DDL,
1706/// which the driver never bind-count-validates, always passes.
1707pub fn check_positional_binds(statement: &str, supplied: usize) -> Result<(), BindError> {
1708    validate_positional_bind_count(statement, supplied)
1709}
1710
1711/// Pre-flight execute-many bind rows without a database round trip: the
1712/// structural shape (rectangular rows — [`BindError::BatchRowWidthMismatch`])
1713/// *and* first-execute per-column type consistency
1714/// ([`BindError::BatchColumnTypeMismatch`]). This mirrors the validation
1715/// [`crate::Connection::execute_many`] performs at execute time.
1716pub fn check_bind_rows(statement: &str, bind_rows: &[Vec<BindValue>]) -> Result<(), BindError> {
1717    validate_bind_rows_shape(statement, bind_rows)?;
1718    validate_bind_rows_types(bind_rows)
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724    use oracledb_protocol::thin::ColumnMetadata;
1725
1726    fn num(text: &str) -> QueryValue {
1727        QueryValue::number_from_text(text, !text.contains('.'))
1728    }
1729
1730    #[test]
1731    fn core_from_sql_scalars() {
1732        assert_eq!(i64::from_sql(&num("42")).unwrap(), 42);
1733        assert_eq!(i32::from_sql(&num("42")).unwrap(), 42);
1734        assert_eq!(u32::from_sql(&num("42")).unwrap(), 42);
1735        assert_eq!(f64::from_sql(&num("2.5")).unwrap(), 2.5);
1736        assert_eq!(f32::from_sql(&num("2.5")).unwrap(), 2.5_f32);
1737        assert!(bool::from_sql(&QueryValue::Boolean(true)).unwrap());
1738        assert!(bool::from_sql(&num("1")).unwrap());
1739        assert_eq!(
1740            String::from_sql(&QueryValue::Text("hi".into())).unwrap(),
1741            "hi"
1742        );
1743        assert_eq!(
1744            Vec::<u8>::from_sql(&QueryValue::Raw(vec![1, 2, 3])).unwrap(),
1745            vec![1, 2, 3]
1746        );
1747    }
1748
1749    #[test]
1750    fn f32_from_sql_rejects_number_overflow() {
1751        let err = f32::from_sql(&num("1e39")).expect_err("finite f64 outside f32 range must fail");
1752        assert!(matches!(
1753            err,
1754            ConversionError::OutOfRange {
1755                expected: "f32",
1756                ..
1757            }
1758        ));
1759
1760        assert_eq!(
1761            f32::from_sql(&num("1.5")).expect("normal f32 value must convert"),
1762            1.5_f32
1763        );
1764    }
1765
1766    #[test]
1767    fn i128_from_sql_is_exact_beyond_i64() {
1768        // A 30-digit integer that exceeds i64 but fits i128 must reconstruct
1769        // exactly from the inline coefficient (no string round-trip loss).
1770        let big = "123456789012345678901234567890";
1771        assert_eq!(
1772            i128::from_sql(&num(big)).unwrap(),
1773            123_456_789_012_345_678_901_234_567_890_i128
1774        );
1775        // i64 of the same value is out of range (typed error, not a panic).
1776        assert!(matches!(
1777            i64::from_sql(&num(big)).unwrap_err(),
1778            ConversionError::OutOfRange { .. }
1779        ));
1780        // A fractional NUMBER is not an integer -> typed OutOfRange.
1781        assert!(matches!(
1782            i128::from_sql(&num("3.14")).unwrap_err(),
1783            ConversionError::OutOfRange { .. }
1784        ));
1785    }
1786
1787    #[test]
1788    fn string_from_sql_is_canonical_byte_exact() {
1789        // The String conversion must yield the exact canonical NUMBER text.
1790        for text in ["0", "-1", "2.5", "100", "0.001", "12345678901234567890"] {
1791            assert_eq!(String::from_sql(&num(text)).unwrap(), text);
1792        }
1793    }
1794
1795    #[test]
1796    fn from_sql_errors_are_typed() {
1797        // type mismatch
1798        let err = i64::from_sql(&QueryValue::Text("x".into())).unwrap_err();
1799        assert!(matches!(err, ConversionError::TypeMismatch { .. }));
1800        // out of range
1801        let err = i32::from_sql(&num("9999999999")).unwrap_err();
1802        assert!(matches!(err, ConversionError::OutOfRange { .. }));
1803    }
1804
1805    #[test]
1806    fn option_accepts_any() {
1807        let v: Option<i64> = Option::<i64>::from_sql(&num("7")).unwrap();
1808        assert_eq!(v, Some(7));
1809    }
1810
1811    #[test]
1812    fn core_to_sql_scalars() {
1813        assert_eq!(40_i64.to_sql(), BindValue::Number("40".into()));
1814        assert_eq!(40_i32.to_sql(), BindValue::Number("40".into()));
1815        assert_eq!(2.5_f64.to_sql(), BindValue::BinaryDouble(2.5));
1816        assert_eq!(true.to_sql(), BindValue::Boolean(true));
1817        assert_eq!("alice".to_sql(), BindValue::Text("alice".into()));
1818        assert_eq!(vec![1u8, 2, 3].to_sql(), BindValue::Raw(vec![1, 2, 3]));
1819        let none: Option<i64> = None;
1820        assert_eq!(none.to_sql(), BindValue::Null);
1821    }
1822
1823    #[test]
1824    fn into_binds_tuple_and_slice() {
1825        let binds = (40_i64, "alice").into_binds();
1826        assert_eq!(
1827            binds,
1828            vec![
1829                BindValue::Number("40".into()),
1830                BindValue::Text("alice".into())
1831            ]
1832        );
1833        let binds = [1_i64, 2, 3].into_binds();
1834        assert_eq!(binds.len(), 3);
1835    }
1836
1837    #[test]
1838    fn params_macro_positional_and_named() {
1839        let positional = params![40_i64, "alice"];
1840        assert_eq!(
1841            positional,
1842            vec![
1843                BindValue::Number("40".into()),
1844                BindValue::Text("alice".into())
1845            ]
1846        );
1847        let named = params! { ":id" => 40_i64, ":name" => "alice" };
1848        assert_eq!(named.len(), 2);
1849        assert_eq!(named[0].0, ":id");
1850        assert_eq!(named[0].1, BindValue::Number("40".into()));
1851        assert_eq!(named[1].0, ":name");
1852    }
1853
1854    #[test]
1855    fn params_from_positional_sources() {
1856        assert_eq!(Params::default(), Params::None);
1857        assert_eq!(Params::from(()), Params::Positional(Cow::Owned(Vec::new())));
1858
1859        let from_tuple = Params::from((40_i64, "alice"));
1860        assert_eq!(
1861            from_tuple,
1862            Params::Positional(Cow::Owned(vec![
1863                BindValue::Number("40".into()),
1864                BindValue::Text("alice".into())
1865            ]))
1866        );
1867
1868        let raw = vec![BindValue::Number("7".into()), BindValue::Boolean(true)];
1869        assert_eq!(
1870            Params::from(raw.clone()),
1871            Params::Positional(Cow::Owned(raw.clone()))
1872        );
1873        assert_eq!(
1874            Params::from(raw.as_slice()),
1875            Params::Positional(Cow::Borrowed(raw.as_slice()))
1876        );
1877        assert_eq!(
1878            Params::from(&raw),
1879            Params::Positional(Cow::Borrowed(raw.as_slice()))
1880        );
1881
1882        let empty: &[BindValue] = &[];
1883        assert_eq!(
1884            Params::from(empty),
1885            Params::Positional(Cow::Borrowed(empty))
1886        );
1887    }
1888
1889    #[test]
1890    fn params_from_named_sources() {
1891        let named = params! { ":id" => 40_i64, ":name" => "alice" };
1892
1893        assert_eq!(
1894            Params::from(named.clone()),
1895            Params::Named(Cow::Owned(named.clone()))
1896        );
1897        assert_eq!(
1898            Params::from(named.as_slice()),
1899            Params::Named(Cow::Borrowed(named.as_slice()))
1900        );
1901        assert_eq!(
1902            Params::from(&named),
1903            Params::Named(Cow::Borrowed(named.as_slice()))
1904        );
1905
1906        let empty: Vec<(String, BindValue)> = Vec::new();
1907        assert_eq!(
1908            Params::from(empty.clone()),
1909            Params::Named(Cow::Owned(empty.clone()))
1910        );
1911        assert_eq!(
1912            Params::from(empty.as_slice()),
1913            Params::Named(Cow::Borrowed(empty.as_slice()))
1914        );
1915    }
1916
1917    #[test]
1918    fn resolve_params_reuses_named_ordering() {
1919        let named = params! { ":b" => 2_i64, ":a" => 1_i64 };
1920        let ordered = resolve_params(
1921            "select * from t where a = :a and b = :b",
1922            Params::from(named),
1923        )
1924        .expect("named binds should resolve");
1925        assert_eq!(
1926            ordered,
1927            vec![BindValue::Number("1".into()), BindValue::Number("2".into())]
1928        );
1929
1930        assert!(resolve_params("select 1 from dual", Params::None)
1931            .expect("no binds should resolve")
1932            .is_empty());
1933    }
1934
1935    #[test]
1936    fn resolve_params_rejects_missing_and_extra_named_binds() {
1937        let missing = resolve_params(
1938            "select * from t where a = :a and b = :b",
1939            Params::from(params! { ":a" => 1_i64 }),
1940        )
1941        .unwrap_err();
1942        assert_eq!(
1943            missing,
1944            BindError::MissingNamedBind {
1945                name: "b".to_string()
1946            }
1947        );
1948
1949        let extra = resolve_params(
1950            "select * from t where a = :a",
1951            Params::from(params! { ":a" => 1_i64, ":b" => 2_i64 }),
1952        )
1953        .unwrap_err();
1954        assert_eq!(
1955            extra,
1956            BindError::ExtraNamedBind {
1957                name: ":b".to_string()
1958            }
1959        );
1960    }
1961
1962    #[test]
1963    fn resolve_params_uses_protocol_tokenizer_for_named_binds() {
1964        let ordered = resolve_params(
1965            r#"select ':skip', q'[not :this]', :"MiX", :a# from dual -- :comment"#,
1966            Params::from(params! { ":a#" => 7_i64, r#""MiX""# => 6_i64 }),
1967        )
1968        .expect("tokenizer-backed named binds should resolve");
1969
1970        assert_eq!(
1971            ordered,
1972            vec![BindValue::Number("6".into()), BindValue::Number("7".into())]
1973        );
1974    }
1975
1976    #[test]
1977    fn resolve_params_rejects_unambiguous_positional_count_mismatch() {
1978        let err = resolve_params(
1979            "select :1 + :2 from dual",
1980            Params::from(vec![BindValue::Number("1".into())]),
1981        )
1982        .unwrap_err();
1983        assert_eq!(
1984            err,
1985            BindError::PositionalCountMismatch {
1986                expected: 2,
1987                actual: 1
1988            }
1989        );
1990
1991        let repeated = resolve_params(
1992            "select :1 + :1 from dual",
1993            Params::from(vec![
1994                BindValue::Number("1".into()),
1995                BindValue::Number("1".into()),
1996            ]),
1997        )
1998        .expect("plain SQL repeated placeholders consume one positional value per occurrence");
1999        assert_eq!(repeated.len(), 2);
2000    }
2001
2002    #[test]
2003    fn validate_bind_rows_shape_rejects_ragged_raw_rows() {
2004        let rows = vec![
2005            vec![
2006                BindValue::Number("1".to_string()),
2007                BindValue::Text("a".into()),
2008            ],
2009            vec![BindValue::Number("2".to_string())],
2010        ];
2011
2012        let err = validate_bind_rows_shape("insert into t values (:1, :2)", &rows).unwrap_err();
2013        assert_eq!(
2014            err,
2015            BindError::BatchRowWidthMismatch {
2016                row_index: 1,
2017                expected: 2,
2018                actual: 1
2019            }
2020        );
2021    }
2022
2023    #[test]
2024    fn validate_bind_rows_shape_accepts_repeated_named_bind_single_value() {
2025        // `:v` appears three times but is one named bind: a single supplied
2026        // value satisfies every occurrence (python-oracledb binds the same var
2027        // to all occurrences, impl/thin/var.pyx). The raw validator must NOT
2028        // treat this as a placeholder-occurrence count mismatch — that check
2029        // belongs only to the style-aware positional path.
2030        let rows = vec![vec![BindValue::Number("1".to_string())]];
2031        validate_bind_rows_shape(
2032            "select :v + 1 from dual union all select :v + 2 from dual \
2033             union all select :v + 3 from dual",
2034            &rows,
2035        )
2036        .expect("repeated named bind is satisfied by one supplied value");
2037    }
2038
2039    #[test]
2040    fn resolve_params_dedups_group_by_case_repeated_named_bind() {
2041        let sql = "\
2042            select case when deptno = :dept then 'target' else 'other' end bucket, count(*) \
2043            from emp \
2044            group by case when deptno = :dept then 'target' else 'other' end";
2045        let ordered = resolve_params(sql, Params::from(params! { ":dept" => 10_i64 }))
2046            .expect("repeated named GROUP BY CASE bind should resolve once");
2047        assert_eq!(ordered, vec![BindValue::Number("10".to_string())]);
2048    }
2049
2050    #[test]
2051    fn validate_bind_rows_shape_accepts_empty_rows_for_parse() {
2052        // A parse-only describe (and any no-bind execute) supplies zero bind
2053        // rows; the wire bind count is zero. This must be accepted.
2054        validate_bind_rows_shape("select :v from dual", &[])
2055            .expect("a no-bind execute / parse-only describe is valid");
2056    }
2057
2058    // ------------------------------------------------------------------
2059    // Bind-COUNT check via the real tokenizer (declared_bind_count /
2060    // check_positional_binds)
2061    // ------------------------------------------------------------------
2062
2063    #[test]
2064    fn declared_bind_count_uses_real_tokenizer_ignoring_literals_and_comments() {
2065        // Placeholders inside a string literal, a `--` comment, a `/* */`
2066        // comment and a q-string are NOT binds; the two real ones are.
2067        let sql = "select :a /* :not_a_bind */, ':also_not', \
2068                   q'[:nope]' from dual where x = :b -- :tail";
2069        assert_eq!(declared_bind_count(sql).expect("tokenizes"), 2);
2070
2071        // Plain SQL counts a repeated positional placeholder once PER
2072        // occurrence (execution binds each occurrence its own value).
2073        assert_eq!(
2074            declared_bind_count("insert into t values (:1, :1, :2)").expect("tokenizes"),
2075            3
2076        );
2077        // A named bind repeated in plain SQL likewise counts per occurrence.
2078        assert_eq!(
2079            declared_bind_count("select :v, :v from dual").expect("tokenizes"),
2080            2
2081        );
2082        // PL/SQL coalesces duplicate placeholders into a single bind.
2083        assert_eq!(
2084            declared_bind_count("begin proc(:x, :x, :y); end;").expect("tokenizes"),
2085            2
2086        );
2087        // No binds at all.
2088        assert_eq!(
2089            declared_bind_count("select 1 from dual").expect("tokenizes"),
2090            0
2091        );
2092    }
2093
2094    #[test]
2095    fn declared_bind_count_propagates_tokenizer_error() {
2096        let err = declared_bind_count("select ':unterminated from dual").unwrap_err();
2097        assert_eq!(err, BindError::Sql(sql::SqlError::MissingEndingSingleQuote));
2098    }
2099
2100    #[test]
2101    fn check_positional_binds_catches_mismatch_and_passes_correct() {
2102        // Too few.
2103        assert_eq!(
2104            check_positional_binds("select :1 + :2 from dual", 1).unwrap_err(),
2105            BindError::PositionalCountMismatch {
2106                expected: 2,
2107                actual: 1
2108            }
2109        );
2110        // Too many.
2111        assert_eq!(
2112            check_positional_binds("select :1 from dual", 3).unwrap_err(),
2113            BindError::PositionalCountMismatch {
2114                expected: 1,
2115                actual: 3
2116            }
2117        );
2118        // Exactly right — including the per-occurrence rule for a repeated bind.
2119        check_positional_binds("insert into t values (:1, :2)", 2).expect("matching count passes");
2120        check_positional_binds("select :v, :v from dual", 2)
2121            .expect("repeated plain-SQL placeholder consumes one value per occurrence");
2122        // DDL is never bind-count-validated (mirrors execution).
2123        check_positional_binds("create table t (id number)", 5)
2124            .expect("DDL is exempt from bind-count validation");
2125    }
2126
2127    // ------------------------------------------------------------------
2128    // First-execute TYPE validation (validate_bind_rows_types / check_bind_rows)
2129    // ------------------------------------------------------------------
2130
2131    fn text(value: &str) -> BindValue {
2132        BindValue::Text(value.into())
2133    }
2134    fn number(value: &str) -> BindValue {
2135        BindValue::Number(value.into())
2136    }
2137
2138    #[test]
2139    fn type_validation_accepts_homogeneous_columns() {
2140        // Column 0 all NUMBER, column 1 all VARCHAR: every row agrees.
2141        let rows = vec![
2142            vec![number("1"), text("a")],
2143            vec![number("2"), text("bb")],
2144            vec![number("3"), text("ccc")],
2145        ];
2146        check_bind_rows("insert into t values (:1, :2)", &rows)
2147            .expect("consistent per-column types are accepted");
2148    }
2149
2150    #[test]
2151    fn type_validation_catches_column_type_mismatch() {
2152        // Column 0: row 0 NUMBER, row 1 VARCHAR -> the batch cannot bind one
2153        // type for the column.
2154        let rows = vec![vec![number("1")], vec![text("oops")]];
2155        let err = validate_bind_rows_types(&rows).unwrap_err();
2156        assert_eq!(
2157            err,
2158            BindError::BatchColumnTypeMismatch {
2159                row_index: 1,
2160                column_index: 0,
2161                expected: "DB_TYPE_NUMBER",
2162                actual: "DB_TYPE_VARCHAR",
2163            }
2164        );
2165    }
2166
2167    #[test]
2168    fn type_validation_reports_the_offending_row_and_column() {
2169        // Rows 0 and 1 agree on both columns; row 2 flips column 1 to NUMBER.
2170        let rows = vec![
2171            vec![number("1"), text("a")],
2172            vec![number("2"), text("b")],
2173            vec![number("3"), number("4")],
2174        ];
2175        let err = validate_bind_rows_types(&rows).unwrap_err();
2176        assert_eq!(
2177            err,
2178            BindError::BatchColumnTypeMismatch {
2179                row_index: 2,
2180                column_index: 1,
2181                expected: "DB_TYPE_VARCHAR",
2182                actual: "DB_TYPE_NUMBER",
2183            }
2184        );
2185    }
2186
2187    #[test]
2188    fn type_validation_treats_untyped_null_as_a_wildcard() {
2189        // An untyped NULL neither establishes nor conflicts with a column type,
2190        // exactly as the wire writer skips it.
2191        let rows = vec![
2192            vec![BindValue::Null, text("a")],
2193            vec![number("2"), BindValue::Null],
2194            vec![number("3"), text("c")],
2195        ];
2196        check_bind_rows("insert into t values (:1, :2)", &rows)
2197            .expect("untyped NULLs ride any column type");
2198    }
2199
2200    #[test]
2201    fn type_validation_establishes_type_from_first_typed_row() {
2202        // Column 0 is NULL until row 2 (NUMBER); a later VARCHAR still conflicts
2203        // with the established NUMBER even though the first row was NULL.
2204        let rows = vec![
2205            vec![BindValue::Null],
2206            vec![BindValue::Null],
2207            vec![number("7")],
2208            vec![text("x")],
2209        ];
2210        let err = validate_bind_rows_types(&rows).unwrap_err();
2211        assert_eq!(
2212            err,
2213            BindError::BatchColumnTypeMismatch {
2214                row_index: 3,
2215                column_index: 0,
2216                expected: "DB_TYPE_NUMBER",
2217                actual: "DB_TYPE_VARCHAR",
2218            }
2219        );
2220    }
2221
2222    #[test]
2223    fn type_validation_folds_char_varchar_and_raw_families() {
2224        use oracledb_protocol::thin::{CS_FORM_IMPLICIT, ORA_TYPE_NUM_CHAR, ORA_TYPE_NUM_LONG_RAW};
2225        // CHAR (via a typed null) and VARCHAR fold to one family: compatible.
2226        let char_then_varchar = vec![
2227            vec![BindValue::TypedNull {
2228                ora_type_num: ORA_TYPE_NUM_CHAR,
2229                csfrm: CS_FORM_IMPLICIT,
2230                buffer_size: 10,
2231            }],
2232            vec![text("hello")],
2233        ];
2234        validate_bind_rows_types(&char_then_varchar)
2235            .expect("CHAR and VARCHAR share a wire type family");
2236
2237        // RAW and LONG_RAW fold to one family: compatible.
2238        let raw_then_long_raw = vec![
2239            vec![BindValue::Raw(vec![1, 2, 3])],
2240            vec![BindValue::TypedNull {
2241                ora_type_num: ORA_TYPE_NUM_LONG_RAW,
2242                csfrm: 0,
2243                buffer_size: 10,
2244            }],
2245        ];
2246        validate_bind_rows_types(&raw_then_long_raw)
2247            .expect("RAW and LONG_RAW share a wire type family");
2248    }
2249
2250    #[test]
2251    fn type_validation_rejects_charset_form_mismatch_within_a_family() {
2252        use oracledb_protocol::thin::{CS_FORM_NCHAR, ORA_TYPE_NUM_VARCHAR};
2253        // Same VARCHAR family but different character-set form (NVARCHAR vs
2254        // VARCHAR): the wire writer treats these as incompatible, so we do too.
2255        let rows = vec![
2256            vec![text("implicit")],
2257            vec![BindValue::TypedNull {
2258                ora_type_num: ORA_TYPE_NUM_VARCHAR,
2259                csfrm: CS_FORM_NCHAR,
2260                buffer_size: 10,
2261            }],
2262        ];
2263        let err = validate_bind_rows_types(&rows).unwrap_err();
2264        assert!(
2265            matches!(
2266                err,
2267                BindError::BatchColumnTypeMismatch {
2268                    row_index: 1,
2269                    column_index: 0,
2270                    ..
2271                }
2272            ),
2273            "charset-form mismatch must be rejected, got {err:?}"
2274        );
2275    }
2276
2277    #[test]
2278    fn type_validation_passes_single_and_empty_rows() {
2279        // A single row can never conflict with itself.
2280        check_bind_rows(
2281            "insert into t values (:1, :2)",
2282            &[vec![number("1"), text("a")]],
2283        )
2284        .expect("a single row is always type-consistent");
2285        // No rows at all (parse-only / no-bind execute).
2286        check_bind_rows("insert into t values (:1)", &[]).expect("no rows to validate");
2287    }
2288
2289    #[test]
2290    fn check_bind_rows_reports_width_before_type() {
2291        // A ragged batch is a structural error surfaced ahead of any type check.
2292        let rows = vec![vec![number("1"), text("a")], vec![number("2")]];
2293        let err = check_bind_rows("insert into t values (:1, :2)", &rows).unwrap_err();
2294        assert_eq!(
2295            err,
2296            BindError::BatchRowWidthMismatch {
2297                row_index: 1,
2298                expected: 2,
2299                actual: 1
2300            }
2301        );
2302    }
2303
2304    #[test]
2305    fn query_result_typed_get() {
2306        let result = QueryResult {
2307            columns: vec![ColumnMetadata::new("ID", 0), ColumnMetadata::new("NAME", 0)],
2308            rows: vec![vec![Some(num("7")), Some(QueryValue::Text("bob".into()))]],
2309            ..Default::default()
2310        };
2311        assert_eq!(result.get::<i64>(0, 0).unwrap(), 7);
2312        assert_eq!(result.get_by_name::<i64>(0, "id").unwrap(), 7);
2313        assert_eq!(result.get_by_name::<String>(0, "name").unwrap(), "bob");
2314        let row = result.typed_row(0);
2315        assert_eq!(row.get::<i64>(0).unwrap(), 7);
2316        assert_eq!(row.get_by_name::<String>("NAME").unwrap(), "bob");
2317        // missing column name -> typed conversion error
2318        assert!(result.get_by_name::<i64>(0, "nope").is_err());
2319    }
2320
2321    #[cfg(feature = "rust_decimal")]
2322    #[test]
2323    fn decimal_roundtrip_is_lossless_to_full_precision() {
2324        use rust_decimal::Decimal;
2325        use std::str::FromStr;
2326        // `rust_decimal::Decimal` holds a 96-bit mantissa (~28-29 significant
2327        // digits). At the *full* precision the type can represent, the
2328        // text-NUMBER carrier preserves every digit exactly in both
2329        // directions — no float rounding anywhere. (python-oracledb hands you a
2330        // lossy f64 of ~15-17 digits unless you opt into decimal.Decimal.)
2331        let text = "7922816251426433759354.395033"; // 28 significant digits
2332        let dec = Decimal::from_str(text).unwrap();
2333        // ToSql carries the exact text into the NUMBER bind...
2334        let bind = dec.to_sql();
2335        assert_eq!(bind, BindValue::Number(text.to_string()));
2336        // ...and FromSql recovers the exact Decimal from the NUMBER carrier.
2337        let back = Decimal::from_sql(&num(text)).unwrap();
2338        assert_eq!(back, dec);
2339        assert_eq!(back.to_string(), text);
2340
2341        // An f64 would corrupt this 28-digit value; Decimal does not.
2342        let as_f64: f64 = text.parse().unwrap();
2343        assert_ne!(
2344            as_f64.to_string(),
2345            text,
2346            "f64 must lose precision here, proving Decimal is the lossless path"
2347        );
2348    }
2349
2350    #[cfg(feature = "chrono")]
2351    #[test]
2352    fn chrono_from_and_to_sql() {
2353        use chrono::{
2354            DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc,
2355        };
2356        let dt = QueryValue::DateTime {
2357            year: 2026,
2358            month: 6,
2359            day: 14,
2360            hour: 12,
2361            minute: 30,
2362            second: 45,
2363            nanosecond: 123_456_789,
2364        };
2365        let parsed = NaiveDateTime::from_sql(&dt).unwrap();
2366        assert_eq!(parsed.to_string(), "2026-06-14 12:30:45.123456789");
2367        let date = NaiveDate::from_sql(&dt).unwrap();
2368        assert_eq!(date, NaiveDate::from_ymd_opt(2026, 6, 14).unwrap());
2369        let bce = QueryValue::DateTime {
2370            year: -4712,
2371            month: 1,
2372            day: 1,
2373            hour: 0,
2374            minute: 0,
2375            second: 0,
2376            nanosecond: 0,
2377        };
2378        let bce_date = NaiveDate::from_sql(&bce).unwrap();
2379        assert_eq!(bce_date.year(), -4712);
2380        // ToSql produces a Timestamp bind carrying the same components.
2381        match parsed.to_sql() {
2382            BindValue::Timestamp {
2383                year, nanosecond, ..
2384            } => {
2385                assert_eq!(year, 2026);
2386                assert_eq!(nanosecond, 123_456_789);
2387            }
2388            other => panic!("expected Timestamp bind, got {other:?}"),
2389        }
2390
2391        // TSTZ wire/QueryValue fields are UTC + display offset (bead
2392        // rust-oracledb-97cj; reference decoders.pyx + converters.pyx). For
2393        // UTC 12:34:56 at -05:30 the wall clock is 07:04:56-05:30 and the
2394        // instant is 12:34:56Z.
2395        let offset_value = QueryValue::TimestampTz {
2396            year: 2026,
2397            month: 6,
2398            day: 29,
2399            hour: 12,
2400            minute: 34,
2401            second: 56,
2402            nanosecond: 987_654_321,
2403            offset_minutes: -330,
2404        };
2405        let fixed = DateTime::<FixedOffset>::from_sql(&offset_value).unwrap();
2406        assert_eq!(fixed.to_rfc3339(), "2026-06-29T07:04:56.987654321-05:30");
2407        let utc = DateTime::<Utc>::from_sql(&offset_value).unwrap();
2408        assert_eq!(utc.to_rfc3339(), "2026-06-29T12:34:56.987654321+00:00");
2409        // NaiveDateTime mirrors python-oracledb: the wall clock (UTC + offset),
2410        // timezone dropped.
2411        let legacy_naive = NaiveDateTime::from_sql(&offset_value).unwrap();
2412        assert_eq!(legacy_naive.to_string(), "2026-06-29 07:04:56.987654321");
2413        // Round-trip consistency: converting the fixed-offset value back to a
2414        // bind must reproduce the original UTC fields + offset.
2415        assert_eq!(
2416            fixed
2417                .try_to_sql()
2418                .expect("whole-minute FixedOffset must encode"),
2419            BindValue::TimestampTz {
2420                year: 2026,
2421                month: 6,
2422                day: 29,
2423                hour: 12,
2424                minute: 34,
2425                second: 56,
2426                nanosecond: 987_654_321,
2427                offset_minutes: -330,
2428            }
2429        );
2430
2431        // Outbound binds carry UTC fields: wall 07:08:09.123 at +05:45 is UTC
2432        // 01:23:09.123, offset +345 minutes.
2433        let outbound = FixedOffset::east_opt(5 * 3600 + 45 * 60)
2434            .unwrap()
2435            .with_ymd_and_hms(2026, 6, 29, 7, 8, 9)
2436            .unwrap()
2437            .with_nanosecond(123_000_000)
2438            .unwrap();
2439        assert_eq!(
2440            outbound.try_to_sql().expect("+05:45 must encode exactly"),
2441            BindValue::TimestampTz {
2442                year: 2026,
2443                month: 6,
2444                day: 29,
2445                hour: 1,
2446                minute: 23,
2447                second: 9,
2448                nanosecond: 123_000_000,
2449                offset_minutes: 345,
2450            }
2451        );
2452        let outbound_round_trip = DateTime::<FixedOffset>::from_sql(&QueryValue::TimestampTz {
2453            year: 2026,
2454            month: 6,
2455            day: 29,
2456            hour: 1,
2457            minute: 23,
2458            second: 9,
2459            nanosecond: 123_000_000,
2460            offset_minutes: 345,
2461        })
2462        .unwrap();
2463        assert_eq!(outbound_round_trip.to_rfc3339(), outbound.to_rfc3339());
2464
2465        // -03:30 is likewise a valid whole-minute offset and must round-trip
2466        // as -210 minutes without changing the instant or its display offset.
2467        let negative_outbound = FixedOffset::west_opt(3 * 3600 + 30 * 60)
2468            .unwrap()
2469            .with_ymd_and_hms(2026, 6, 29, 7, 8, 9)
2470            .unwrap();
2471        assert_eq!(
2472            negative_outbound
2473                .try_to_sql()
2474                .expect("-03:30 must encode exactly"),
2475            BindValue::TimestampTz {
2476                year: 2026,
2477                month: 6,
2478                day: 29,
2479                hour: 10,
2480                minute: 38,
2481                second: 9,
2482                nanosecond: 0,
2483                offset_minutes: -210,
2484            }
2485        );
2486        let negative_round_trip = DateTime::<FixedOffset>::from_sql(&QueryValue::TimestampTz {
2487            year: 2026,
2488            month: 6,
2489            day: 29,
2490            hour: 10,
2491            minute: 38,
2492            second: 9,
2493            nanosecond: 0,
2494            offset_minutes: -210,
2495        })
2496        .unwrap();
2497        assert_eq!(
2498            negative_round_trip.to_rfc3339(),
2499            negative_outbound.to_rfc3339()
2500        );
2501
2502        let utc_outbound = outbound.with_timezone(&Utc);
2503        assert_eq!(
2504            utc_outbound.try_to_sql().expect("UTC must encode exactly"),
2505            BindValue::TimestampTz {
2506                year: 2026,
2507                month: 6,
2508                day: 29,
2509                hour: 1,
2510                minute: 23,
2511                second: 9,
2512                nanosecond: 123_000_000,
2513                offset_minutes: 0,
2514            }
2515        );
2516        assert_eq!(
2517            DateTime::<Utc>::from_sql(&QueryValue::TimestampTz {
2518                year: 2026,
2519                month: 6,
2520                day: 29,
2521                hour: 1,
2522                minute: 23,
2523                second: 9,
2524                nanosecond: 123_000_000,
2525                offset_minutes: 0,
2526            })
2527            .unwrap(),
2528            utc_outbound
2529        );
2530
2531        // DC5: sub-minute FixedOffset has no faithful Oracle TSTZ encoding.
2532        // Both signs must fail before a TimestampTz bind can be returned; the
2533        // diagnostic names the residual seconds that would otherwise be lost.
2534        for offset_seconds in [30, -30] {
2535            let value = FixedOffset::east_opt(offset_seconds)
2536                .unwrap()
2537                .with_ymd_and_hms(2026, 6, 29, 12, 0, 0)
2538                .unwrap();
2539            let error = value
2540                .try_to_sql()
2541                .expect_err("sub-minute FixedOffset must not emit a TimestampTz bind");
2542            match error {
2543                ConversionError::OutOfRange { expected, detail } => {
2544                    assert_eq!(expected, "Oracle TIMESTAMP WITH TIME ZONE offset");
2545                    assert!(
2546                        detail.contains(&format!("{offset_seconds:+} seconds")),
2547                        "detail must name the original offset: {detail}"
2548                    );
2549                    assert!(
2550                        detail.contains("30 sub-minute second(s)"),
2551                        "detail must name the precision that would be lost: {detail}"
2552                    );
2553                }
2554                other => panic!("expected a typed offset precision error, got {other:?}"),
2555            }
2556        }
2557    }
2558
2559    #[cfg(feature = "uuid")]
2560    #[test]
2561    fn uuid_from_raw_and_text() {
2562        use uuid::Uuid;
2563        let id = Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10);
2564        // from RAW(16)
2565        let raw = QueryValue::Raw(id.as_bytes().to_vec());
2566        assert_eq!(Uuid::from_sql(&raw).unwrap(), id);
2567        // from text
2568        let text = QueryValue::Text(id.to_string());
2569        assert_eq!(Uuid::from_sql(&text).unwrap(), id);
2570        // ToSql -> RAW(16)
2571        assert_eq!(id.to_sql(), BindValue::Raw(id.as_bytes().to_vec()));
2572    }
2573
2574    #[cfg(feature = "serde_json")]
2575    #[test]
2576    fn serde_json_from_oson_tree() {
2577        use oracledb_protocol::oson::OsonValue;
2578        use serde_json::json;
2579        let oson = OsonValue::Object(vec![
2580            ("id".into(), OsonValue::Number("7".into())),
2581            ("name".into(), OsonValue::String("bob".into())),
2582            ("active".into(), OsonValue::Bool(true)),
2583            (
2584                "tags".into(),
2585                OsonValue::Array(vec![
2586                    OsonValue::String("a".into()),
2587                    OsonValue::String("b".into()),
2588                ]),
2589            ),
2590        ]);
2591        let value = serde_json::Value::from_sql(&QueryValue::Json(Box::new(oson)))
2592            .expect("OSON tree converts to serde_json");
2593        assert_eq!(
2594            value,
2595            json!({"id": 7, "name": "bob", "active": true, "tags": ["a", "b"]})
2596        );
2597    }
2598
2599    #[cfg(feature = "serde_json")]
2600    #[test]
2601    fn serde_json_number_conversion_preserves_high_precision_text() {
2602        use oracledb_protocol::oson::OsonValue;
2603        use serde_json::{json, Value};
2604
2605        let oson = OsonValue::Object(vec![
2606            (
2607                "precise_decimal".into(),
2608                OsonValue::Number("1.234567890123456789".into()),
2609            ),
2610            (
2611                "wide_integer".into(),
2612                OsonValue::Number("99999999999999999999999999999999999999".into()),
2613            ),
2614            ("small_int".into(), OsonValue::Number("42".into())),
2615            ("small_decimal".into(), OsonValue::Number("12.5".into())),
2616        ]);
2617
2618        let value = serde_json::Value::from_sql(&QueryValue::Json(Box::new(oson)))
2619            .expect("OSON tree converts to serde_json");
2620        assert_eq!(
2621            value,
2622            json!({
2623                "precise_decimal": "1.234567890123456789",
2624                "wide_integer": "99999999999999999999999999999999999999",
2625                "small_int": 42,
2626                "small_decimal": 12.5
2627            })
2628        );
2629        assert!(matches!(value["small_int"], Value::Number(_)));
2630        assert!(matches!(value["small_decimal"], Value::Number(_)));
2631    }
2632
2633    #[test]
2634    fn named_binds_reorder_to_first_appearance() {
2635        // :a appears before :b in the SQL, but the params! list is in the
2636        // opposite order. order_named_binds must reorder to [a=1, b=2].
2637        let named = params! { ":b" => 2_i64, ":a" => 1_i64 };
2638        let ordered = order_named_binds("select * from t where a = :a and b = :b", named)
2639            .expect("named binds should reorder");
2640        assert_eq!(
2641            ordered,
2642            vec![BindValue::Number("1".into()), BindValue::Number("2".into())]
2643        );
2644    }
2645
2646    #[test]
2647    fn named_binds_repeated_placeholder_counts_once() {
2648        // :id used twice should appear once in the ordering.
2649        let named = params! { ":id" => 5_i64, ":name" => "x" };
2650        let ordered = order_named_binds("select :id from t where id = :id and name = :name", named)
2651            .expect("named binds should coalesce repeated placeholders");
2652        assert_eq!(
2653            ordered,
2654            vec![BindValue::Number("5".into()), BindValue::Text("x".into())]
2655        );
2656    }
2657
2658    #[test]
2659    fn named_binds_ignore_colon_in_string_literal() {
2660        // The ':not_a_bind' inside the literal must not be treated as a
2661        // placeholder; only :real is.
2662        let named = params! { ":real" => 9_i64 };
2663        let ordered = order_named_binds("select 'time is 12:30' as t, :real from dual", named)
2664            .expect("bind in string literal should be ignored");
2665        assert_eq!(ordered, vec![BindValue::Number("9".into())]);
2666    }
2667
2668    #[test]
2669    fn vector_from_sql_f32_f64() {
2670        let vector = QueryValue::Vector(Box::new(Vector::Dense(VectorValues::Float32(vec![
2671            1.0, 2.0, 3.0,
2672        ]))));
2673        assert_eq!(Vec::<f32>::from_sql(&vector).unwrap(), vec![1.0, 2.0, 3.0]);
2674        assert_eq!(Vec::<f64>::from_sql(&vector).unwrap(), vec![1.0, 2.0, 3.0]);
2675        // ToSql round-trips Vec<f32> into a dense float32 vector bind.
2676        assert_eq!(
2677            vec![1.0_f32, 2.0, 3.0].to_sql(),
2678            BindValue::Vector(Vector::Dense(VectorValues::Float32(vec![1.0, 2.0, 3.0])))
2679        );
2680    }
2681}