Skip to main content

pylon_client/
queryable.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! [`Queryable`] — decoding a generic [`Value`] into a caller's own type,
21//! and the helpers `#[derive(Queryable)]` generates calls to.
22//!
23//! Conventional in role rather than in mechanism: a client that decodes
24//! straight off a binary wire format does so against a type descriptor, while
25//! Pylon has already walked the compiled shape into a [`Value`] by the time
26//! this runs, so this is a plain `&Value -> Result<Self>` conversion.
27//!
28//! **Fields match by name, not by shape position.** A positional derive would
29//! compare the struct's field order against the shape's pointer order and
30//! reject a mismatch; a Pylon [`Object`] is name-keyed, so order is irrelevant
31//! here. The bug a positional check catches is still caught: a struct field
32//! the query never selected is a [`DecodeErrorKind::MissingField`], not a
33//! silently-wrong value.
34
35use std::fmt;
36
37use crate::value::Value;
38
39/// A type a query result can be decoded into — implemented for the scalars
40/// Pylon returns, for `Option`/`Vec` of those, and derivable for a row
41/// struct or a schema enum with `#[derive(Queryable)]`.
42pub trait Queryable: Sized {
43    fn decode(value: &Value) -> Result<Self, DecodeError>;
44}
45
46/// What went wrong, and where in the result it was.
47#[derive(Debug, Clone, PartialEq)]
48pub struct DecodeError {
49    /// The type `query::<R, _>` was asked for. Set by every derived
50    /// `decode` as the error passes back out of it, so the *outermost* one
51    /// wins — the caller's own row type, not whichever nested struct
52    /// happened to fail.
53    type_root: Option<String>,
54    /// Field path from `type_root` down to the offending value, built up as
55    /// the error propagates out — `transformer.latest_version.runner`.
56    path: Vec<String>,
57    kind: DecodeErrorKind,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub enum DecodeErrorKind {
62    /// The query's shape never selected this pointer. Usually a shape that
63    /// forgot a field the struct declares, not a bad value.
64    MissingField(String),
65    WrongType {
66        expected: &'static str,
67        actual: &'static str,
68    },
69    /// An enum label the Rust enum has no variant for.
70    UnknownVariant(String),
71    /// Pylon returns every integer as an `i64`; this is the narrowing to a
72    /// smaller Rust integer failing.
73    OutOfRange { value: i64, target: &'static str },
74    /// A `#[pylon(json)]` field or container failed to deserialize.
75    Json(String),
76    /// A value that parses in principle but not into this type — a
77    /// malformed decimal string, a timestamp outside `chrono`'s range.
78    Invalid(String),
79}
80
81impl DecodeError {
82    fn new(kind: DecodeErrorKind) -> Self {
83        Self {
84            type_root: None,
85            path: Vec::new(),
86            kind,
87        }
88    }
89
90    /// Records that this failure happened inside the field (or set element)
91    /// named `segment`.
92    fn under(mut self, segment: impl Into<String>) -> Self {
93        self.path.insert(0, segment.into());
94        self
95    }
96
97    /// Names the type being decoded. Overwrites any name a nested decode
98    /// already set, because the field path is absolute and the intermediate
99    /// type names in the middle of it are noise.
100    fn rooted_at(mut self, type_name: &str) -> Self {
101        self.type_root = Some(type_name.to_string());
102        self
103    }
104
105    pub fn kind(&self) -> &DecodeErrorKind {
106        &self.kind
107    }
108
109    /// A decode failure the caller produced itself, for a consumer that
110    /// deserializes a `query_*_json` result with its own deserializer instead
111    /// of going through [`Queryable`]. Exists so such a function can keep
112    /// returning this crate's own [`Error`](crate::Error) rather than growing
113    /// a bespoke error type.
114    pub fn custom(message: impl Into<String>) -> Self {
115        Self::new(DecodeErrorKind::Invalid(message.into()))
116    }
117
118    fn wrong_type(expected: &'static str, actual: &Value) -> Self {
119        Self::new(DecodeErrorKind::WrongType {
120            expected,
121            actual: type_name(actual),
122        })
123    }
124}
125
126impl fmt::Display for DecodeError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        let mut location: Vec<&str> = Vec::with_capacity(self.path.len() + 1);
129        location.extend(self.type_root.as_deref());
130        location.extend(self.path.iter().map(String::as_str));
131        if location.is_empty() {
132            write!(f, "cannot decode query result: {}", self.kind)
133        } else {
134            write!(f, "cannot decode {}: {}", location.join("."), self.kind)
135        }
136    }
137}
138
139impl std::error::Error for DecodeError {}
140
141impl fmt::Display for DecodeErrorKind {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::MissingField(name) => write!(f, "the query's shape has no '{name}'"),
145            Self::WrongType { expected, actual } => write!(f, "expected {expected}, got {actual}"),
146            Self::UnknownVariant(label) => write!(f, "'{label}' is not a known variant"),
147            Self::OutOfRange { value, target } => write!(f, "{value} is out of range for {target}"),
148            Self::Json(message) => write!(f, "{message}"),
149            Self::Invalid(message) => write!(f, "{message}"),
150        }
151    }
152}
153
154/// The variant name as it appears in an error message.
155fn type_name(value: &Value) -> &'static str {
156    match value {
157        Value::Null => "an empty set",
158        Value::Bool(_) => "a bool",
159        Value::Int64(_) => "an integer",
160        Value::Float64(_) => "a float",
161        Value::Str(_) => "a string",
162        Value::Bytes(_) => "bytes",
163        Value::Uuid(_) => "a uuid",
164        Value::Decimal(_) => "a decimal",
165        Value::Duration { .. } => "a duration",
166        Value::Date(_) => "a date",
167        Value::Time(_) => "a time",
168        Value::Timestamp(_) => "a local datetime",
169        Value::Timestamptz(_) => "a datetime",
170        Value::Range(_) => "a range",
171        Value::Array(_) => "a set",
172        Value::Tuple(_) => "a tuple",
173        Value::Object(_) => "an object",
174        Value::Enum { .. } => "an enum value",
175        Value::Group(_) => "a group",
176        Value::VectorSearch { .. } => "a vector search result",
177        Value::FtsSearch { .. } => "an FTS search result",
178    }
179}
180
181/// Decoding `Value` into itself — lets `query::<Value, _>(...)` (the
182/// untyped path every caller used before `Queryable` existed) go through
183/// the same generic method as a derived row struct.
184impl Queryable for Value {
185    fn decode(value: &Value) -> Result<Self, DecodeError> {
186        Ok(value.clone())
187    }
188}
189
190/// An empty set decodes as `None`; anything else has to decode as `T`.
191impl<T: Queryable> Queryable for Option<T> {
192    fn decode(value: &Value) -> Result<Self, DecodeError> {
193        match value {
194            Value::Null => Ok(None),
195            other => T::decode(other).map(Some),
196        }
197    }
198}
199
200/// A multi pointer or any other set-valued result. A single (non-`Array`)
201/// value decodes as a one-element `Vec` — a shape's cardinality is the
202/// query's business, and rejecting it here would make `Vec<T>` unusable for
203/// a pointer Pylon happens to return unwrapped.
204impl<T: Queryable> Queryable for Vec<T> {
205    fn decode(value: &Value) -> Result<Self, DecodeError> {
206        match value {
207            Value::Array(items) => items
208                .iter()
209                .enumerate()
210                .map(|(index, item)| T::decode(item).map_err(|error| error.under(index.to_string())))
211                .collect(),
212            Value::Null => Ok(Vec::new()),
213            single => T::decode(single).map(|decoded| vec![decoded]),
214        }
215    }
216}
217
218impl Queryable for bool {
219    fn decode(value: &Value) -> Result<Self, DecodeError> {
220        match value {
221            Value::Bool(b) => Ok(*b),
222            other => Err(DecodeError::wrong_type("a bool", other)),
223        }
224    }
225}
226
227impl Queryable for String {
228    fn decode(value: &Value) -> Result<Self, DecodeError> {
229        match value {
230            Value::Str(s) => Ok(s.clone()),
231            // An enum read into a `String` is what `<str>.status` produces
232            // once the cast is applied, and a decimal's canonical form is a
233            // string by definition — both are the value the caller asked
234            // for, not a type confusion.
235            Value::Enum { value, .. } => Ok(value.clone()),
236            Value::Decimal(s) => Ok(s.clone()),
237            other => Err(DecodeError::wrong_type("a string", other)),
238        }
239    }
240}
241
242impl Queryable for Vec<u8> {
243    fn decode(value: &Value) -> Result<Self, DecodeError> {
244        match value {
245            Value::Bytes(b) => Ok(b.clone()),
246            other => Err(DecodeError::wrong_type("bytes", other)),
247        }
248    }
249}
250
251impl Queryable for uuid::Uuid {
252    fn decode(value: &Value) -> Result<Self, DecodeError> {
253        match value {
254            Value::Uuid(u) => Ok(*u),
255            other => Err(DecodeError::wrong_type("a uuid", other)),
256        }
257    }
258}
259
260impl Queryable for i64 {
261    fn decode(value: &Value) -> Result<Self, DecodeError> {
262        match value {
263            Value::Int64(n) => Ok(*n),
264            other => Err(DecodeError::wrong_type("an integer", other)),
265        }
266    }
267}
268
269/// Pylon decodes every width of integer into `Value::Int64`, so the
270/// narrower Rust integers range-check rather than matching a variant of
271/// their own.
272macro_rules! queryable_narrow_int {
273    ($($target:ty),* $(,)?) => {
274        $(
275            impl Queryable for $target {
276                fn decode(value: &Value) -> Result<Self, DecodeError> {
277                    match value {
278                        Value::Int64(n) => <$target>::try_from(*n).map_err(|_| {
279                            DecodeError::new(DecodeErrorKind::OutOfRange {
280                                value: *n,
281                                target: stringify!($target),
282                            })
283                        }),
284                        other => Err(DecodeError::wrong_type("an integer", other)),
285                    }
286                }
287            }
288        )*
289    };
290}
291
292queryable_narrow_int!(i16, i32, u16, u32, u64);
293
294impl Queryable for f64 {
295    fn decode(value: &Value) -> Result<Self, DecodeError> {
296        match value {
297            Value::Float64(f) => Ok(*f),
298            // An `int64` widens to a float without loss of the value the
299            // caller cares about, and `select 1` in a float slot is a
300            // routine shape.
301            Value::Int64(n) => Ok(*n as f64),
302            other => Err(DecodeError::wrong_type("a float", other)),
303        }
304    }
305}
306
307impl Queryable for f32 {
308    fn decode(value: &Value) -> Result<Self, DecodeError> {
309        f64::decode(value).map(|f| f as f32)
310    }
311}
312
313impl Queryable for chrono::DateTime<chrono::Utc> {
314    fn decode(value: &Value) -> Result<Self, DecodeError> {
315        match value {
316            Value::Timestamptz(micros) => from_pg_micros(*micros).map(|naive| naive.and_utc()),
317            other => Err(DecodeError::wrong_type("a datetime", other)),
318        }
319    }
320}
321
322impl Queryable for chrono::NaiveDateTime {
323    fn decode(value: &Value) -> Result<Self, DecodeError> {
324        match value {
325            Value::Timestamp(micros) => from_pg_micros(*micros),
326            other => Err(DecodeError::wrong_type("a local datetime", other)),
327        }
328    }
329}
330
331impl Queryable for chrono::NaiveDate {
332    fn decode(value: &Value) -> Result<Self, DecodeError> {
333        match value {
334            Value::Date(days) => pg_epoch()
335                .checked_add_signed(chrono::Duration::days(i64::from(*days)))
336                .ok_or_else(|| {
337                    DecodeError::new(DecodeErrorKind::Invalid(format!(
338                        "date {days} days from 2000-01-01 is outside the supported range"
339                    )))
340                }),
341            other => Err(DecodeError::wrong_type("a date", other)),
342        }
343    }
344}
345
346impl Queryable for chrono::NaiveTime {
347    fn decode(value: &Value) -> Result<Self, DecodeError> {
348        match value {
349            Value::Time(micros) => chrono::NaiveTime::from_hms_opt(0, 0, 0)
350                .and_then(|midnight| {
351                    midnight
352                        .overflowing_add_signed(chrono::Duration::microseconds(*micros))
353                        .0
354                        .into()
355                })
356                .ok_or_else(|| {
357                    DecodeError::new(DecodeErrorKind::Invalid(format!(
358                        "time {micros}\u{b5}s after midnight is not a valid time of day"
359                    )))
360                }),
361            other => Err(DecodeError::wrong_type("a time", other)),
362        }
363    }
364}
365
366fn pg_epoch() -> chrono::NaiveDate {
367    // 2000-01-01 is a valid date, so this cannot fail.
368    chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date")
369}
370
371fn from_pg_micros(micros: i64) -> Result<chrono::NaiveDateTime, DecodeError> {
372    pg_epoch()
373        .and_hms_opt(0, 0, 0)
374        .and_then(|midnight| midnight.checked_add_signed(chrono::Duration::microseconds(micros)))
375        .ok_or_else(|| {
376            DecodeError::new(DecodeErrorKind::Invalid(format!(
377                "timestamp {micros}\u{b5}s from 2000-01-01 is outside the supported range"
378            )))
379        })
380}
381
382/// Helpers `#[derive(Queryable)]` expands to. Public because the generated
383/// code lives in the caller's crate, but not part of the API to program
384/// against — the signatures here follow the macro's needs.
385#[doc(hidden)]
386pub mod derive {
387    use super::{DecodeError, DecodeErrorKind, Queryable};
388    use crate::value::{Object, Value};
389
390    pub fn object<'v>(value: &'v Value, container: &'static str) -> Result<&'v Object, DecodeError> {
391        match value {
392            Value::Object(object) => Ok(object),
393            other => Err(DecodeError::wrong_type("an object", other).rooted_at(container)),
394        }
395    }
396
397    /// A field the shape omitted reports only the container — the kind
398    /// already names the field, so repeating it in the path would read
399    /// `Row.last_error: the query's shape has no 'last_error'`.
400    fn missing(container: &'static str, name: &str) -> DecodeError {
401        DecodeError::new(DecodeErrorKind::MissingField(name.to_string())).rooted_at(container)
402    }
403
404    pub fn field<T: Queryable>(object: &Object, container: &'static str, name: &str) -> Result<T, DecodeError> {
405        let value = object.get(name).ok_or_else(|| missing(container, name))?;
406        T::decode(value).map_err(|error| error.under(name).rooted_at(container))
407    }
408
409    pub fn json_field<T: serde::de::DeserializeOwned>(
410        object: &Object,
411        container: &'static str,
412        name: &str,
413    ) -> Result<T, DecodeError> {
414        let value = object.get(name).ok_or_else(|| missing(container, name))?;
415        from_json_value(value).map_err(|error| error.under(name).rooted_at(container))
416    }
417
418    pub fn from_json<T: serde::de::DeserializeOwned>(value: &Value, container: &'static str) -> Result<T, DecodeError> {
419        from_json_value(value).map_err(|error| error.rooted_at(container))
420    }
421
422    /// Pylon decodes a `json` column natively (an object becomes
423    /// [`Value::Object`], not a string of JSON text), so a
424    /// `#[pylon(json)]` target is reached by rendering that back to JSON
425    /// and letting serde read it. Rendering first rather than mapping
426    /// `Value` onto `serde_json::Value` directly keeps one definition of
427    /// how each variant looks as JSON — `crate::json`'s, which is also
428    /// what `query_json` returns.
429    fn from_json_value<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, DecodeError> {
430        serde_json::from_str(&crate::json::to_json(value))
431            .map_err(|error| DecodeError::new(DecodeErrorKind::Json(error.to_string())))
432    }
433
434    /// An enum label, whether the shape returned it as a real enum or cast
435    /// it to `str` first (`<str>.status`).
436    pub fn enum_label<'v>(value: &'v Value, container: &'static str) -> Result<&'v str, DecodeError> {
437        match value {
438            Value::Enum { value, .. } => Ok(value.as_str()),
439            Value::Str(label) => Ok(label.as_str()),
440            other => Err(DecodeError::wrong_type("an enum value", other).rooted_at(container)),
441        }
442    }
443
444    pub fn unknown_variant(container: &'static str, label: &str) -> DecodeError {
445        DecodeError::new(DecodeErrorKind::UnknownVariant(label.to_string())).rooted_at(container)
446    }
447}
448
449/// Decodes every row of an untyped result into `R` — the shared tail of
450/// `Client`/`Transaction`'s `query` methods, which run the query through
451/// `exec` generically and only then know what they are decoding into.
452pub(crate) fn decode_rows<R: Queryable>(values: Vec<Value>) -> crate::Result<Vec<R>> {
453    values
454        .iter()
455        .map(|value| R::decode(value).map_err(crate::Error::from))
456        .collect()
457}
458
459pub(crate) fn decode_optional_row<R: Queryable>(value: Option<Value>) -> crate::Result<Option<R>> {
460    value.as_ref().map(R::decode).transpose().map_err(crate::Error::from)
461}
462
463pub(crate) fn decode_row<R: Queryable>(value: Value) -> crate::Result<R> {
464    R::decode(&value).map_err(crate::Error::from)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::value::Object;
471    use crate::{QueryArgs, named_args};
472
473    // `crate_path = crate` is what lets the derive be exercised from inside
474    // the crate that defines the trait — the generated code otherwise names
475    // `::pylon_client`, which doesn't resolve here.
476    #[derive(Debug, PartialEq, crate::Queryable)]
477    #[pylon(crate_path = crate)]
478    struct Row {
479        id: uuid::Uuid,
480        attempt: i32,
481        last_error: Option<String>,
482        created_at: chrono::DateTime<chrono::Utc>,
483    }
484
485    #[derive(Debug, PartialEq, crate::Queryable)]
486    #[pylon(crate_path = crate)]
487    enum WebhookEvent {
488        #[pylon(rename = "contact.created")]
489        ContactCreated,
490        #[pylon(rename = "contact.updated")]
491        ContactUpdated,
492        Other,
493    }
494
495    #[derive(Debug, PartialEq, crate::Queryable)]
496    #[pylon(crate_path = crate)]
497    struct Nested {
498        latest_version: Option<Inner>,
499        #[pylon(rename = "type")]
500        kind: WebhookEvent,
501    }
502
503    #[derive(Debug, PartialEq, crate::Queryable)]
504    #[pylon(crate_path = crate)]
505    struct Inner {
506        runner: String,
507    }
508
509    fn object(fields: Vec<(&str, Value)>) -> Value {
510        Value::Object(Object {
511            type_name: Some("test::Row".to_string()),
512            fields: fields.into_iter().map(|(n, v)| (n.to_string(), v)).collect(),
513            implicit_id: false,
514        })
515    }
516
517    #[test]
518    fn decodes_a_row_struct() {
519        let id = uuid::Uuid::from_u128(7);
520        let row = Row::decode(&object(vec![
521            ("id", Value::Uuid(id)),
522            ("attempt", Value::Int64(3)),
523            ("last_error", Value::Null),
524            ("created_at", Value::Timestamptz(0)),
525        ]))
526        .unwrap();
527        assert_eq!(row.id, id);
528        assert_eq!(row.attempt, 3);
529        assert_eq!(row.last_error, None);
530        assert_eq!(row.created_at.to_rfc3339(), "2000-01-01T00:00:00+00:00");
531    }
532
533    /// Pylon objects are name-keyed, so the shape listing its pointers in a
534    /// different order than the struct declares its fields is not an error,
535    /// as it would be under a positional check.
536    #[test]
537    fn field_order_does_not_matter() {
538        let id = uuid::Uuid::from_u128(1);
539        let row = Row::decode(&object(vec![
540            ("created_at", Value::Timestamptz(0)),
541            ("last_error", Value::Str("boom".into())),
542            ("attempt", Value::Int64(1)),
543            ("id", Value::Uuid(id)),
544        ]))
545        .unwrap();
546        assert_eq!(row.last_error.as_deref(), Some("boom"));
547        assert_eq!(row.id, id);
548    }
549
550    /// The bug a positional check exists to catch: a struct field the query
551    /// never selected has to be an error, not a default.
552    #[test]
553    fn a_field_the_shape_omitted_is_an_error() {
554        let error = Row::decode(&object(vec![
555            ("id", Value::Uuid(uuid::Uuid::nil())),
556            ("attempt", Value::Int64(1)),
557            ("created_at", Value::Timestamptz(0)),
558        ]))
559        .unwrap_err();
560        assert_eq!(error.kind(), &DecodeErrorKind::MissingField("last_error".to_string()));
561        assert_eq!(
562            error.to_string(),
563            "cannot decode Row: the query's shape has no 'last_error'"
564        );
565    }
566
567    #[test]
568    fn nested_failures_name_their_whole_path() {
569        let error = Nested::decode(&object(vec![
570            ("latest_version", object(vec![("runner", Value::Int64(4))])),
571            ("type", Value::Str("contact.created".into())),
572        ]))
573        .unwrap_err();
574        assert_eq!(
575            error.to_string(),
576            "cannot decode Nested.latest_version.runner: expected a string, got an integer"
577        );
578    }
579
580    #[test]
581    fn decodes_a_renamed_enum_from_either_a_real_enum_or_a_str_cast() {
582        let from_enum = WebhookEvent::decode(&Value::Enum {
583            type_name: "integration::WebhookEvent".to_string(),
584            value: "contact.updated".to_string(),
585        })
586        .unwrap();
587        assert_eq!(from_enum, WebhookEvent::ContactUpdated);
588        // `<str>.event` — the cast is applied server-side, so the label
589        // arrives as a plain string.
590        let from_cast = WebhookEvent::decode(&Value::Str("contact.created".into())).unwrap();
591        assert_eq!(from_cast, WebhookEvent::ContactCreated);
592        // An un-renamed variant keeps its Rust name as the label.
593        assert_eq!(
594            WebhookEvent::decode(&Value::Str("Other".into())).unwrap(),
595            WebhookEvent::Other
596        );
597    }
598
599    #[test]
600    fn an_unknown_enum_label_names_the_label_it_saw() {
601        let error = WebhookEvent::decode(&Value::Str("contact.merged".into())).unwrap_err();
602        assert_eq!(
603            error.to_string(),
604            "cannot decode WebhookEvent: 'contact.merged' is not a known variant"
605        );
606    }
607
608    /// An optional link that is itself empty, inside an optional outer link —
609    /// `event-bridge`'s `TransformerRefRow` shape.
610    #[test]
611    fn decodes_doubly_optional_nesting() {
612        let row = Nested::decode(&object(vec![
613            ("latest_version", Value::Null),
614            ("type", Value::Str("Other".into())),
615        ]))
616        .unwrap();
617        assert_eq!(row.latest_version, None);
618    }
619
620    /// A caller decoding `query_*_json` itself still reports through this
621    /// crate's error type.
622    #[test]
623    fn a_custom_decode_failure_converts_into_the_crate_error() {
624        let error: crate::Error = DecodeError::custom("failed to decode query result JSON: eof").into();
625        assert_eq!(
626            error.to_string(),
627            "cannot decode query result: failed to decode query result JSON: eof"
628        );
629    }
630
631    #[test]
632    fn narrow_integers_range_check_instead_of_wrapping() {
633        assert_eq!(i32::decode(&Value::Int64(-5)).unwrap(), -5);
634        let error = i32::decode(&Value::Int64(i64::from(i32::MAX) + 1)).unwrap_err();
635        assert_eq!(
636            error.kind(),
637            &DecodeErrorKind::OutOfRange {
638                value: 2_147_483_648,
639                target: "i32"
640            }
641        );
642    }
643
644    #[test]
645    fn a_multi_pointer_decodes_into_a_vec() {
646        let rows: Vec<Inner> = Vec::decode(&Value::Array(vec![
647            object(vec![("runner", Value::Str("lambda".into()))]),
648            object(vec![("runner", Value::Str("firecracker".into()))]),
649        ]))
650        .unwrap();
651        assert_eq!(rows.len(), 2);
652        assert_eq!(rows[1].runner, "firecracker");
653        // An empty multi pointer comes back as an empty set, not as an error.
654        assert_eq!(Vec::<Inner>::decode(&Value::Null).unwrap(), vec![]);
655    }
656
657    #[test]
658    fn a_json_field_deserializes_from_a_natively_decoded_document() {
659        #[derive(Debug, PartialEq, crate::Queryable)]
660        #[pylon(crate_path = crate)]
661        struct WithJson {
662            #[pylon(json)]
663            payload: Option<serde_json::Value>,
664        }
665        // Pylon decodes `jsonb` natively, so the column arrives as an
666        // Object/Array, never as a string of JSON text.
667        let row = WithJson::decode(&object(vec![(
668            "payload",
669            Value::Object(Object {
670                type_name: None,
671                fields: vec![("email".to_string(), Value::Str("a@b.test".into()))],
672                implicit_id: false,
673            }),
674        )]))
675        .unwrap();
676        assert_eq!(row.payload.unwrap()["email"], "a@b.test");
677    }
678
679    /// A duration binds as an interval whose whole span is microseconds —
680    /// months and days are calendar-relative and a `std::time::Duration` is not.
681    #[test]
682    fn a_duration_binds_as_a_fixed_microsecond_interval() {
683        let bound = crate::QueryArg::to_decoded(&std::time::Duration::from_secs(90));
684        assert_eq!(
685            bound,
686            pylon_value::DecodedValue::Interval {
687                months: 0,
688                days: 0,
689                microseconds: 90_000_000
690            }
691        );
692    }
693
694    #[test]
695    fn positional_arguments_bind_by_index() {
696        let id = uuid::Uuid::from_u128(9);
697        // `to_params` borrows the names out of the argument collection, so
698        // the collection has to outlive the params — exactly as it does at a
699        // real call site, where it is a temporary in the `query(...)` call.
700        let args = (id, "urgent", 4i32);
701        let params = args.to_params();
702        assert_eq!(params[0].0, "0");
703        assert_eq!(params[1], ("1", pylon_value::DecodedValue::Str("urgent".into())));
704        assert_eq!(params[2], ("2", pylon_value::DecodedValue::I64(4)));
705        assert!(().to_params().is_empty());
706    }
707
708    #[test]
709    fn named_arguments_accept_mixed_types_including_absent_optionals() {
710        let args = named_args! {
711            "id" => uuid::Uuid::nil(),
712            "width" => Option::<i32>::None,
713            "labels" => vec!["a".to_string()],
714        };
715        let params: std::collections::HashMap<_, _> = args.to_params().into_iter().collect();
716        assert_eq!(params["width"], pylon_value::DecodedValue::Null);
717        assert_eq!(
718            params["labels"],
719            pylon_value::DecodedValue::Array(vec![pylon_value::DecodedValue::Str("a".into())])
720        );
721        assert_eq!(params["id"], pylon_value::DecodedValue::Uuid([0; 16]));
722    }
723
724    /// A datetime argument and a datetime result have to agree about the
725    /// epoch, or every timestamp written back is 30 years out.
726    #[test]
727    fn datetimes_round_trip_through_the_pg_epoch() {
728        let when = chrono::DateTime::parse_from_rfc3339("2026-09-24T12:34:56Z")
729            .unwrap()
730            .with_timezone(&chrono::Utc);
731        let bound = crate::QueryArg::to_decoded(&when);
732        let pylon_value::DecodedValue::Timestamptz(micros) = bound else {
733            panic!("a datetime must bind as a timestamptz, got {bound:?}");
734        };
735        assert_eq!(
736            chrono::DateTime::<chrono::Utc>::decode(&Value::Timestamptz(micros)).unwrap(),
737            when
738        );
739    }
740}