nusadb 0.1.0

Fast, stable native Rust driver and ORM for NusaDB (Nusa Wire Protocol 1.1).
Documentation
//! Value model: decode a `DataRow` field (text format, §11.1) into a native [`Value`] by its column
//! [`TypeTag`], plus [`ToSql`] (bind a Rust value as a text parameter) and [`FromSql`] (read a column
//! back into a Rust type).
//!
//! Arbitrary-precision / arbitrary-shape types (`NUMERIC`, `DATE`/`TIME`/`TIMESTAMP*`, `INTERVAL`,
//! `UUID`, `JSON`, `ARRAY`, `VECTOR`) are kept as their canonical text — lossless and round-trippable.
//! `BOOL`/`INT`/`FLOAT` decode to native `bool`/`i64`/`f64`; `BYTES` decodes the `\x<hex>` text form
//! to raw bytes.

use crate::error::{Error, Result};
use crate::protocol::TypeTag;

/// A decoded column value.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Value {
    /// SQL `NULL`.
    Null,
    /// `BOOL`.
    Bool(bool),
    /// `INT` (64-bit signed).
    Int(i64),
    /// `FLOAT` (IEEE-754 double).
    Float(f64),
    /// `NUMERIC` — canonical decimal text (exact at arbitrary precision).
    Numeric(String),
    /// `TEXT`.
    Text(String),
    /// `BYTES` — raw field bytes as received.
    Bytes(Vec<u8>),
    /// `DATE`, `TIME`, `TIMETZ`, `TIMESTAMP`, `TIMESTAMPTZ`, `INTERVAL`, `UUID`, `JSON`, `ARRAY`,
    /// `VECTOR` — kept as canonical text, tagged so callers know the column's declared type.
    Typed {
        /// The column's declared type.
        tag: TypeTag,
        /// The canonical text rendering.
        text: String,
    },
}

impl Value {
    /// `true` if this is SQL `NULL`.
    #[must_use]
    pub const fn is_null(&self) -> bool {
        matches!(self, Self::Null)
    }

    /// Borrow as `i64` if this is an `INT`.
    #[must_use]
    pub const fn as_i64(&self) -> Option<i64> {
        if let Self::Int(n) = self {
            Some(*n)
        } else {
            None
        }
    }

    /// Borrow as `f64` if this is a `FLOAT`.
    #[must_use]
    pub const fn as_f64(&self) -> Option<f64> {
        if let Self::Float(n) = self {
            Some(*n)
        } else {
            None
        }
    }

    /// Borrow as `bool` if this is a `BOOL`.
    #[must_use]
    pub const fn as_bool(&self) -> Option<bool> {
        if let Self::Bool(b) = self {
            Some(*b)
        } else {
            None
        }
    }

    /// Borrow the text of any string-backed value (`TEXT`, `NUMERIC`, `DATE`, `JSON`, …).
    #[must_use]
    pub fn as_str(&self) -> Option<&str> {
        match self {
            Self::Text(s) | Self::Numeric(s) => Some(s),
            Self::Typed { text, .. } => Some(text),
            _ => None,
        }
    }

    /// Borrow raw bytes if this is `BYTES`.
    #[must_use]
    pub fn as_bytes(&self) -> Option<&[u8]> {
        if let Self::Bytes(b) = self {
            Some(b)
        } else {
            None
        }
    }

    /// Decode one `DataRow` field (`None` = NULL, `Some(bytes)` = text-format value) by its column
    /// type. Unknown / unresolvable types are treated as `TEXT`.
    pub(crate) fn decode(field: Option<Vec<u8>>, tag: TypeTag) -> Result<Self> {
        let Some(bytes) = field else {
            return Ok(Self::Null);
        };
        if tag == TypeTag::Bytes {
            // A BYTEA column arrives in text format as `\x<hex>`; decode it to the raw bytes.
            let text = String::from_utf8(bytes)
                .map_err(|_| Error::Protocol("invalid UTF-8 in BYTEA field".to_owned()))?;
            return Ok(Self::Bytes(decode_bytea_hex(&text)?));
        }
        let text = String::from_utf8(bytes)
            .map_err(|_| Error::Protocol("invalid UTF-8 in value field".to_owned()))?;
        Ok(match tag {
            TypeTag::Bool => Self::Bool(matches!(text.as_str(), "true" | "t" | "TRUE" | "T")),
            TypeTag::Int => text
                .parse::<i64>()
                .map(Self::Int)
                .unwrap_or(Self::Text(text)),
            TypeTag::Float => text
                .parse::<f64>()
                .map(Self::Float)
                .unwrap_or(Self::Text(text)),
            TypeTag::Numeric => Self::Numeric(text),
            TypeTag::Text | TypeTag::Unknown => Self::Text(text),
            TypeTag::Bytes => unreachable!("handled above"),
            other => Self::Typed { tag: other, text },
        })
    }
}

/// Decode the BYTEA hex text form `\x<hex>` into raw bytes (empty `\x` → empty vec).
fn decode_bytea_hex(s: &str) -> Result<Vec<u8>> {
    let h = s.strip_prefix("\\x").unwrap_or(s);
    if h.len() % 2 != 0 {
        return Err(Error::Protocol(
            "malformed BYTEA hex (odd length)".to_owned(),
        ));
    }
    (0..h.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&h[i..i + 2], 16)
                .map_err(|_| Error::Protocol("malformed BYTEA hex".to_owned()))
        })
        .collect()
}

impl std::fmt::Display for Value {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Null => write!(f, "NULL"),
            Self::Bool(b) => write!(f, "{b}"),
            Self::Int(n) => write!(f, "{n}"),
            Self::Float(n) => write!(f, "{n}"),
            Self::Numeric(s) | Self::Text(s) => write!(f, "{s}"),
            Self::Typed { text, .. } => write!(f, "{text}"),
            Self::Bytes(b) => write!(
                f,
                "\\x{}",
                b.iter().map(|x| format!("{x:02x}")).collect::<String>()
            ),
        }
    }
}

/// Bind a Rust value as a positional `$n` parameter. Parameters travel in **text format** (§10.4);
/// `None` is SQL `NULL`.
pub trait ToSql {
    /// Render to the text bytes the wire carries, or `None` for SQL `NULL`.
    fn to_sql_text(&self) -> Option<Vec<u8>>;
}

macro_rules! to_sql_display {
    ($($t:ty),*) => {$(
        impl ToSql for $t {
            fn to_sql_text(&self) -> Option<Vec<u8>> {
                Some(self.to_string().into_bytes())
            }
        }
    )*};
}
to_sql_display!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);

impl ToSql for bool {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        Some(if *self {
            b"true".to_vec()
        } else {
            b"false".to_vec()
        })
    }
}

impl ToSql for str {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        Some(self.as_bytes().to_vec())
    }
}

impl ToSql for String {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        Some(self.clone().into_bytes())
    }
}

impl<T: ToSql> ToSql for Option<T> {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        self.as_ref().and_then(ToSql::to_sql_text)
    }
}

// Raw bytes bind as the BYTEA text form `\x<hex>`, which the server coerces into a BYTEA column.
impl ToSql for [u8] {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        Some(Value::Bytes(self.to_vec()).to_string().into_bytes())
    }
}

impl ToSql for Vec<u8> {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        self.as_slice().to_sql_text()
    }
}

impl<T: ToSql + ?Sized> ToSql for &T {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        (**self).to_sql_text()
    }
}

impl ToSql for Value {
    fn to_sql_text(&self) -> Option<Vec<u8>> {
        match self {
            Self::Null => None,
            other => Some(other.to_string().into_bytes()),
        }
    }
}

/// Read a column [`Value`] back into a Rust type.
pub trait FromSql: Sized {
    /// Convert, or fail with [`Error::Conversion`] on a type mismatch.
    fn from_sql(value: &Value) -> Result<Self>;
}

impl FromSql for Value {
    fn from_sql(value: &Value) -> Result<Self> {
        Ok(value.clone())
    }
}

impl FromSql for i64 {
    fn from_sql(value: &Value) -> Result<Self> {
        match value {
            Value::Int(n) => Ok(*n),
            Value::Text(s) | Value::Numeric(s) => s
                .parse()
                .map_err(|_| Error::Conversion(format!("cannot read {s:?} as i64"))),
            other => Err(Error::Conversion(format!("cannot read {other:?} as i64"))),
        }
    }
}

impl FromSql for f64 {
    fn from_sql(value: &Value) -> Result<Self> {
        match value {
            Value::Float(n) => Ok(*n),
            Value::Int(n) => Ok(*n as Self),
            Value::Numeric(s) | Value::Text(s) => s
                .parse()
                .map_err(|_| Error::Conversion(format!("cannot read {s:?} as f64"))),
            other => Err(Error::Conversion(format!("cannot read {other:?} as f64"))),
        }
    }
}

impl FromSql for bool {
    fn from_sql(value: &Value) -> Result<Self> {
        match value {
            Value::Bool(b) => Ok(*b),
            other => Err(Error::Conversion(format!("cannot read {other:?} as bool"))),
        }
    }
}

impl FromSql for String {
    fn from_sql(value: &Value) -> Result<Self> {
        match value {
            Value::Null => Err(Error::Conversion("NULL into non-Option String".to_owned())),
            other => Ok(other.to_string()),
        }
    }
}

impl FromSql for Vec<u8> {
    fn from_sql(value: &Value) -> Result<Self> {
        match value {
            Value::Bytes(b) => Ok(b.clone()),
            other => Err(Error::Conversion(format!("cannot read {other:?} as bytes"))),
        }
    }
}

impl<T: FromSql> FromSql for Option<T> {
    fn from_sql(value: &Value) -> Result<Self> {
        if value.is_null() {
            Ok(None)
        } else {
            T::from_sql(value).map(Some)
        }
    }
}