use std::borrow::Cow;
use oracledb_protocol::sql;
use oracledb_protocol::thin::{BindValue, ColumnMetadata, QueryResult, QueryValue};
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConversionError {
UnexpectedNull,
TypeMismatch {
expected: &'static str,
found: &'static str,
},
OutOfRange {
expected: &'static str,
detail: String,
},
}
impl std::fmt::Display for ConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConversionError::UnexpectedNull => {
write!(f, "value is SQL NULL but the target type is not Option<_>")
}
ConversionError::TypeMismatch { expected, found } => {
write!(f, "cannot convert Oracle {found} into {expected}")
}
ConversionError::OutOfRange { expected, detail } => {
write!(f, "value does not fit {expected}: {detail}")
}
}
}
}
impl std::error::Error for ConversionError {}
impl From<ConversionError> for crate::Error {
fn from(err: ConversionError) -> Self {
crate::Error::Conversion(err)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum BindError {
Sql(sql::SqlError),
PositionalCountMismatch { expected: usize, actual: usize },
MissingNamedBind { name: String },
ExtraNamedBind { name: String },
BatchRowWidthMismatch {
row_index: usize,
expected: usize,
actual: usize,
},
}
impl std::fmt::Display for BindError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BindError::Sql(err) => write!(f, "SQL bind scan failed: {err}"),
BindError::PositionalCountMismatch { expected, actual } => write!(
f,
"SQL expects {expected} bind values but {actual} were supplied"
),
BindError::MissingNamedBind { name } => {
write!(f, "missing value for named bind :{name}")
}
BindError::ExtraNamedBind { name } => {
write!(f, "named bind {name} is not referenced by the SQL")
}
BindError::BatchRowWidthMismatch {
row_index,
expected,
actual,
} => write!(
f,
"batch row {row_index} has {actual} bind values; expected {expected}"
),
}
}
}
impl std::error::Error for BindError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
BindError::Sql(err) => Some(err),
_ => None,
}
}
}
impl From<sql::SqlError> for BindError {
fn from(err: sql::SqlError) -> Self {
BindError::Sql(err)
}
}
fn value_kind(value: &QueryValue) -> &'static str {
match value {
QueryValue::Text(_) => "VARCHAR2/CHAR text",
QueryValue::TextRaw { .. } => "undecodable character data",
QueryValue::Raw(_) => "RAW",
QueryValue::Rowid(_) => "ROWID",
QueryValue::BinaryDouble(_) => "BINARY_DOUBLE/BINARY_FLOAT",
QueryValue::IntervalDS { .. } => "INTERVAL DAY TO SECOND",
QueryValue::IntervalYM { .. } => "INTERVAL YEAR TO MONTH",
QueryValue::Number(_) => "NUMBER",
QueryValue::Boolean(_) => "BOOLEAN",
QueryValue::Cursor(_) => "REF CURSOR",
QueryValue::DateTime { .. } => "DATE/TIMESTAMP",
QueryValue::Object(_) => "object/ADT",
QueryValue::Lob(_) => "LOB locator",
QueryValue::Vector(_) => "VECTOR",
QueryValue::Json(_) => "JSON",
QueryValue::Array(_) => "collection",
_ => "Oracle value",
}
}
fn mismatch<T>(expected: &'static str, value: &QueryValue) -> Result<T, ConversionError> {
Err(ConversionError::TypeMismatch {
expected,
found: value_kind(value),
})
}
pub trait FromSql: Sized {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError>;
}
impl FromSql for i64 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Number(num) => num.to_i64().ok_or_else(|| ConversionError::OutOfRange {
expected: "i64",
detail: format!(
"NUMBER {:?} is not an integer that fits i64",
num.to_canonical_string()
),
}),
QueryValue::Boolean(b) => Ok(i64::from(*b)),
other => mismatch("i64", other),
}
}
}
impl FromSql for i128 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Number(num) => num.to_i128().ok_or_else(|| ConversionError::OutOfRange {
expected: "i128",
detail: format!(
"NUMBER {:?} is not an integer that fits i128",
num.to_canonical_string()
),
}),
QueryValue::Boolean(b) => Ok(i128::from(*b)),
other => mismatch("i128", other),
}
}
}
impl FromSql for i32 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
let wide = i64::from_sql(value)?;
i32::try_from(wide).map_err(|_| ConversionError::OutOfRange {
expected: "i32",
detail: format!("{wide} is out of range for i32"),
})
}
}
impl FromSql for u32 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
let wide = i64::from_sql(value)?;
u32::try_from(wide).map_err(|_| ConversionError::OutOfRange {
expected: "u32",
detail: format!("{wide} is out of range for u32"),
})
}
}
impl FromSql for f64 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Number(num) => {
let text = num.to_canonical_string();
text.parse::<f64>()
.map_err(|_| ConversionError::OutOfRange {
expected: "f64",
detail: format!("{text:?} is not a finite f64"),
})
}
QueryValue::BinaryDouble(text) => {
text.trim()
.parse::<f64>()
.map_err(|_| ConversionError::OutOfRange {
expected: "f64",
detail: format!("{text:?} is not a finite f64"),
})
}
other => mismatch("f64", other),
}
}
}
impl FromSql for f32 {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
let wide = f64::from_sql(value)?;
let narrowed = wide as f32;
if !narrowed.is_finite() {
return Err(ConversionError::OutOfRange {
expected: "f32",
detail: format!("{wide:?} is out of range for f32"),
});
}
Ok(narrowed)
}
}
impl FromSql for bool {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Boolean(b) => Ok(*b),
QueryValue::Number(num) => match num.to_i64() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err(ConversionError::OutOfRange {
expected: "bool",
detail: format!("NUMBER {:?} is neither 0 nor 1", num.to_canonical_string()),
}),
},
other => mismatch("bool", other),
}
}
}
impl FromSql for String {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Text(s) => Ok(s.clone()),
QueryValue::Rowid(s) => Ok(s.clone()),
QueryValue::Number(num) => Ok(num.to_canonical_string()),
QueryValue::BinaryDouble(text) => Ok(text.clone()),
other => mismatch("String", other),
}
}
}
impl FromSql for Vec<u8> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Raw(bytes) => Ok(bytes.clone()),
QueryValue::TextRaw { bytes, .. } => Ok(bytes.clone()),
other => mismatch("Vec<u8>", other),
}
}
}
impl<T: FromSql> FromSql for Option<T> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
T::from_sql(value).map(Some)
}
}
#[cfg(feature = "chrono")]
mod chrono_impls {
use super::{mismatch, ConversionError, FromSql};
use chrono::{
DateTime, Duration as ChronoDuration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime,
TimeZone, Utc,
};
use oracledb_protocol::thin::QueryValue;
fn naive_from_components(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
nanosecond: u32,
) -> Result<NaiveDateTime, ConversionError> {
let date =
NaiveDate::from_ymd_opt(year, u32::from(month), u32::from(day)).ok_or_else(|| {
ConversionError::OutOfRange {
expected: "chrono::NaiveDateTime",
detail: format!("invalid date {year:04}-{month:02}-{day:02}"),
}
})?;
let time = NaiveTime::from_hms_nano_opt(
u32::from(hour),
u32::from(minute),
u32::from(second),
nanosecond,
)
.ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::NaiveDateTime",
detail: format!("invalid time {hour:02}:{minute:02}:{second:02}.{nanosecond:09}"),
})?;
Ok(NaiveDateTime::new(date, time))
}
impl FromSql for NaiveDateTime {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::DateTime {
year,
month,
day,
hour,
minute,
second,
nanosecond,
} => {
naive_from_components(*year, *month, *day, *hour, *minute, *second, *nanosecond)
}
QueryValue::TimestampTz {
year,
month,
day,
hour,
minute,
second,
nanosecond,
offset_minutes,
} => {
let naive = naive_from_components(
*year,
*month,
*day,
*hour,
*minute,
*second,
*nanosecond,
)?;
naive
.checked_add_signed(ChronoDuration::minutes(i64::from(*offset_minutes)))
.ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::NaiveDateTime",
detail: "TIMESTAMP WITH TIME ZONE offset adjustment overflow"
.to_string(),
})
}
other => mismatch("chrono::NaiveDateTime", other),
}
}
}
impl FromSql for NaiveDate {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::DateTime {
year, month, day, ..
} => NaiveDate::from_ymd_opt(*year, u32::from(*month), u32::from(*day)).ok_or_else(
|| ConversionError::OutOfRange {
expected: "chrono::NaiveDate",
detail: format!("invalid date {year:04}-{month:02}-{day:02}"),
},
),
QueryValue::TimestampTz { .. } => {
let datetime = NaiveDateTime::from_sql(value)?;
Ok(datetime.date())
}
other => mismatch("chrono::NaiveDate", other),
}
}
}
fn fixed_offset_from_minutes(offset_minutes: i32) -> Result<FixedOffset, ConversionError> {
let seconds =
offset_minutes
.checked_mul(60)
.ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::FixedOffset",
detail: format!("offset minutes {offset_minutes} overflow seconds"),
})?;
FixedOffset::east_opt(seconds).ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::FixedOffset",
detail: format!("offset minutes {offset_minutes} out of chrono range"),
})
}
impl FromSql for DateTime<FixedOffset> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::TimestampTz {
year,
month,
day,
hour,
minute,
second,
nanosecond,
offset_minutes,
} => {
let naive = naive_from_components(
*year,
*month,
*day,
*hour,
*minute,
*second,
*nanosecond,
)?;
fixed_offset_from_minutes(*offset_minutes)?
.from_local_datetime(&naive)
.single()
.ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::DateTime<FixedOffset>",
detail: "invalid fixed-offset local datetime".to_string(),
})
}
QueryValue::DateTime {
year,
month,
day,
hour,
minute,
second,
nanosecond,
} => {
let naive = naive_from_components(
*year,
*month,
*day,
*hour,
*minute,
*second,
*nanosecond,
)?;
fixed_offset_from_minutes(0)?
.from_local_datetime(&naive)
.single()
.ok_or_else(|| ConversionError::OutOfRange {
expected: "chrono::DateTime<FixedOffset>",
detail: "invalid UTC local datetime".to_string(),
})
}
other => mismatch("chrono::DateTime<FixedOffset>", other),
}
}
}
impl FromSql for DateTime<Utc> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
DateTime::<FixedOffset>::from_sql(value).map(|datetime| datetime.with_timezone(&Utc))
}
}
}
#[cfg(feature = "uuid")]
mod uuid_impls {
use super::{mismatch, ConversionError, FromSql};
use oracledb_protocol::thin::QueryValue;
use uuid::Uuid;
impl FromSql for Uuid {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Raw(bytes) => {
let array: [u8; 16] =
bytes
.as_slice()
.try_into()
.map_err(|_| ConversionError::OutOfRange {
expected: "uuid::Uuid",
detail: format!("RAW length {} is not 16 bytes", bytes.len()),
})?;
Ok(Uuid::from_bytes(array))
}
QueryValue::Text(text) => {
Uuid::parse_str(text.trim()).map_err(|err| ConversionError::OutOfRange {
expected: "uuid::Uuid",
detail: format!("text {text:?} is not a UUID: {err}"),
})
}
other => mismatch("uuid::Uuid", other),
}
}
}
}
#[cfg(feature = "serde_json")]
mod serde_json_impls {
use super::{mismatch, ConversionError, FromSql};
use oracledb_protocol::oson::OsonValue;
use oracledb_protocol::thin::QueryValue;
use serde_json::{Map, Number, Value};
fn oson_to_json(node: &OsonValue) -> Value {
match node {
OsonValue::Null => Value::Null,
OsonValue::Bool(b) => Value::Bool(*b),
OsonValue::Number(text) => number_to_json(text),
OsonValue::BinaryFloat(v) => f64_to_json(f64::from(*v)),
OsonValue::BinaryDouble(v) => f64_to_json(*v),
OsonValue::String(s) => Value::String(s.clone()),
OsonValue::Raw(bytes) => Value::String(hex_encode(bytes)),
OsonValue::DateTime {
year,
month,
day,
hour,
minute,
second,
nanosecond,
} => Value::String(format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanosecond:09}"
)),
OsonValue::IntervalDS {
days,
hours,
minutes,
seconds,
fseconds,
} => Value::String(format!(
"P{days}DT{hours}H{minutes}M{seconds}.{fseconds:09}S"
)),
OsonValue::Vector(_) => Value::String("<vector>".to_string()),
OsonValue::Array(items) => Value::Array(items.iter().map(oson_to_json).collect()),
OsonValue::Object(entries) => {
let mut map = Map::with_capacity(entries.len());
for (key, val) in entries {
map.insert(key.clone(), oson_to_json(val));
}
Value::Object(map)
}
}
}
fn number_to_json(text: &str) -> Value {
let trimmed = text.trim();
if let Ok(i) = trimmed.parse::<i64>() {
return Value::Number(Number::from(i));
}
if let Ok(u) = trimmed.parse::<u64>() {
return Value::Number(Number::from(u));
}
if significant_digit_count(trimmed) <= 15 {
if let Ok(f) = trimmed.parse::<f64>() {
if let Some(n) = Number::from_f64(f) {
return Value::Number(n);
}
}
}
Value::String(trimmed.to_string())
}
fn significant_digit_count(text: &str) -> usize {
let mantissa = text
.split_once(['e', 'E'])
.map_or(text, |(mantissa, _)| mantissa)
.trim_start_matches(['+', '-']);
let mut seen_non_zero = false;
let mut count = 0usize;
for ch in mantissa.chars().filter(|ch| ch.is_ascii_digit()) {
if ch != '0' || seen_non_zero {
seen_non_zero = true;
count += 1;
}
}
count
}
fn f64_to_json(v: f64) -> Value {
Number::from_f64(v).map_or_else(|| Value::String(v.to_string()), Value::Number)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
impl FromSql for Value {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Json(oson) => Ok(oson_to_json(oson)),
QueryValue::Text(text) => {
serde_json::from_str(text).map_err(|err| ConversionError::OutOfRange {
expected: "serde_json::Value",
detail: format!("text is not valid JSON: {err}"),
})
}
other => mismatch("serde_json::Value", other),
}
}
}
}
#[cfg(feature = "rust_decimal")]
mod rust_decimal_impls {
use super::{mismatch, ConversionError, FromSql};
use oracledb_protocol::thin::QueryValue;
use rust_decimal::Decimal;
use std::str::FromStr;
impl FromSql for Decimal {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Number(num) => {
if let (Some(coefficient), Some(scale)) = (num.coefficient(), num.scale()) {
if (0..=28).contains(&scale) {
if let Ok(dec) = Decimal::try_from_i128_with_scale(
coefficient,
u32::from(scale as u16),
) {
return Ok(dec);
}
}
}
let text = num.to_canonical_string();
Decimal::from_str(&text).or_else(|_| {
Decimal::from_scientific(&text).map_err(|err| ConversionError::OutOfRange {
expected: "rust_decimal::Decimal",
detail: format!("NUMBER {text:?} does not fit Decimal: {err}"),
})
})
}
other => mismatch("rust_decimal::Decimal", other),
}
}
}
}
use oracledb_protocol::vector::{Vector, VectorValues};
impl FromSql for Vec<f32> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Vector(vector) => vector_to_f32(vector),
other => mismatch("Vec<f32>", other),
}
}
}
impl FromSql for Vec<f64> {
fn from_sql(value: &QueryValue) -> Result<Self, ConversionError> {
match value {
QueryValue::Vector(vector) => vector_to_f64(vector),
other => mismatch("Vec<f64>", other),
}
}
}
fn dense_values(vector: &Vector) -> Result<&VectorValues, ConversionError> {
match vector {
Vector::Dense(values) => Ok(values),
Vector::Sparse { .. } => Err(ConversionError::OutOfRange {
expected: "Vec<element>",
detail: "sparse VECTOR cannot be read as a dense Vec".to_string(),
}),
}
}
fn vector_to_f32(vector: &Vector) -> Result<Vec<f32>, ConversionError> {
match dense_values(vector)? {
VectorValues::Float32(v) => Ok(v.clone()),
VectorValues::Float64(v) => Ok(v.iter().map(|x| *x as f32).collect()),
VectorValues::Int8(v) => Ok(v.iter().map(|x| f32::from(*x)).collect()),
VectorValues::Binary(_) => Err(ConversionError::OutOfRange {
expected: "Vec<f32>",
detail: "BINARY-format VECTOR has no float elements".to_string(),
}),
}
}
fn vector_to_f64(vector: &Vector) -> Result<Vec<f64>, ConversionError> {
match dense_values(vector)? {
VectorValues::Float64(v) => Ok(v.clone()),
VectorValues::Float32(v) => Ok(v.iter().map(|x| f64::from(*x)).collect()),
VectorValues::Int8(v) => Ok(v.iter().map(|x| f64::from(*x)).collect()),
VectorValues::Binary(_) => Err(ConversionError::OutOfRange {
expected: "Vec<f64>",
detail: "BINARY-format VECTOR has no float elements".to_string(),
}),
}
}
pub trait QueryResultExt {
fn get<T: FromSql>(&self, row: usize, col: usize) -> crate::Result<T>;
fn get_by_name<T: FromSql>(&self, row: usize, name: &str) -> crate::Result<T>;
fn typed_row(&self, row: usize) -> TypedRow<'_>;
fn rows_as<T: FromRow>(&self) -> crate::Result<Vec<T>>;
}
fn convert_cell<T: FromSql>(cell: Option<&Option<QueryValue>>, what: String) -> crate::Result<T> {
convert_cell_ce(cell, what).map_err(crate::Error::Conversion)
}
fn convert_cell_ce<T: FromSql>(
cell: Option<&Option<QueryValue>>,
what: String,
) -> Result<T, ConversionError> {
match cell {
None => Err(ConversionError::OutOfRange {
expected: std::any::type_name::<T>(),
detail: what,
}),
Some(None) => Err(ConversionError::UnexpectedNull),
Some(Some(value)) => T::from_sql(value),
}
}
fn convert_cell_opt_ce<T: FromSql>(
cell: Option<&Option<QueryValue>>,
what: String,
) -> Result<Option<T>, ConversionError> {
match cell {
None => Err(ConversionError::OutOfRange {
expected: std::any::type_name::<Option<T>>(),
detail: what,
}),
Some(None) => Ok(None),
Some(Some(value)) => T::from_sql(value).map(Some),
}
}
impl QueryResultExt for QueryResult {
fn get<T: FromSql>(&self, row: usize, col: usize) -> crate::Result<T> {
let cell = self.rows.get(row).and_then(|r| r.get(col));
convert_cell(cell, format!("no cell at (row {row}, col {col})"))
}
fn get_by_name<T: FromSql>(&self, row: usize, name: &str) -> crate::Result<T> {
match self.column_index(name) {
Some(col) => self.get(row, col),
None => Err(crate::Error::Conversion(ConversionError::OutOfRange {
expected: std::any::type_name::<T>(),
detail: format!("no column named {name:?}"),
})),
}
}
fn typed_row(&self, row: usize) -> TypedRow<'_> {
let cells = self.rows.get(row).map_or(&[][..], Vec::as_slice);
TypedRow::new(&self.columns, cells, row)
}
fn rows_as<T: FromRow>(&self) -> crate::Result<Vec<T>> {
let mut out = Vec::with_capacity(self.rows.len());
for row in 0..self.rows.len() {
out.push(T::from_row(&self.typed_row(row))?);
}
Ok(out)
}
}
#[derive(Clone, Copy)]
pub struct TypedRow<'a> {
columns: &'a [ColumnMetadata],
cells: &'a [Option<QueryValue>],
row: usize,
}
impl TypedRow<'_> {
pub(crate) fn new<'a>(
columns: &'a [ColumnMetadata],
cells: &'a [Option<QueryValue>],
row: usize,
) -> TypedRow<'a> {
TypedRow {
columns,
cells,
row,
}
}
pub fn get<T: FromSql>(&self, col: usize) -> crate::Result<T> {
convert_cell(
self.cell_at(col),
format!("no cell at (row {}, col {col})", self.row),
)
}
pub fn get_by_name<T: FromSql>(&self, name: &str) -> crate::Result<T> {
match column_index(self.columns, name) {
Some(col) => self.get(col),
None => Err(crate::Error::Conversion(ConversionError::OutOfRange {
expected: std::any::type_name::<T>(),
detail: format!("no column named {name:?}"),
})),
}
}
fn cell_at(&self, col: usize) -> Option<&Option<QueryValue>> {
self.cells.get(col)
}
pub fn try_get<T: FromSql>(&self, col: usize) -> Result<T, ConversionError> {
convert_cell_ce(
self.cell_at(col),
format!("no cell at (row {}, col {col})", self.row),
)
}
pub fn try_get_opt<T: FromSql>(&self, col: usize) -> Result<Option<T>, ConversionError> {
convert_cell_opt_ce(
self.cell_at(col),
format!("no cell at (row {}, col {col})", self.row),
)
}
pub fn try_get_by_name<T: FromSql>(&self, name: &str) -> Result<T, ConversionError> {
match column_index(self.columns, name) {
Some(col) => self.try_get(col),
None => Err(ConversionError::OutOfRange {
expected: std::any::type_name::<T>(),
detail: format!("no column named {name:?}"),
}),
}
}
pub fn try_get_by_name_opt<T: FromSql>(
&self,
name: &str,
) -> Result<Option<T>, ConversionError> {
match column_index(self.columns, name) {
Some(col) => self.try_get_opt(col),
None => Err(ConversionError::OutOfRange {
expected: std::any::type_name::<Option<T>>(),
detail: format!("no column named {name:?}"),
}),
}
}
}
fn column_index(columns: &[ColumnMetadata], name: &str) -> Option<usize> {
columns
.iter()
.position(|col| col.name().eq_ignore_ascii_case(name))
}
pub trait FromRow: Sized {
fn from_row(row: &TypedRow<'_>) -> Result<Self, ConversionError>;
}
pub trait ToSql {
fn to_sql(&self) -> BindValue;
}
impl ToSql for i64 {
fn to_sql(&self) -> BindValue {
BindValue::Number(self.to_string())
}
}
impl ToSql for i32 {
fn to_sql(&self) -> BindValue {
BindValue::Number(self.to_string())
}
}
impl ToSql for u32 {
fn to_sql(&self) -> BindValue {
BindValue::Number(self.to_string())
}
}
impl ToSql for f64 {
fn to_sql(&self) -> BindValue {
BindValue::BinaryDouble(*self)
}
}
impl ToSql for f32 {
fn to_sql(&self) -> BindValue {
BindValue::BinaryFloat(f64::from(*self))
}
}
impl ToSql for bool {
fn to_sql(&self) -> BindValue {
BindValue::Boolean(*self)
}
}
impl ToSql for str {
fn to_sql(&self) -> BindValue {
BindValue::Text(self.to_string())
}
}
impl ToSql for String {
fn to_sql(&self) -> BindValue {
BindValue::Text(self.clone())
}
}
impl ToSql for [u8] {
fn to_sql(&self) -> BindValue {
BindValue::Raw(self.to_vec())
}
}
impl ToSql for Vec<u8> {
fn to_sql(&self) -> BindValue {
BindValue::Raw(self.clone())
}
}
impl<T: ToSql + ?Sized> ToSql for &T {
fn to_sql(&self) -> BindValue {
(**self).to_sql()
}
}
impl<T: ToSql> ToSql for Option<T> {
fn to_sql(&self) -> BindValue {
match self {
Some(value) => value.to_sql(),
None => BindValue::Null,
}
}
}
#[cfg(feature = "chrono")]
mod chrono_to_sql {
use super::ToSql;
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, Timelike, Utc};
use oracledb_protocol::thin::BindValue;
impl ToSql for NaiveDateTime {
fn to_sql(&self) -> BindValue {
BindValue::Timestamp {
ora_type_num: 180,
year: self.year(),
month: self.month() as u8,
day: self.day() as u8,
hour: self.hour() as u8,
minute: self.minute() as u8,
second: self.second() as u8,
nanosecond: self.nanosecond(),
}
}
}
impl ToSql for NaiveDate {
fn to_sql(&self) -> BindValue {
BindValue::DateTime {
year: self.year(),
month: self.month() as u8,
day: self.day() as u8,
hour: 0,
minute: 0,
second: 0,
}
}
}
impl ToSql for DateTime<FixedOffset> {
fn to_sql(&self) -> BindValue {
BindValue::TimestampTz {
year: self.year(),
month: self.month() as u8,
day: self.day() as u8,
hour: self.hour() as u8,
minute: self.minute() as u8,
second: self.second() as u8,
nanosecond: self.nanosecond(),
offset_minutes: self.offset().local_minus_utc() / 60,
}
}
}
impl ToSql for DateTime<Utc> {
fn to_sql(&self) -> BindValue {
BindValue::TimestampTz {
year: self.year(),
month: self.month() as u8,
day: self.day() as u8,
hour: self.hour() as u8,
minute: self.minute() as u8,
second: self.second() as u8,
nanosecond: self.nanosecond(),
offset_minutes: 0,
}
}
}
}
#[cfg(feature = "uuid")]
mod uuid_to_sql {
use super::ToSql;
use oracledb_protocol::thin::BindValue;
use uuid::Uuid;
impl ToSql for Uuid {
fn to_sql(&self) -> BindValue {
BindValue::Raw(self.as_bytes().to_vec())
}
}
}
#[cfg(feature = "serde_json")]
mod serde_json_to_sql {
use super::ToSql;
use oracledb_protocol::thin::BindValue;
use serde_json::Value;
impl ToSql for Value {
fn to_sql(&self) -> BindValue {
BindValue::Text(self.to_string())
}
}
}
#[cfg(feature = "rust_decimal")]
mod rust_decimal_to_sql {
use super::ToSql;
use oracledb_protocol::thin::BindValue;
use rust_decimal::Decimal;
impl ToSql for Decimal {
fn to_sql(&self) -> BindValue {
BindValue::Number(self.to_string())
}
}
}
impl ToSql for Vec<f32> {
fn to_sql(&self) -> BindValue {
BindValue::Vector(Vector::Dense(VectorValues::Float32(self.clone())))
}
}
impl ToSql for [f32] {
fn to_sql(&self) -> BindValue {
BindValue::Vector(Vector::Dense(VectorValues::Float32(self.to_vec())))
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Params<'a> {
#[default]
None,
Positional(Cow<'a, [BindValue]>),
Named(Cow<'a, [(String, BindValue)]>),
}
impl<'a, T: IntoBinds> From<T> for Params<'a> {
fn from(value: T) -> Self {
Params::Positional(Cow::Owned(value.into_binds()))
}
}
impl<'a> From<&'a [BindValue]> for Params<'a> {
fn from(value: &'a [BindValue]) -> Self {
Params::Positional(Cow::Borrowed(value))
}
}
impl<'a> From<&'a Vec<BindValue>> for Params<'a> {
fn from(value: &'a Vec<BindValue>) -> Self {
Params::from(value.as_slice())
}
}
impl<'a> From<Vec<(String, BindValue)>> for Params<'a> {
fn from(value: Vec<(String, BindValue)>) -> Self {
Params::Named(Cow::Owned(value))
}
}
impl<'a> From<&'a [(String, BindValue)]> for Params<'a> {
fn from(value: &'a [(String, BindValue)]) -> Self {
Params::Named(Cow::Borrowed(value))
}
}
impl<'a> From<&'a Vec<(String, BindValue)>> for Params<'a> {
fn from(value: &'a Vec<(String, BindValue)>) -> Self {
Params::from(value.as_slice())
}
}
pub trait IntoBinds {
fn into_binds(self) -> Vec<BindValue>;
}
impl IntoBinds for Vec<BindValue> {
fn into_binds(self) -> Vec<BindValue> {
self
}
}
impl IntoBinds for () {
fn into_binds(self) -> Vec<BindValue> {
Vec::new()
}
}
impl<T: ToSql> IntoBinds for Vec<T> {
fn into_binds(self) -> Vec<BindValue> {
self.iter().map(ToSql::to_sql).collect()
}
}
impl<T: ToSql, const N: usize> IntoBinds for [T; N] {
fn into_binds(self) -> Vec<BindValue> {
self.iter().map(ToSql::to_sql).collect()
}
}
macro_rules! impl_into_binds_tuple {
($($name:ident),+) => {
impl<$($name: ToSql),+> IntoBinds for ($($name,)+) {
fn into_binds(self) -> Vec<BindValue> {
#[allow(non_snake_case)]
let ($($name,)+) = self;
vec![$($name.to_sql()),+]
}
}
};
}
impl_into_binds_tuple!(A);
impl_into_binds_tuple!(A, B);
impl_into_binds_tuple!(A, B, C);
impl_into_binds_tuple!(A, B, C, D);
impl_into_binds_tuple!(A, B, C, D, E);
impl_into_binds_tuple!(A, B, C, D, E, F);
impl_into_binds_tuple!(A, B, C, D, E, F, G);
impl_into_binds_tuple!(A, B, C, D, E, F, G, H);
impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I);
impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J);
impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J, K);
impl_into_binds_tuple!(A, B, C, D, E, F, G, H, I, J, K, L);
#[macro_export]
macro_rules! params {
($($name:expr => $value:expr),+ $(,)?) => {{
let binds: ::std::vec::Vec<(::std::string::String, $crate::protocol::thin::BindValue)> =
::std::vec![$(
(::std::string::String::from($name), $crate::ToSql::to_sql(&$value))
),+];
binds
}};
($($value:expr),+ $(,)?) => {{
let binds: ::std::vec::Vec<$crate::protocol::thin::BindValue> =
::std::vec![$( $crate::ToSql::to_sql(&$value) ),+];
binds
}};
}
pub(crate) fn order_named_binds(
sql: &str,
named: Vec<(String, BindValue)>,
) -> Result<Vec<BindValue>, BindError> {
if !bind_shape_validation_enabled(sql) {
return Ok(named.into_iter().map(|(_, value)| value).collect());
}
let order = sql::unique_bind_names(sql)?;
let mut remaining = named;
let mut out = Vec::with_capacity(remaining.len());
for placeholder in &order {
if let Some(pos) = remaining
.iter()
.position(|(name, _)| sql::bind_name_matches_key(placeholder, name))
{
let (_, value) = remaining.remove(pos);
out.push(value);
} else {
return Err(BindError::MissingNamedBind {
name: placeholder.clone(),
});
}
}
if let Some((name, _)) = remaining.first() {
return Err(BindError::ExtraNamedBind { name: name.clone() });
}
Ok(out)
}
pub(crate) fn resolve_params(sql: &str, params: Params<'_>) -> Result<Vec<BindValue>, BindError> {
match params {
Params::None => {
validate_positional_bind_count(sql, 0)?;
Ok(Vec::new())
}
Params::Positional(binds) => {
let binds = binds.into_owned();
validate_positional_bind_count(sql, binds.len())?;
Ok(binds)
}
Params::Named(named) => order_named_binds(sql, named.into_owned()),
}
}
pub(crate) fn validate_bind_rows_shape(
_sql: &str,
bind_rows: &[Vec<BindValue>],
) -> Result<(), BindError> {
let Some(first_row) = bind_rows.first() else {
return Ok(());
};
let expected = first_row.len();
for (row_index, row) in bind_rows.iter().enumerate().skip(1) {
if row.len() != expected {
return Err(BindError::BatchRowWidthMismatch {
row_index,
expected,
actual: row.len(),
});
}
}
Ok(())
}
pub(crate) fn validate_positional_bind_count(sql: &str, actual: usize) -> Result<(), BindError> {
if !bind_shape_validation_enabled(sql) {
return Ok(());
}
let expected = sql::bind_names_per_occurrence(sql)?.len();
if expected != actual {
return Err(BindError::PositionalCountMismatch { expected, actual });
}
Ok(())
}
fn bind_shape_validation_enabled(sql: &str) -> bool {
!sql::statement_is_ddl(sql)
}
#[cfg(test)]
mod tests {
use super::*;
use oracledb_protocol::thin::ColumnMetadata;
fn num(text: &str) -> QueryValue {
QueryValue::number_from_text(text, !text.contains('.'))
}
#[test]
fn core_from_sql_scalars() {
assert_eq!(i64::from_sql(&num("42")).unwrap(), 42);
assert_eq!(i32::from_sql(&num("42")).unwrap(), 42);
assert_eq!(u32::from_sql(&num("42")).unwrap(), 42);
assert_eq!(f64::from_sql(&num("2.5")).unwrap(), 2.5);
assert_eq!(f32::from_sql(&num("2.5")).unwrap(), 2.5_f32);
assert!(bool::from_sql(&QueryValue::Boolean(true)).unwrap());
assert!(bool::from_sql(&num("1")).unwrap());
assert_eq!(
String::from_sql(&QueryValue::Text("hi".into())).unwrap(),
"hi"
);
assert_eq!(
Vec::<u8>::from_sql(&QueryValue::Raw(vec![1, 2, 3])).unwrap(),
vec![1, 2, 3]
);
}
#[test]
fn f32_from_sql_rejects_number_overflow() {
let err = f32::from_sql(&num("1e39")).expect_err("finite f64 outside f32 range must fail");
assert!(matches!(
err,
ConversionError::OutOfRange {
expected: "f32",
..
}
));
assert_eq!(
f32::from_sql(&num("1.5")).expect("normal f32 value must convert"),
1.5_f32
);
}
#[test]
fn i128_from_sql_is_exact_beyond_i64() {
let big = "123456789012345678901234567890";
assert_eq!(
i128::from_sql(&num(big)).unwrap(),
123_456_789_012_345_678_901_234_567_890_i128
);
assert!(matches!(
i64::from_sql(&num(big)).unwrap_err(),
ConversionError::OutOfRange { .. }
));
assert!(matches!(
i128::from_sql(&num("3.14")).unwrap_err(),
ConversionError::OutOfRange { .. }
));
}
#[test]
fn string_from_sql_is_canonical_byte_exact() {
for text in ["0", "-1", "2.5", "100", "0.001", "12345678901234567890"] {
assert_eq!(String::from_sql(&num(text)).unwrap(), text);
}
}
#[test]
fn from_sql_errors_are_typed() {
let err = i64::from_sql(&QueryValue::Text("x".into())).unwrap_err();
assert!(matches!(err, ConversionError::TypeMismatch { .. }));
let err = i32::from_sql(&num("9999999999")).unwrap_err();
assert!(matches!(err, ConversionError::OutOfRange { .. }));
}
#[test]
fn option_accepts_any() {
let v: Option<i64> = Option::<i64>::from_sql(&num("7")).unwrap();
assert_eq!(v, Some(7));
}
#[test]
fn core_to_sql_scalars() {
assert_eq!(40_i64.to_sql(), BindValue::Number("40".into()));
assert_eq!(40_i32.to_sql(), BindValue::Number("40".into()));
assert_eq!(2.5_f64.to_sql(), BindValue::BinaryDouble(2.5));
assert_eq!(true.to_sql(), BindValue::Boolean(true));
assert_eq!("alice".to_sql(), BindValue::Text("alice".into()));
assert_eq!(vec![1u8, 2, 3].to_sql(), BindValue::Raw(vec![1, 2, 3]));
let none: Option<i64> = None;
assert_eq!(none.to_sql(), BindValue::Null);
}
#[test]
fn into_binds_tuple_and_slice() {
let binds = (40_i64, "alice").into_binds();
assert_eq!(
binds,
vec![
BindValue::Number("40".into()),
BindValue::Text("alice".into())
]
);
let binds = [1_i64, 2, 3].into_binds();
assert_eq!(binds.len(), 3);
}
#[test]
fn params_macro_positional_and_named() {
let positional = params![40_i64, "alice"];
assert_eq!(
positional,
vec![
BindValue::Number("40".into()),
BindValue::Text("alice".into())
]
);
let named = params! { ":id" => 40_i64, ":name" => "alice" };
assert_eq!(named.len(), 2);
assert_eq!(named[0].0, ":id");
assert_eq!(named[0].1, BindValue::Number("40".into()));
assert_eq!(named[1].0, ":name");
}
#[test]
fn params_from_positional_sources() {
assert_eq!(Params::default(), Params::None);
assert_eq!(Params::from(()), Params::Positional(Cow::Owned(Vec::new())));
let from_tuple = Params::from((40_i64, "alice"));
assert_eq!(
from_tuple,
Params::Positional(Cow::Owned(vec![
BindValue::Number("40".into()),
BindValue::Text("alice".into())
]))
);
let raw = vec![BindValue::Number("7".into()), BindValue::Boolean(true)];
assert_eq!(
Params::from(raw.clone()),
Params::Positional(Cow::Owned(raw.clone()))
);
assert_eq!(
Params::from(raw.as_slice()),
Params::Positional(Cow::Borrowed(raw.as_slice()))
);
assert_eq!(
Params::from(&raw),
Params::Positional(Cow::Borrowed(raw.as_slice()))
);
let empty: &[BindValue] = &[];
assert_eq!(
Params::from(empty),
Params::Positional(Cow::Borrowed(empty))
);
}
#[test]
fn params_from_named_sources() {
let named = params! { ":id" => 40_i64, ":name" => "alice" };
assert_eq!(
Params::from(named.clone()),
Params::Named(Cow::Owned(named.clone()))
);
assert_eq!(
Params::from(named.as_slice()),
Params::Named(Cow::Borrowed(named.as_slice()))
);
assert_eq!(
Params::from(&named),
Params::Named(Cow::Borrowed(named.as_slice()))
);
let empty: Vec<(String, BindValue)> = Vec::new();
assert_eq!(
Params::from(empty.clone()),
Params::Named(Cow::Owned(empty.clone()))
);
assert_eq!(
Params::from(empty.as_slice()),
Params::Named(Cow::Borrowed(empty.as_slice()))
);
}
#[test]
fn resolve_params_reuses_named_ordering() {
let named = params! { ":b" => 2_i64, ":a" => 1_i64 };
let ordered = resolve_params(
"select * from t where a = :a and b = :b",
Params::from(named),
)
.expect("named binds should resolve");
assert_eq!(
ordered,
vec![BindValue::Number("1".into()), BindValue::Number("2".into())]
);
assert!(resolve_params("select 1 from dual", Params::None)
.expect("no binds should resolve")
.is_empty());
}
#[test]
fn resolve_params_rejects_missing_and_extra_named_binds() {
let missing = resolve_params(
"select * from t where a = :a and b = :b",
Params::from(params! { ":a" => 1_i64 }),
)
.unwrap_err();
assert_eq!(
missing,
BindError::MissingNamedBind {
name: "b".to_string()
}
);
let extra = resolve_params(
"select * from t where a = :a",
Params::from(params! { ":a" => 1_i64, ":b" => 2_i64 }),
)
.unwrap_err();
assert_eq!(
extra,
BindError::ExtraNamedBind {
name: ":b".to_string()
}
);
}
#[test]
fn resolve_params_uses_protocol_tokenizer_for_named_binds() {
let ordered = resolve_params(
r#"select ':skip', q'[not :this]', :"MiX", :a# from dual -- :comment"#,
Params::from(params! { ":a#" => 7_i64, r#""MiX""# => 6_i64 }),
)
.expect("tokenizer-backed named binds should resolve");
assert_eq!(
ordered,
vec![BindValue::Number("6".into()), BindValue::Number("7".into())]
);
}
#[test]
fn resolve_params_rejects_unambiguous_positional_count_mismatch() {
let err = resolve_params(
"select :1 + :2 from dual",
Params::from(vec![BindValue::Number("1".into())]),
)
.unwrap_err();
assert_eq!(
err,
BindError::PositionalCountMismatch {
expected: 2,
actual: 1
}
);
let repeated = resolve_params(
"select :1 + :1 from dual",
Params::from(vec![
BindValue::Number("1".into()),
BindValue::Number("1".into()),
]),
)
.expect("plain SQL repeated placeholders consume one positional value per occurrence");
assert_eq!(repeated.len(), 2);
}
#[test]
fn validate_bind_rows_shape_rejects_ragged_raw_rows() {
let rows = vec![
vec![
BindValue::Number("1".to_string()),
BindValue::Text("a".into()),
],
vec![BindValue::Number("2".to_string())],
];
let err = validate_bind_rows_shape("insert into t values (:1, :2)", &rows).unwrap_err();
assert_eq!(
err,
BindError::BatchRowWidthMismatch {
row_index: 1,
expected: 2,
actual: 1
}
);
}
#[test]
fn validate_bind_rows_shape_accepts_repeated_named_bind_single_value() {
let rows = vec![vec![BindValue::Number("1".to_string())]];
validate_bind_rows_shape(
"select :v + 1 from dual union all select :v + 2 from dual \
union all select :v + 3 from dual",
&rows,
)
.expect("repeated named bind is satisfied by one supplied value");
}
#[test]
fn resolve_params_dedups_group_by_case_repeated_named_bind() {
let sql = "\
select case when deptno = :dept then 'target' else 'other' end bucket, count(*) \
from emp \
group by case when deptno = :dept then 'target' else 'other' end";
let ordered = resolve_params(sql, Params::from(params! { ":dept" => 10_i64 }))
.expect("repeated named GROUP BY CASE bind should resolve once");
assert_eq!(ordered, vec![BindValue::Number("10".to_string())]);
}
#[test]
fn validate_bind_rows_shape_accepts_empty_rows_for_parse() {
validate_bind_rows_shape("select :v from dual", &[])
.expect("a no-bind execute / parse-only describe is valid");
}
#[test]
fn query_result_typed_get() {
let result = QueryResult {
columns: vec![ColumnMetadata::new("ID", 0), ColumnMetadata::new("NAME", 0)],
rows: vec![vec![Some(num("7")), Some(QueryValue::Text("bob".into()))]],
..Default::default()
};
assert_eq!(result.get::<i64>(0, 0).unwrap(), 7);
assert_eq!(result.get_by_name::<i64>(0, "id").unwrap(), 7);
assert_eq!(result.get_by_name::<String>(0, "name").unwrap(), "bob");
let row = result.typed_row(0);
assert_eq!(row.get::<i64>(0).unwrap(), 7);
assert_eq!(row.get_by_name::<String>("NAME").unwrap(), "bob");
assert!(result.get_by_name::<i64>(0, "nope").is_err());
}
#[cfg(feature = "rust_decimal")]
#[test]
fn decimal_roundtrip_is_lossless_to_full_precision() {
use rust_decimal::Decimal;
use std::str::FromStr;
let text = "7922816251426433759354.395033"; let dec = Decimal::from_str(text).unwrap();
let bind = dec.to_sql();
assert_eq!(bind, BindValue::Number(text.to_string()));
let back = Decimal::from_sql(&num(text)).unwrap();
assert_eq!(back, dec);
assert_eq!(back.to_string(), text);
let as_f64: f64 = text.parse().unwrap();
assert_ne!(
as_f64.to_string(),
text,
"f64 must lose precision here, proving Decimal is the lossless path"
);
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_from_and_to_sql() {
use chrono::{
DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc,
};
let dt = QueryValue::DateTime {
year: 2026,
month: 6,
day: 14,
hour: 12,
minute: 30,
second: 45,
nanosecond: 123_456_789,
};
let parsed = NaiveDateTime::from_sql(&dt).unwrap();
assert_eq!(parsed.to_string(), "2026-06-14 12:30:45.123456789");
let date = NaiveDate::from_sql(&dt).unwrap();
assert_eq!(date, NaiveDate::from_ymd_opt(2026, 6, 14).unwrap());
let bce = QueryValue::DateTime {
year: -4712,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
nanosecond: 0,
};
let bce_date = NaiveDate::from_sql(&bce).unwrap();
assert_eq!(bce_date.year(), -4712);
match parsed.to_sql() {
BindValue::Timestamp {
year, nanosecond, ..
} => {
assert_eq!(year, 2026);
assert_eq!(nanosecond, 123_456_789);
}
other => panic!("expected Timestamp bind, got {other:?}"),
}
let offset_value = QueryValue::TimestampTz {
year: 2026,
month: 6,
day: 29,
hour: 12,
minute: 34,
second: 56,
nanosecond: 987_654_321,
offset_minutes: -330,
};
let fixed = DateTime::<FixedOffset>::from_sql(&offset_value).unwrap();
assert_eq!(fixed.to_rfc3339(), "2026-06-29T12:34:56.987654321-05:30");
let utc = DateTime::<Utc>::from_sql(&offset_value).unwrap();
assert_eq!(utc.to_rfc3339(), "2026-06-29T18:04:56.987654321+00:00");
let legacy_naive = NaiveDateTime::from_sql(&offset_value).unwrap();
assert_eq!(legacy_naive.to_string(), "2026-06-29 07:04:56.987654321");
let outbound = FixedOffset::east_opt(5 * 3600 + 45 * 60)
.unwrap()
.with_ymd_and_hms(2026, 6, 29, 7, 8, 9)
.unwrap()
.with_nanosecond(123_000_000)
.unwrap();
assert_eq!(
outbound.to_sql(),
BindValue::TimestampTz {
year: 2026,
month: 6,
day: 29,
hour: 7,
minute: 8,
second: 9,
nanosecond: 123_000_000,
offset_minutes: 345,
}
);
}
#[cfg(feature = "uuid")]
#[test]
fn uuid_from_raw_and_text() {
use uuid::Uuid;
let id = Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10);
let raw = QueryValue::Raw(id.as_bytes().to_vec());
assert_eq!(Uuid::from_sql(&raw).unwrap(), id);
let text = QueryValue::Text(id.to_string());
assert_eq!(Uuid::from_sql(&text).unwrap(), id);
assert_eq!(id.to_sql(), BindValue::Raw(id.as_bytes().to_vec()));
}
#[cfg(feature = "serde_json")]
#[test]
fn serde_json_from_oson_tree() {
use oracledb_protocol::oson::OsonValue;
use serde_json::json;
let oson = OsonValue::Object(vec![
("id".into(), OsonValue::Number("7".into())),
("name".into(), OsonValue::String("bob".into())),
("active".into(), OsonValue::Bool(true)),
(
"tags".into(),
OsonValue::Array(vec![
OsonValue::String("a".into()),
OsonValue::String("b".into()),
]),
),
]);
let value = serde_json::Value::from_sql(&QueryValue::Json(Box::new(oson)))
.expect("OSON tree converts to serde_json");
assert_eq!(
value,
json!({"id": 7, "name": "bob", "active": true, "tags": ["a", "b"]})
);
}
#[cfg(feature = "serde_json")]
#[test]
fn serde_json_number_conversion_preserves_high_precision_text() {
use oracledb_protocol::oson::OsonValue;
use serde_json::{json, Value};
let oson = OsonValue::Object(vec![
(
"precise_decimal".into(),
OsonValue::Number("1.234567890123456789".into()),
),
(
"wide_integer".into(),
OsonValue::Number("99999999999999999999999999999999999999".into()),
),
("small_int".into(), OsonValue::Number("42".into())),
("small_decimal".into(), OsonValue::Number("12.5".into())),
]);
let value = serde_json::Value::from_sql(&QueryValue::Json(Box::new(oson)))
.expect("OSON tree converts to serde_json");
assert_eq!(
value,
json!({
"precise_decimal": "1.234567890123456789",
"wide_integer": "99999999999999999999999999999999999999",
"small_int": 42,
"small_decimal": 12.5
})
);
assert!(matches!(value["small_int"], Value::Number(_)));
assert!(matches!(value["small_decimal"], Value::Number(_)));
}
#[test]
fn named_binds_reorder_to_first_appearance() {
let named = params! { ":b" => 2_i64, ":a" => 1_i64 };
let ordered = order_named_binds("select * from t where a = :a and b = :b", named)
.expect("named binds should reorder");
assert_eq!(
ordered,
vec![BindValue::Number("1".into()), BindValue::Number("2".into())]
);
}
#[test]
fn named_binds_repeated_placeholder_counts_once() {
let named = params! { ":id" => 5_i64, ":name" => "x" };
let ordered = order_named_binds("select :id from t where id = :id and name = :name", named)
.expect("named binds should coalesce repeated placeholders");
assert_eq!(
ordered,
vec![BindValue::Number("5".into()), BindValue::Text("x".into())]
);
}
#[test]
fn named_binds_ignore_colon_in_string_literal() {
let named = params! { ":real" => 9_i64 };
let ordered = order_named_binds("select 'time is 12:30' as t, :real from dual", named)
.expect("bind in string literal should be ignored");
assert_eq!(ordered, vec![BindValue::Number("9".into())]);
}
#[test]
fn vector_from_sql_f32_f64() {
let vector = QueryValue::Vector(Box::new(Vector::Dense(VectorValues::Float32(vec![
1.0, 2.0, 3.0,
]))));
assert_eq!(Vec::<f32>::from_sql(&vector).unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(Vec::<f64>::from_sql(&vector).unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(
vec![1.0_f32, 2.0, 3.0].to_sql(),
BindValue::Vector(Vector::Dense(VectorValues::Float32(vec![1.0, 2.0, 3.0])))
);
}
}