Skip to main content

pylon_value/
lib.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//! A purpose-built, self-describing value tree for decoded Postgres rows.
21//!
22//! Not `serde_json::Value` (would blur int64 vs float64 vs decimal vs uuid —
23//! real distinctions PyQL's own type system preserves) and not raw
24//! serialization-of-arbitrary-PyObject (Rust has no way to serialize an
25//! arbitrary *registered* Python dataclass — those only exist Python-side).
26//! Instead, this sits at the same layer `pylon.query.deserialize()`'s
27//! `_decode()` consumes today: already decoded from Postgres wire format
28//! (never raw bytes), but still generic/positional, not yet hydrated into a
29//! user class.
30//!
31//! This is the shared decode target for both `pylon-cache` (stores it,
32//! serialized via `rkyv`) and `pylon-pgcon` (the Postgres driver — decodes
33//! wire bytes straight into this same representation) — the whole point
34//! being one decode path regardless of whether a result came fresh from
35//! Postgres or from the LMDB cache. Deliberately has no dependency on
36//! `pylon-core`, pyo3, or any I/O crate: it's just the value shape.
37//!
38//! Serialized with `rkyv` (zero-copy) rather than `bincode` — `bincode` is
39//! effectively unmaintained upstream (its 3.0.0 release is a deliberate
40//! `compile_error!` protest, forcing anyone pinned loosely onto the
41//! archived 2.x line).
42
43use rkyv::{Archive, Deserialize, Serialize};
44
45#[derive(Debug, Clone, PartialEq, Archive, Serialize, Deserialize)]
46#[rkyv(
47    compare(PartialEq),
48    derive(Debug),
49    serialize_bounds(__S: rkyv::ser::Writer + rkyv::ser::Allocator),
50    deserialize_bounds(__D::Error: rkyv::rancor::Source),
51    // Needed for the same reason as `omit_bounds` below, but on the
52    // validation side: when a *consumer* turns on rkyv's `bytecheck` feature
53    // (this crate does not), the derive also emits a `Verify` impl, and the
54    // recursive fields leave it without the context bound. Only parsed when
55    // that feature is on, so it costs nothing when it is off.
56    bytecheck(bounds(__C: rkyv::validation::ArchiveContext)),
57)]
58pub enum DecodedValue {
59    Null,
60    Bool(bool),
61    I64(i64),
62    F64(f64),
63    Str(String),
64    Bytes(Vec<u8>),
65    /// Raw 16-byte UUID, matching `QueryParam::Uuid`'s own convention in
66    /// `pylon-core`. Stored as raw bytes rather than a `uuid::Uuid` field
67    /// (every consuming crate already re-wraps this in its own richer type
68    /// on the way out — e.g. `pylon-client`'s `Value::Uuid(uuid::Uuid)` —
69    /// so there's no benefit to carrying that type this deep); `uuid` is
70    /// still a dependency of this crate, purely so `From<uuid::Uuid>` below
71    /// can convert into this variant without every caller writing
72    /// `.into_bytes()` by hand.
73    Uuid([u8; 16]),
74    /// Arbitrary-precision decimal, stored as its canonical string form
75    /// (matches how `_pg_decode_numeric` round-trips today) rather than a
76    /// lossy f64 or a bespoke bignum encoding.
77    Decimal(String),
78    /// A PostgreSQL `interval` — backs both Pylon's `std::duration` (months
79    /// always 0 by convention) and `cal::relative_duration` (months may be
80    /// nonzero). Kept as the three raw wire components rather than folded
81    /// into a single duration, since `months` (a calendar-relative unit —
82    /// "1 month" isn't a fixed number of days) can't be losslessly combined
83    /// with `days`/`microseconds` without a reference date.
84    Interval {
85        months: i32,
86        days: i32,
87        microseconds: i64,
88    },
89    /// PostgreSQL `date` — whole days since the PG epoch (2000-01-01),
90    /// exactly as the wire encodes it. Backs `cal::local_date`.
91    Date(i32),
92    /// PostgreSQL `time` (no timezone) — microseconds since midnight,
93    /// exactly as the wire encodes it. Backs `cal::local_time`.
94    Time(i64),
95    /// PostgreSQL `timestamp` (no timezone) — microseconds since the PG
96    /// epoch (2000-01-01T00:00:00), exactly as the wire encodes it. Backs
97    /// `cal::local_datetime`; decodes to a naive `datetime.datetime`.
98    Timestamp(i64),
99    /// PostgreSQL `timestamptz` — microseconds since the PG epoch
100    /// (2000-01-01T00:00:00 UTC; PostgreSQL always normalizes `timestamptz`
101    /// to UTC on the wire, regardless of session timezone), exactly as the
102    /// wire encodes it. Backs `std::datetime`; decodes to a UTC-aware
103    /// `datetime.datetime`. A distinct variant from `Timestamp` (not a
104    /// shared representation with a tag) so decode/encode can't mix up
105    /// naive vs. aware at the type level.
106    Timestamptz(i64),
107    // `omit_bounds` is required on self-referential fields: rkyv's derive
108    // otherwise adds a naive `FieldType: Archive` bound per field, which
109    // for a directly-recursive type like this overflows trait resolution
110    // (`DecodedValue: Archive` requires `Vec<DecodedValue>: Archive` requires
111    // `DecodedValue: Archive`, forever) — see rkyv's own docs on recursive
112    // types.
113    /// A genuine Postgres array (`text[]`, `int8[]`, ...) — reconstructed
114    /// Python-side as a `list`, matching what has always decoded a
115    /// Postgres array into. Do not use this for a composite/record's
116    /// positional fields; see `Composite`.
117    Array(#[rkyv(omit_bounds)] Vec<DecodedValue>),
118    /// A positional composite (`record` — a schema object's own field
119    /// tuple, or a nested `ROW(...)`), reconstructed Python-side as a
120    /// `tuple`, matching a record row's own behavior — critically,
121    /// `isinstance(a_tuple, (dict, list))` is `False`, the same as a real
122    /// a record row, which `pylon.query._decode()`'s `"named_tuple"`
123    /// case relies on to tell "this position holds a raw jsonb value"
124    /// apart from "this position holds a composite that needs `value[pos]`
125    /// indexing first." Using `Array` (→ `list`) here instead silently
126    /// breaks that check — a real bug caught by comparing decoded output
127    /// against the live driver path on real queries.
128    Composite(#[rkyv(omit_bounds)] Vec<DecodedValue>),
129    /// Field name + value pairs, in shape order (not a map — field order is
130    /// part of what `ShapeNode` positions describe, and duplicate names
131    /// can't happen for a single object's own pointers).
132    Object(#[rkyv(omit_bounds)] Vec<(String, DecodedValue)>),
133    /// A PostgreSQL range value (`int8range`, `numrange`, `tsrange`,
134    /// `tstzrange`, `daterange`, ...). `lower`/`upper` are `None` for an
135    /// unbounded side; `empty == true` means the whole range is empty
136    /// (`lower`/`upper` are meaningless in that case, not "both unbounded" —
137    /// PostgreSQL's own binary encoding distinguishes the two). A
138    /// `multirange<T>` decodes to a plain `Array` of these, not a separate
139    /// variant — it's just an ordered collection of ranges.
140    Range {
141        #[rkyv(omit_bounds)]
142        lower: Option<Box<DecodedValue>>,
143        #[rkyv(omit_bounds)]
144        upper: Option<Box<DecodedValue>>,
145        inc_lower: bool,
146        inc_upper: bool,
147        empty: bool,
148    },
149    /// A jsonb number, as the digits it was written with.
150    ///
151    /// JSON has one number type, so the value alone cannot say whether it
152    /// was a `float64` or a `decimal` — but `12.3400` reaching here through
153    /// an `f64` has already lost its scale, and a value wider than a float
154    /// has lost more than that. The text survives instead, and what reads
155    /// it decides: a tuple member declared `decimal` hydrates to one, and
156    /// everything else to the float it has always been.
157    ///
158    /// Appended at the end on purpose — see `CACHE_FORMAT_VERSION`.
159    JsonNumber(String),
160}
161
162// ── Native-type conversions ──────────────────────────────────────────────────
163//
164// Lets a caller write `"id".into()`/`some_uuid.into()` instead of spelling
165// out `DecodedValue::Uuid(...)` at every query-parameter call site. The
166// wrapper enum itself isn't going away — a query parameter/result still has
167// to carry its own runtime type tag — but constructing one shouldn't require
168// spelling out the variant name by hand for the common cases.
169//
170// `i16`/`i32` and `f32` widen into this crate's single `I64`/`F64` variants
171// rather than getting their own — `DecodedValue` has never distinguished
172// integer/float width the way Postgres's own wire protocol does
173// (every integer column decodes to `I64`, every float column to `F64`
174// already, regardless of the underlying `int2`/`int4`/`int8` or
175// `float4`/`float8` column type), so these conversions just meet that
176// existing convention rather than introduce a new one.
177
178impl From<String> for DecodedValue {
179    fn from(value: String) -> Self {
180        DecodedValue::Str(value)
181    }
182}
183
184impl From<&str> for DecodedValue {
185    fn from(value: &str) -> Self {
186        DecodedValue::Str(value.to_string())
187    }
188}
189
190impl From<bool> for DecodedValue {
191    fn from(value: bool) -> Self {
192        DecodedValue::Bool(value)
193    }
194}
195
196impl From<i16> for DecodedValue {
197    fn from(value: i16) -> Self {
198        DecodedValue::I64(value.into())
199    }
200}
201
202impl From<i32> for DecodedValue {
203    fn from(value: i32) -> Self {
204        DecodedValue::I64(value.into())
205    }
206}
207
208impl From<i64> for DecodedValue {
209    fn from(value: i64) -> Self {
210        DecodedValue::I64(value)
211    }
212}
213
214impl From<f32> for DecodedValue {
215    fn from(value: f32) -> Self {
216        DecodedValue::F64(value.into())
217    }
218}
219
220impl From<f64> for DecodedValue {
221    fn from(value: f64) -> Self {
222        DecodedValue::F64(value)
223    }
224}
225
226impl From<Vec<u8>> for DecodedValue {
227    fn from(value: Vec<u8>) -> Self {
228        DecodedValue::Bytes(value)
229    }
230}
231
232impl From<uuid::Uuid> for DecodedValue {
233    fn from(value: uuid::Uuid) -> Self {
234        DecodedValue::Uuid(value.into_bytes())
235    }
236}
237
238/// One cache entry: the cached rows plus the tags a write to any of which
239/// must invalidate it — stored together so invalidation never needs a
240/// second lookup to find out what a key was tagged with.
241#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
242#[rkyv(derive(Debug))]
243pub struct CachedEntry {
244    pub rows: Vec<DecodedValue>,
245    pub tags: Vec<String>,
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use rkyv::rancor::Error;
252
253    #[test]
254    fn round_trips_every_variant() {
255        let value = DecodedValue::Object(vec![
256            ("id".into(), DecodedValue::Uuid([1; 16])),
257            ("name".into(), DecodedValue::Str("Alice".into())),
258            ("age".into(), DecodedValue::I64(30)),
259            ("score".into(), DecodedValue::F64(1.5)),
260            ("active".into(), DecodedValue::Bool(true)),
261            ("balance".into(), DecodedValue::Decimal("12.50".into())),
262            (
263                "tags".into(),
264                DecodedValue::Array(vec![DecodedValue::Str("a".into()), DecodedValue::Null]),
265            ),
266            ("avatar".into(), DecodedValue::Bytes(vec![1, 2, 3])),
267            (
268                "point".into(),
269                DecodedValue::Composite(vec![DecodedValue::F64(1.0), DecodedValue::F64(2.0)]),
270            ),
271            (
272                "span".into(),
273                DecodedValue::Interval {
274                    months: 1,
275                    days: 2,
276                    microseconds: 3_600_000_000,
277                },
278            ),
279            ("day".into(), DecodedValue::Date(9525)),
280            ("clock".into(), DecodedValue::Time(3_600_000_000)),
281            ("naive_ts".into(), DecodedValue::Timestamp(1_000_000_000)),
282            ("aware_ts".into(), DecodedValue::Timestamptz(1_000_000_000)),
283            (
284                "span_range".into(),
285                DecodedValue::Range {
286                    lower: Some(Box::new(DecodedValue::I64(1))),
287                    upper: Some(Box::new(DecodedValue::I64(10))),
288                    inc_lower: true,
289                    inc_upper: false,
290                    empty: false,
291                },
292            ),
293        ]);
294        let bytes = rkyv::to_bytes::<Error>(&value).unwrap();
295        // SAFETY: bytes were produced moments ago by `to_bytes` on this same
296        // type, in this same process — not untrusted external input, so the
297        // `bytecheck`-validated safe `access` API (which this crate opts out
298        // of entirely; see the `default-features = false` note in Cargo.toml)
299        // isn't needed here.
300        let archived = unsafe { rkyv::access_unchecked::<ArchivedDecodedValue>(&bytes) };
301        let decoded: DecodedValue = rkyv::deserialize::<DecodedValue, Error>(archived).unwrap();
302        assert_eq!(decoded, value);
303    }
304
305    #[test]
306    fn round_trips_cached_entry() {
307        let entry = CachedEntry {
308            rows: vec![DecodedValue::I64(1), DecodedValue::I64(2)],
309            tags: vec!["public.person".into()],
310        };
311        let bytes = rkyv::to_bytes::<Error>(&entry).unwrap();
312        // SAFETY: see the comment in `round_trips_every_variant` above.
313        let archived = unsafe { rkyv::access_unchecked::<ArchivedCachedEntry>(&bytes) };
314        let decoded: CachedEntry = rkyv::deserialize::<CachedEntry, Error>(archived).unwrap();
315        assert_eq!(decoded.rows, entry.rows);
316        assert_eq!(decoded.tags, entry.tags);
317    }
318
319    #[test]
320    fn from_native_string_types() {
321        assert_eq!(
322            DecodedValue::from("hello".to_string()),
323            DecodedValue::Str("hello".into())
324        );
325        assert_eq!(DecodedValue::from("hello"), DecodedValue::Str("hello".into()));
326    }
327
328    #[test]
329    fn from_native_bool() {
330        assert_eq!(DecodedValue::from(true), DecodedValue::Bool(true));
331    }
332
333    #[test]
334    fn from_native_integers_widen_into_i64() {
335        assert_eq!(DecodedValue::from(1i16), DecodedValue::I64(1));
336        assert_eq!(DecodedValue::from(2i32), DecodedValue::I64(2));
337        assert_eq!(DecodedValue::from(3i64), DecodedValue::I64(3));
338    }
339
340    #[test]
341    fn from_native_floats_widen_into_f64() {
342        assert_eq!(DecodedValue::from(1.5f32), DecodedValue::F64(1.5));
343        assert_eq!(DecodedValue::from(2.5f64), DecodedValue::F64(2.5));
344    }
345
346    #[test]
347    fn from_native_bytes() {
348        assert_eq!(DecodedValue::from(vec![1u8, 2, 3]), DecodedValue::Bytes(vec![1, 2, 3]));
349    }
350
351    #[test]
352    fn from_native_uuid() {
353        let u = uuid::Uuid::from_bytes([7; 16]);
354        assert_eq!(DecodedValue::from(u), DecodedValue::Uuid([7; 16]));
355    }
356
357    #[test]
358    fn into_conversion_works_at_a_query_param_style_call_site() {
359        // The motivating case: `&[(&str, DecodedValue)]`-shaped params
360        // accepting `.into()` instead of the explicit variant.
361        let params: Vec<(&str, DecodedValue)> =
362            vec![("name", "Ada".into()), ("age", 30i64.into()), ("active", true.into())];
363        assert_eq!(params[0].1, DecodedValue::Str("Ada".into()));
364        assert_eq!(params[1].1, DecodedValue::I64(30));
365        assert_eq!(params[2].1, DecodedValue::Bool(true));
366    }
367}