use std::fmt;
use crate::value::Value;
pub trait Queryable: Sized {
fn decode(value: &Value) -> Result<Self, DecodeError>;
}
#[derive(Debug, Clone, PartialEq)]
pub struct DecodeError {
type_root: Option<String>,
path: Vec<String>,
kind: DecodeErrorKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DecodeErrorKind {
MissingField(String),
WrongType {
expected: &'static str,
actual: &'static str,
},
UnknownVariant(String),
OutOfRange { value: i64, target: &'static str },
Json(String),
Invalid(String),
}
impl DecodeError {
fn new(kind: DecodeErrorKind) -> Self {
Self {
type_root: None,
path: Vec::new(),
kind,
}
}
fn under(mut self, segment: impl Into<String>) -> Self {
self.path.insert(0, segment.into());
self
}
fn rooted_at(mut self, type_name: &str) -> Self {
self.type_root = Some(type_name.to_string());
self
}
pub fn kind(&self) -> &DecodeErrorKind {
&self.kind
}
pub fn custom(message: impl Into<String>) -> Self {
Self::new(DecodeErrorKind::Invalid(message.into()))
}
fn wrong_type(expected: &'static str, actual: &Value) -> Self {
Self::new(DecodeErrorKind::WrongType {
expected,
actual: type_name(actual),
})
}
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut location: Vec<&str> = Vec::with_capacity(self.path.len() + 1);
location.extend(self.type_root.as_deref());
location.extend(self.path.iter().map(String::as_str));
if location.is_empty() {
write!(f, "cannot decode query result: {}", self.kind)
} else {
write!(f, "cannot decode {}: {}", location.join("."), self.kind)
}
}
}
impl std::error::Error for DecodeError {}
impl fmt::Display for DecodeErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingField(name) => write!(f, "the query's shape has no '{name}'"),
Self::WrongType { expected, actual } => write!(f, "expected {expected}, got {actual}"),
Self::UnknownVariant(label) => write!(f, "'{label}' is not a known variant"),
Self::OutOfRange { value, target } => write!(f, "{value} is out of range for {target}"),
Self::Json(message) => write!(f, "{message}"),
Self::Invalid(message) => write!(f, "{message}"),
}
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "an empty set",
Value::Bool(_) => "a bool",
Value::Int64(_) => "an integer",
Value::Float64(_) => "a float",
Value::Str(_) => "a string",
Value::Bytes(_) => "bytes",
Value::Uuid(_) => "a uuid",
Value::Decimal(_) => "a decimal",
Value::Duration { .. } => "a duration",
Value::Date(_) => "a date",
Value::Time(_) => "a time",
Value::Timestamp(_) => "a local datetime",
Value::Timestamptz(_) => "a datetime",
Value::Range(_) => "a range",
Value::Array(_) => "a set",
Value::Tuple(_) => "a tuple",
Value::Object(_) => "an object",
Value::Enum { .. } => "an enum value",
Value::Group(_) => "a group",
Value::VectorSearch { .. } => "a vector search result",
Value::FtsSearch { .. } => "an FTS search result",
}
}
impl Queryable for Value {
fn decode(value: &Value) -> Result<Self, DecodeError> {
Ok(value.clone())
}
}
impl<T: Queryable> Queryable for Option<T> {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Null => Ok(None),
other => T::decode(other).map(Some),
}
}
}
impl<T: Queryable> Queryable for Vec<T> {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Array(items) => items
.iter()
.enumerate()
.map(|(index, item)| T::decode(item).map_err(|error| error.under(index.to_string())))
.collect(),
Value::Null => Ok(Vec::new()),
single => T::decode(single).map(|decoded| vec![decoded]),
}
}
}
impl Queryable for bool {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Bool(b) => Ok(*b),
other => Err(DecodeError::wrong_type("a bool", other)),
}
}
}
impl Queryable for String {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Str(s) => Ok(s.clone()),
Value::Enum { value, .. } => Ok(value.clone()),
Value::Decimal(s) => Ok(s.clone()),
other => Err(DecodeError::wrong_type("a string", other)),
}
}
}
impl Queryable for Vec<u8> {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Bytes(b) => Ok(b.clone()),
other => Err(DecodeError::wrong_type("bytes", other)),
}
}
}
impl Queryable for uuid::Uuid {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Uuid(u) => Ok(*u),
other => Err(DecodeError::wrong_type("a uuid", other)),
}
}
}
impl Queryable for i64 {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Int64(n) => Ok(*n),
other => Err(DecodeError::wrong_type("an integer", other)),
}
}
}
macro_rules! queryable_narrow_int {
($($target:ty),* $(,)?) => {
$(
impl Queryable for $target {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Int64(n) => <$target>::try_from(*n).map_err(|_| {
DecodeError::new(DecodeErrorKind::OutOfRange {
value: *n,
target: stringify!($target),
})
}),
other => Err(DecodeError::wrong_type("an integer", other)),
}
}
}
)*
};
}
queryable_narrow_int!(i16, i32, u16, u32, u64);
impl Queryable for f64 {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Float64(f) => Ok(*f),
Value::Int64(n) => Ok(*n as f64),
other => Err(DecodeError::wrong_type("a float", other)),
}
}
}
impl Queryable for f32 {
fn decode(value: &Value) -> Result<Self, DecodeError> {
f64::decode(value).map(|f| f as f32)
}
}
impl Queryable for chrono::DateTime<chrono::Utc> {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Timestamptz(micros) => from_pg_micros(*micros).map(|naive| naive.and_utc()),
other => Err(DecodeError::wrong_type("a datetime", other)),
}
}
}
impl Queryable for chrono::NaiveDateTime {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Timestamp(micros) => from_pg_micros(*micros),
other => Err(DecodeError::wrong_type("a local datetime", other)),
}
}
}
impl Queryable for chrono::NaiveDate {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Date(days) => pg_epoch()
.checked_add_signed(chrono::Duration::days(i64::from(*days)))
.ok_or_else(|| {
DecodeError::new(DecodeErrorKind::Invalid(format!(
"date {days} days from 2000-01-01 is outside the supported range"
)))
}),
other => Err(DecodeError::wrong_type("a date", other)),
}
}
}
impl Queryable for chrono::NaiveTime {
fn decode(value: &Value) -> Result<Self, DecodeError> {
match value {
Value::Time(micros) => chrono::NaiveTime::from_hms_opt(0, 0, 0)
.and_then(|midnight| {
midnight
.overflowing_add_signed(chrono::Duration::microseconds(*micros))
.0
.into()
})
.ok_or_else(|| {
DecodeError::new(DecodeErrorKind::Invalid(format!(
"time {micros}\u{b5}s after midnight is not a valid time of day"
)))
}),
other => Err(DecodeError::wrong_type("a time", other)),
}
}
}
fn pg_epoch() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date")
}
fn from_pg_micros(micros: i64) -> Result<chrono::NaiveDateTime, DecodeError> {
pg_epoch()
.and_hms_opt(0, 0, 0)
.and_then(|midnight| midnight.checked_add_signed(chrono::Duration::microseconds(micros)))
.ok_or_else(|| {
DecodeError::new(DecodeErrorKind::Invalid(format!(
"timestamp {micros}\u{b5}s from 2000-01-01 is outside the supported range"
)))
})
}
#[doc(hidden)]
pub mod derive {
use super::{DecodeError, DecodeErrorKind, Queryable};
use crate::value::{Object, Value};
pub fn object<'v>(value: &'v Value, container: &'static str) -> Result<&'v Object, DecodeError> {
match value {
Value::Object(object) => Ok(object),
other => Err(DecodeError::wrong_type("an object", other).rooted_at(container)),
}
}
fn missing(container: &'static str, name: &str) -> DecodeError {
DecodeError::new(DecodeErrorKind::MissingField(name.to_string())).rooted_at(container)
}
pub fn field<T: Queryable>(object: &Object, container: &'static str, name: &str) -> Result<T, DecodeError> {
let value = object.get(name).ok_or_else(|| missing(container, name))?;
T::decode(value).map_err(|error| error.under(name).rooted_at(container))
}
pub fn json_field<T: serde::de::DeserializeOwned>(
object: &Object,
container: &'static str,
name: &str,
) -> Result<T, DecodeError> {
let value = object.get(name).ok_or_else(|| missing(container, name))?;
from_json_value(value).map_err(|error| error.under(name).rooted_at(container))
}
pub fn from_json<T: serde::de::DeserializeOwned>(value: &Value, container: &'static str) -> Result<T, DecodeError> {
from_json_value(value).map_err(|error| error.rooted_at(container))
}
fn from_json_value<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, DecodeError> {
serde_json::from_str(&crate::json::to_json(value))
.map_err(|error| DecodeError::new(DecodeErrorKind::Json(error.to_string())))
}
pub fn enum_label<'v>(value: &'v Value, container: &'static str) -> Result<&'v str, DecodeError> {
match value {
Value::Enum { value, .. } => Ok(value.as_str()),
Value::Str(label) => Ok(label.as_str()),
other => Err(DecodeError::wrong_type("an enum value", other).rooted_at(container)),
}
}
pub fn unknown_variant(container: &'static str, label: &str) -> DecodeError {
DecodeError::new(DecodeErrorKind::UnknownVariant(label.to_string())).rooted_at(container)
}
}
pub(crate) fn decode_rows<R: Queryable>(values: Vec<Value>) -> crate::Result<Vec<R>> {
values
.iter()
.map(|value| R::decode(value).map_err(crate::Error::from))
.collect()
}
pub(crate) fn decode_optional_row<R: Queryable>(value: Option<Value>) -> crate::Result<Option<R>> {
value.as_ref().map(R::decode).transpose().map_err(crate::Error::from)
}
pub(crate) fn decode_row<R: Queryable>(value: Value) -> crate::Result<R> {
R::decode(&value).map_err(crate::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::Object;
use crate::{QueryArgs, named_args};
#[derive(Debug, PartialEq, crate::Queryable)]
#[pylon(crate_path = crate)]
struct Row {
id: uuid::Uuid,
attempt: i32,
last_error: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, PartialEq, crate::Queryable)]
#[pylon(crate_path = crate)]
enum WebhookEvent {
#[pylon(rename = "contact.created")]
ContactCreated,
#[pylon(rename = "contact.updated")]
ContactUpdated,
Other,
}
#[derive(Debug, PartialEq, crate::Queryable)]
#[pylon(crate_path = crate)]
struct Nested {
latest_version: Option<Inner>,
#[pylon(rename = "type")]
kind: WebhookEvent,
}
#[derive(Debug, PartialEq, crate::Queryable)]
#[pylon(crate_path = crate)]
struct Inner {
runner: String,
}
fn object(fields: Vec<(&str, Value)>) -> Value {
Value::Object(Object {
type_name: Some("test::Row".to_string()),
fields: fields.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
implicit_id: false,
})
}
#[test]
fn decodes_a_row_struct() {
let id = uuid::Uuid::from_u128(7);
let row = Row::decode(&object(vec![
("id", Value::Uuid(id)),
("attempt", Value::Int64(3)),
("last_error", Value::Null),
("created_at", Value::Timestamptz(0)),
]))
.unwrap();
assert_eq!(row.id, id);
assert_eq!(row.attempt, 3);
assert_eq!(row.last_error, None);
assert_eq!(row.created_at.to_rfc3339(), "2000-01-01T00:00:00+00:00");
}
#[test]
fn field_order_does_not_matter() {
let id = uuid::Uuid::from_u128(1);
let row = Row::decode(&object(vec![
("created_at", Value::Timestamptz(0)),
("last_error", Value::Str("boom".into())),
("attempt", Value::Int64(1)),
("id", Value::Uuid(id)),
]))
.unwrap();
assert_eq!(row.last_error.as_deref(), Some("boom"));
assert_eq!(row.id, id);
}
#[test]
fn a_field_the_shape_omitted_is_an_error() {
let error = Row::decode(&object(vec![
("id", Value::Uuid(uuid::Uuid::nil())),
("attempt", Value::Int64(1)),
("created_at", Value::Timestamptz(0)),
]))
.unwrap_err();
assert_eq!(error.kind(), &DecodeErrorKind::MissingField("last_error".to_string()));
assert_eq!(
error.to_string(),
"cannot decode Row: the query's shape has no 'last_error'"
);
}
#[test]
fn nested_failures_name_their_whole_path() {
let error = Nested::decode(&object(vec![
("latest_version", object(vec![("runner", Value::Int64(4))])),
("type", Value::Str("contact.created".into())),
]))
.unwrap_err();
assert_eq!(
error.to_string(),
"cannot decode Nested.latest_version.runner: expected a string, got an integer"
);
}
#[test]
fn decodes_a_renamed_enum_from_either_a_real_enum_or_a_str_cast() {
let from_enum = WebhookEvent::decode(&Value::Enum {
type_name: "integration::WebhookEvent".to_string(),
value: "contact.updated".to_string(),
})
.unwrap();
assert_eq!(from_enum, WebhookEvent::ContactUpdated);
let from_cast = WebhookEvent::decode(&Value::Str("contact.created".into())).unwrap();
assert_eq!(from_cast, WebhookEvent::ContactCreated);
assert_eq!(
WebhookEvent::decode(&Value::Str("Other".into())).unwrap(),
WebhookEvent::Other
);
}
#[test]
fn an_unknown_enum_label_names_the_label_it_saw() {
let error = WebhookEvent::decode(&Value::Str("contact.merged".into())).unwrap_err();
assert_eq!(
error.to_string(),
"cannot decode WebhookEvent: 'contact.merged' is not a known variant"
);
}
#[test]
fn decodes_doubly_optional_nesting() {
let row = Nested::decode(&object(vec![
("latest_version", Value::Null),
("type", Value::Str("Other".into())),
]))
.unwrap();
assert_eq!(row.latest_version, None);
}
#[test]
fn a_custom_decode_failure_converts_into_the_crate_error() {
let error: crate::Error = DecodeError::custom("failed to decode query result JSON: eof").into();
assert_eq!(
error.to_string(),
"cannot decode query result: failed to decode query result JSON: eof"
);
}
#[test]
fn narrow_integers_range_check_instead_of_wrapping() {
assert_eq!(i32::decode(&Value::Int64(-5)).unwrap(), -5);
let error = i32::decode(&Value::Int64(i64::from(i32::MAX) + 1)).unwrap_err();
assert_eq!(
error.kind(),
&DecodeErrorKind::OutOfRange {
value: 2_147_483_648,
target: "i32"
}
);
}
#[test]
fn a_multi_pointer_decodes_into_a_vec() {
let rows: Vec<Inner> = Vec::decode(&Value::Array(vec![
object(vec![("runner", Value::Str("lambda".into()))]),
object(vec![("runner", Value::Str("firecracker".into()))]),
]))
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].runner, "firecracker");
assert_eq!(Vec::<Inner>::decode(&Value::Null).unwrap(), vec![]);
}
#[test]
fn a_json_field_deserializes_from_a_natively_decoded_document() {
#[derive(Debug, PartialEq, crate::Queryable)]
#[pylon(crate_path = crate)]
struct WithJson {
#[pylon(json)]
payload: Option<serde_json::Value>,
}
let row = WithJson::decode(&object(vec![(
"payload",
Value::Object(Object {
type_name: None,
fields: vec![("email".to_string(), Value::Str("a@b.test".into()))],
implicit_id: false,
}),
)]))
.unwrap();
assert_eq!(row.payload.unwrap()["email"], "a@b.test");
}
#[test]
fn a_duration_binds_as_a_fixed_microsecond_interval() {
let bound = crate::QueryArg::to_decoded(&std::time::Duration::from_secs(90));
assert_eq!(
bound,
pylon_value::DecodedValue::Interval {
months: 0,
days: 0,
microseconds: 90_000_000
}
);
}
#[test]
fn positional_arguments_bind_by_index() {
let id = uuid::Uuid::from_u128(9);
let args = (id, "urgent", 4i32);
let params = args.to_params();
assert_eq!(params[0].0, "0");
assert_eq!(params[1], ("1", pylon_value::DecodedValue::Str("urgent".into())));
assert_eq!(params[2], ("2", pylon_value::DecodedValue::I64(4)));
assert!(().to_params().is_empty());
}
#[test]
fn named_arguments_accept_mixed_types_including_absent_optionals() {
let args = named_args! {
"id" => uuid::Uuid::nil(),
"width" => Option::<i32>::None,
"labels" => vec!["a".to_string()],
};
let params: std::collections::HashMap<_, _> = args.to_params().into_iter().collect();
assert_eq!(params["width"], pylon_value::DecodedValue::Null);
assert_eq!(
params["labels"],
pylon_value::DecodedValue::Array(vec![pylon_value::DecodedValue::Str("a".into())])
);
assert_eq!(params["id"], pylon_value::DecodedValue::Uuid([0; 16]));
}
#[test]
fn datetimes_round_trip_through_the_pg_epoch() {
let when = chrono::DateTime::parse_from_rfc3339("2026-09-24T12:34:56Z")
.unwrap()
.with_timezone(&chrono::Utc);
let bound = crate::QueryArg::to_decoded(&when);
let pylon_value::DecodedValue::Timestamptz(micros) = bound else {
panic!("a datetime must bind as a timestamptz, got {bound:?}");
};
assert_eq!(
chrono::DateTime::<chrono::Utc>::decode(&Value::Timestamptz(micros)).unwrap(),
when
);
}
}