Skip to main content

ant_types/
property.rs

1//! `PropertyValue` — the typed value of a vertex/edge property.
2//!
3//! ## Why the wire form is what it is
4//!
5//! This type used to be `#[serde(untagged)]`: a value serialized as
6//! bare JSON and deserialized by trying the variants in order. That is
7//! exactly right for `Null | Bool | Long | Float | Text | Json`, whose
8//! JSON shapes are already distinct, and it is the shape every existing
9//! client, every stored RocksDB record, and every `.ant` file on disk
10//! is written in.
11//!
12//! It cannot express the SQL types. `DECIMAL`, `DATE`, `TIME`,
13//! `TIMESTAMP`, `UUID` and `BLOB` all serialize as JSON strings, so an
14//! untagged decode of `"2024-03-01"` cannot tell a DATE from a TEXT
15//! that happens to look like one — the type is lost on the first
16//! round-trip. Storing a type you cannot read back is not parity.
17//!
18//! So the encoding is split by variant:
19//!
20//! - **The six legacy variants serialize exactly as before** — bare
21//!   `null`, `true`, `42`, `1.5`, `"hi"`, `{...}`. Byte-identical
22//!   output, byte-identical parsing. Old records read back unchanged
23//!   and old clients see no difference, because for these values there
24//!   IS no difference.
25//! - **The SQL-parity variants serialize as a tagged envelope**,
26//!   `{"$ant":"decimal","v":"12.34"}`. Self-describing, so the type
27//!   survives store → `.ant` → store, and unambiguous, so it can never
28//!   be confused with a `Text` that looks similar.
29//!
30//! An object is only read as an envelope when it has exactly the two
31//! keys `$ant` and `v` AND `$ant` names a known type. Anything else is
32//! a `Json` value, so a caller's own document containing a `$ant` field
33//! is still stored as their document (there is a test for precisely
34//! this).
35//!
36//! ## The compatibility surface
37//!
38//! `/public/v1` is the OpenSPG/KAG-compatible API and must keep
39//! emitting what it always emitted, so it projects through
40//! [`PropertyValue::to_compat_json`], which flattens the typed variants
41//! back to the bare scalars they would have been stored as before this
42//! existed. The Antares-native API uses [`PropertyValue::to_json`] and
43//! sees the real types.
44
45use std::cmp::Ordering;
46
47use base64::Engine as _;
48use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime};
49use serde::{Deserialize, Deserializer, Serialize, Serializer};
50use serde_json::Value;
51
52use crate::decimal::Decimal;
53
54/// The key that marks a tagged envelope.
55const TAG: &str = "$ant";
56/// The key holding an envelope's payload.
57const VAL: &str = "v";
58
59/// Typed property value, at SQL fidelity.
60#[derive(Debug, Clone, PartialEq)]
61pub enum PropertyValue {
62    // ---- Legacy scalars: bare JSON on the wire, unchanged forever ----
63    /// SQL NULL / JSON `null`.
64    Null,
65    /// Boolean.
66    Bool(bool),
67    /// SQL BIGINT.
68    Long(i64),
69    /// SQL DOUBLE PRECISION. Already a double — the gap this type had
70    /// was never width, it was exactness, and `Decimal` fills that.
71    Float(f64),
72    /// Free-form text.
73    Text(String),
74    /// Arbitrary nested JSON (JSONB columns, document subdocuments).
75    Json(Value),
76
77    // ---- SQL parity: tagged envelope on the wire ----
78    /// SQL INT/INTEGER. Distinct from `Long` so a 32-bit column does
79    /// not silently come back 64-bit on round-trip.
80    Int32(i32),
81    /// SQL SMALLINT.
82    Int16(i16),
83    /// SQL DECIMAL/NUMERIC and money. Exact — never `f64`.
84    Decimal(Decimal),
85    /// SQL DATE.
86    Date(NaiveDate),
87    /// SQL TIME.
88    Time(NaiveTime),
89    /// SQL TIMESTAMP WITH TIME ZONE. The offset is part of the value
90    /// and is preserved verbatim: `+02:00` does not come back as `Z`.
91    Timestamp(DateTime<FixedOffset>),
92    /// SQL UUID/UNIQUEIDENTIFIER.
93    Uuid(uuid::Uuid),
94    /// SQL BLOB/BYTEA/VARBINARY. Base64 in every JSON form.
95    Bytes(Vec<u8>),
96    /// SQL array / document-store array. Elements are themselves typed,
97    /// so `Array<Decimal>` stays exact.
98    Array(Vec<PropertyValue>),
99}
100
101impl PropertyValue {
102    /// The `&str` inside a `Text` value; `None` for every other variant.
103    pub fn as_str(&self) -> Option<&str> {
104        if let Self::Text(s) = self {
105            Some(s.as_str())
106        } else {
107            None
108        }
109    }
110
111    /// The tag name used in the envelope and in error/type reporting.
112    pub fn type_name(&self) -> &'static str {
113        match self {
114            Self::Null => "null",
115            Self::Bool(_) => "bool",
116            Self::Long(_) => "long",
117            Self::Float(_) => "float",
118            Self::Text(_) => "text",
119            Self::Json(_) => "json",
120            Self::Int32(_) => "int32",
121            Self::Int16(_) => "int16",
122            Self::Decimal(_) => "decimal",
123            Self::Date(_) => "date",
124            Self::Time(_) => "time",
125            Self::Timestamp(_) => "timestamp",
126            Self::Uuid(_) => "uuid",
127            Self::Bytes(_) => "bytes",
128            Self::Array(_) => "array",
129        }
130    }
131
132    /// Base64 alphabet used for `Bytes` everywhere (standard, padded).
133    fn b64() -> base64::engine::general_purpose::GeneralPurpose {
134        base64::engine::general_purpose::STANDARD
135    }
136
137    /// Canonical text of an envelope payload, for the variants whose
138    /// payload is a string.
139    fn payload(&self) -> Option<Value> {
140        Some(match self {
141            Self::Int32(i) => Value::from(*i),
142            Self::Int16(i) => Value::from(*i),
143            Self::Decimal(d) => Value::String(d.to_string()),
144            Self::Date(d) => Value::String(d.format("%Y-%m-%d").to_string()),
145            // Fixed 6-digit fraction so lexicographic order matches
146            // chronological order for anyone sorting the raw strings.
147            Self::Time(t) => Value::String(t.format("%H:%M:%S%.6f").to_string()),
148            Self::Timestamp(ts) => Value::String(ts.to_rfc3339()),
149            Self::Uuid(u) => Value::String(u.to_string()),
150            Self::Bytes(b) => Value::String(Self::b64().encode(b)),
151            Self::Array(items) => Value::Array(items.iter().map(Self::to_json).collect()),
152            _ => return None,
153        })
154    }
155
156    /// The type-PRESERVING JSON form: what serde emits, what RocksDB
157    /// stores, and what a `.ant` file carries. Legacy variants are bare
158    /// scalars; SQL-parity variants are envelopes.
159    pub fn to_json(&self) -> Value {
160        match self {
161            Self::Null => Value::Null,
162            Self::Bool(b) => Value::Bool(*b),
163            Self::Long(i) => Value::from(*i),
164            Self::Float(f) => serde_json::Number::from_f64(*f)
165                .map(Value::Number)
166                .unwrap_or(Value::Null),
167            Self::Text(s) => Value::String(s.clone()),
168            Self::Json(v) => v.clone(),
169            other => {
170                let mut o = serde_json::Map::with_capacity(2);
171                o.insert(TAG.into(), Value::String(other.type_name().into()));
172                o.insert(VAL.into(), other.payload().expect("parity variant"));
173                Value::Object(o)
174            }
175        }
176    }
177
178    /// The BACKWARD-COMPATIBLE JSON form for `/public/v1`: every typed
179    /// value flattened to the bare scalar it would have been stored as
180    /// before SQL parity existed.
181    ///
182    /// Deliberately lossy. The KAG-compatible surface has a published
183    /// shape and a generation of clients that parse it; emitting an
184    /// envelope there would widen the contract for every caller, in
185    /// exchange for a type they never asked for. Callers who want the
186    /// types use the Antares-native API.
187    pub fn to_compat_json(&self) -> Value {
188        match self {
189            // Ints widen to JSON numbers, as they did when the schema
190            // carried the width and the value was stored as Long.
191            Self::Int32(i) => Value::from(*i),
192            Self::Int16(i) => Value::from(*i),
193            // Decimal stayed a canonical string, and must keep doing so
194            // — rendering it as a JSON number here would hand the
195            // f64 corruption straight back to the client.
196            Self::Decimal(d) => Value::String(d.to_string()),
197            Self::Date(_) | Self::Time(_) | Self::Timestamp(_) | Self::Uuid(_) | Self::Bytes(_) => {
198                self.payload().expect("string-payload variant")
199            }
200            Self::Array(items) => Value::Array(items.iter().map(Self::to_compat_json).collect()),
201            legacy => legacy.to_json(),
202        }
203    }
204
205    /// Read the type-preserving form back. Unknown/!malformed envelopes
206    /// fall through to `Json`, so no input is ever rejected here — this
207    /// runs against stored data, where refusing to decode would mean
208    /// losing a record that is already durable.
209    pub fn from_json(v: Value) -> Self {
210        match v {
211            Value::Null => Self::Null,
212            Value::Bool(b) => Self::Bool(b),
213            Value::Number(n) => n
214                .as_i64()
215                .map(Self::Long)
216                .or_else(|| n.as_f64().map(Self::Float))
217                .unwrap_or(Self::Null),
218            Value::String(s) => Self::Text(s),
219            Value::Array(_) => Self::Json(v),
220            Value::Object(ref o) => match Self::from_object(o) {
221                Some(typed) => typed,
222                None => Self::Json(v),
223            },
224        }
225    }
226
227    /// `Some` only for a well-formed envelope: exactly `{$ant, v}` with
228    /// a known tag and a payload that parses. Anything else is the
229    /// caller's own JSON document and must be preserved as such.
230    fn from_object(o: &serde_json::Map<String, Value>) -> Option<Self> {
231        if o.len() != 2 {
232            return None;
233        }
234        let tag = o.get(TAG)?.as_str()?;
235        let v = o.get(VAL)?;
236        let text = || v.as_str();
237        Some(match tag {
238            "int32" => Self::Int32(i32::try_from(v.as_i64()?).ok()?),
239            "int16" => Self::Int16(i16::try_from(v.as_i64()?).ok()?),
240            "decimal" => Self::Decimal(Decimal::parse(text()?)?),
241            "date" => Self::Date(NaiveDate::parse_from_str(text()?, "%Y-%m-%d").ok()?),
242            "time" => Self::Time(parse_time(text()?)?),
243            "timestamp" => Self::Timestamp(DateTime::parse_from_rfc3339(text()?).ok()?),
244            "uuid" => Self::Uuid(uuid::Uuid::parse_str(text()?).ok()?),
245            "bytes" => Self::Bytes(Self::b64().decode(text()?).ok()?),
246            "array" => Self::Array(v.as_array()?.iter().cloned().map(Self::from_json).collect()),
247            _ => return None,
248        })
249    }
250}
251
252/// `HH:MM:SS`, with or without a fractional part.
253fn parse_time(s: &str) -> Option<NaiveTime> {
254    NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
255        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S"))
256        .ok()
257}
258
259impl Serialize for PropertyValue {
260    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
261        self.to_json().serialize(s)
262    }
263}
264
265impl<'de> Deserialize<'de> for PropertyValue {
266    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
267        Ok(Self::from_json(Value::deserialize(d)?))
268    }
269}
270
271// ---------------------------------------------------------------------
272// Ordering
273// ---------------------------------------------------------------------
274
275/// Cross-type sort rank. A value that can be stored but not compared is
276/// a trap — a WHERE clause silently matching nothing, an ORDER BY that
277/// drops rows — so EVERY variant is comparable against every other, and
278/// values of unlike types fall back to this ordering rather than being
279/// declared incomparable.
280fn rank(v: &PropertyValue) -> u8 {
281    match v {
282        PropertyValue::Null => 0,
283        PropertyValue::Bool(_) => 1,
284        // Every numeric shares a rank so they compare by value.
285        PropertyValue::Long(_)
286        | PropertyValue::Int32(_)
287        | PropertyValue::Int16(_)
288        | PropertyValue::Float(_)
289        | PropertyValue::Decimal(_) => 2,
290        PropertyValue::Date(_) => 3,
291        PropertyValue::Time(_) => 4,
292        PropertyValue::Timestamp(_) => 5,
293        PropertyValue::Text(_) => 6,
294        PropertyValue::Uuid(_) => 7,
295        PropertyValue::Bytes(_) => 8,
296        PropertyValue::Array(_) => 9,
297        PropertyValue::Json(_) => 10,
298    }
299}
300
301/// The integer value of any integral variant.
302fn as_int(v: &PropertyValue) -> Option<i128> {
303    Some(match v {
304        PropertyValue::Long(i) => *i as i128,
305        PropertyValue::Int32(i) => *i as i128,
306        PropertyValue::Int16(i) => *i as i128,
307        _ => return None,
308    })
309}
310
311/// Any numeric as an exact decimal. `Float` is excluded on purpose:
312/// binary floating point has no exact decimal form, so mixing it in
313/// here would fake an exactness that isn't there.
314fn as_exact(v: &PropertyValue) -> Option<Decimal> {
315    match v {
316        PropertyValue::Decimal(d) => Some(*d),
317        other => Decimal::from_parts(as_int(other)?, 0),
318    }
319}
320
321fn as_f64(v: &PropertyValue) -> Option<f64> {
322    match v {
323        PropertyValue::Float(f) => Some(*f),
324        PropertyValue::Decimal(d) => d.to_string().parse().ok(),
325        other => as_int(other).map(|i| i as f64),
326    }
327}
328
329impl PropertyValue {
330    /// Total ordering used by ORDER BY and by range comparisons.
331    ///
332    /// Within a rank the comparison is type-appropriate: integers and
333    /// decimals compare EXACTLY (a decimal never round-trips through
334    /// `f64` to be compared), timestamps compare as instants so a
335    /// `+02:00` value orders correctly against a `Z` one, bytes compare
336    /// lexicographically, and arrays compare element-wise then by
337    /// length.
338    pub fn cmp_value(&self, other: &Self) -> Ordering {
339        use PropertyValue as P;
340        match (self, other) {
341            (P::Bool(a), P::Bool(b)) => a.cmp(b),
342            (P::Text(a), P::Text(b)) => a.cmp(b),
343            (P::Date(a), P::Date(b)) => a.cmp(b),
344            (P::Time(a), P::Time(b)) => a.cmp(b),
345            // DateTime<FixedOffset> compares by instant, which is the
346            // SQL TIMESTAMPTZ rule.
347            (P::Timestamp(a), P::Timestamp(b)) => a.cmp(b),
348            (P::Uuid(a), P::Uuid(b)) => a.cmp(b),
349            (P::Bytes(a), P::Bytes(b)) => a.cmp(b),
350            (P::Array(a), P::Array(b)) => a
351                .iter()
352                .zip(b.iter())
353                .map(|(x, y)| x.cmp_value(y))
354                .find(|o| *o != Ordering::Equal)
355                .unwrap_or_else(|| a.len().cmp(&b.len())),
356            (P::Json(a), P::Json(b)) => a.to_string().cmp(&b.to_string()),
357            _ if rank(self) == rank(other) => {
358                // Numeric. Prefer the exact path; fall back to f64 only
359                // when a Float is actually involved.
360                match (as_exact(self), as_exact(other)) {
361                    (Some(a), Some(b)) => a.cmp_value(&b),
362                    _ => match (as_f64(self), as_f64(other)) {
363                        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
364                        _ => Ordering::Equal,
365                    },
366                }
367            }
368            _ => rank(self).cmp(&rank(other)),
369        }
370    }
371
372    /// Whether this carries one of the SQL-parity types — i.e. a value
373    /// whose wire form is a tagged envelope.
374    pub fn is_sql_typed(&self) -> bool {
375        !matches!(
376            self,
377            Self::Null
378                | Self::Bool(_)
379                | Self::Long(_)
380                | Self::Float(_)
381                | Self::Text(_)
382                | Self::Json(_)
383        )
384    }
385
386    /// Reinterpret an untyped literal as the type of `like`.
387    ///
388    /// A query says `WHERE due = "2024-03-01"` or `WHERE amount >
389    /// "10.50"`. The literal arrives as `Text` — the query language has
390    /// no date or decimal literal — so comparing it against a stored
391    /// `Date` or `Decimal` by cross-type rank would silently match
392    /// nothing. This pulls the literal to the stored value's type so the
393    /// comparison is the one the caller meant.
394    ///
395    /// Returns `self` unchanged when the coercion does not apply or the
396    /// literal does not parse as that type; the comparison then falls
397    /// back to cross-type ordering rather than inventing a match.
398    pub fn coerce_like(&self, like: &Self) -> Self {
399        // Already the same kind, or nothing to coerce toward.
400        if rank(self) == rank(like) || !like.is_sql_typed() {
401            return self.clone();
402        }
403        let text = match self {
404            Self::Text(s) => s.clone(),
405            // Numeric literals reach a decimal column as numbers.
406            Self::Long(i) => i.to_string(),
407            Self::Float(f) => f.to_string(),
408            _ => return self.clone(),
409        };
410        let coerced = match like {
411            Self::Decimal(_) => Decimal::parse(&text).map(Self::Decimal),
412            Self::Date(_) => NaiveDate::parse_from_str(&text, "%Y-%m-%d")
413                .ok()
414                .map(Self::Date),
415            Self::Time(_) => parse_time(&text).map(Self::Time),
416            Self::Timestamp(_) => DateTime::parse_from_rfc3339(&text)
417                .ok()
418                .map(Self::Timestamp),
419            Self::Uuid(_) => uuid::Uuid::parse_str(&text).ok().map(Self::Uuid),
420            Self::Bytes(_) => Self::b64().decode(&text).ok().map(Self::Bytes),
421            _ => None,
422        };
423        coerced.unwrap_or_else(|| self.clone())
424    }
425
426    /// Equality by VALUE rather than by representation: `Long(1)`
427    /// equals `Int32(1)` equals `Decimal("1.0")`, and a `+02:00`
428    /// timestamp equals the same instant written as `Z`. This is what a
429    /// filter comparison uses; derived `PartialEq` stays
430    /// representation-exact for tests and dedup.
431    pub fn eq_value(&self, other: &Self) -> bool {
432        self.cmp_value(other) == Ordering::Equal && rank(self) == rank(other)
433    }
434}
435
436impl PartialOrd for PropertyValue {
437    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
438        Some(self.cmp_value(other))
439    }
440}
441
442// ---------------------------------------------------------------------
443// OpenAPI
444// ---------------------------------------------------------------------
445
446/// Concrete `oneOf` so a generated client sees real types instead of
447/// `any`. Downstream code generators build typed clients off this
448/// spec, so each SQL variant is described as its own object schema
449/// with its literal tag.
450#[cfg(feature = "utoipa")]
451impl<'s> utoipa::ToSchema<'s> for PropertyValue {
452    fn schema() -> (&'s str, utoipa::openapi::RefOr<utoipa::openapi::Schema>) {
453        use utoipa::openapi::schema::{ObjectBuilder, OneOfBuilder, SchemaType};
454        use utoipa::openapi::RefOr;
455
456        /// One tagged-envelope arm: `{"$ant":"<tag>","v":<payload>}`.
457        fn arm(
458            tag: &str,
459            payload: RefOr<utoipa::openapi::Schema>,
460            desc: &str,
461        ) -> RefOr<utoipa::openapi::Schema> {
462            ObjectBuilder::new()
463                .description(Some(desc.to_string()))
464                .property(
465                    TAG,
466                    ObjectBuilder::new()
467                        .schema_type(SchemaType::String)
468                        .enum_values(Some([tag])),
469                )
470                .required(TAG)
471                .property(VAL, payload)
472                .required(VAL)
473                .into()
474        }
475
476        /// A scalar envelope payload with an optional OpenAPI format.
477        fn envelope(
478            tag: &str,
479            ty: SchemaType,
480            format: Option<&str>,
481            desc: &str,
482        ) -> RefOr<utoipa::openapi::Schema> {
483            let mut v = ObjectBuilder::new().schema_type(ty);
484            if let Some(f) = format {
485                v = v.format(Some(utoipa::openapi::SchemaFormat::Custom(f.into())));
486            }
487            arm(tag, v.into(), desc)
488        }
489
490        let scalar = |t: SchemaType, desc: &str| -> RefOr<utoipa::openapi::Schema> {
491            ObjectBuilder::new()
492                .schema_type(t)
493                .description(Some(desc.to_string()))
494                .into()
495        };
496
497        let schema = OneOfBuilder::new()
498            .description(Some(
499                "A typed property value. Legacy scalars are bare JSON; SQL-parity types \
500                 are tagged envelopes of the form {\"$ant\":\"<type>\",\"v\":<payload>}. \
501                 The /public/v1 (OpenSPG-compatible) endpoints always emit the bare \
502                 scalar form."
503                    .to_string(),
504            ))
505            .item(scalar(SchemaType::String, "SQL TEXT/VARCHAR."))
506            .item(scalar(SchemaType::Boolean, "SQL BOOLEAN."))
507            .item(scalar(SchemaType::Integer, "SQL BIGINT."))
508            .item(scalar(SchemaType::Number, "SQL DOUBLE PRECISION."))
509            .item(scalar(SchemaType::Object, "JSON/JSONB document."))
510            .item(envelope(
511                "int32",
512                SchemaType::Integer,
513                Some("int32"),
514                "SQL INT.",
515            ))
516            .item(envelope(
517                "int16",
518                SchemaType::Integer,
519                Some("int32"),
520                "SQL SMALLINT.",
521            ))
522            .item(envelope(
523                "decimal",
524                SchemaType::String,
525                None,
526                "SQL DECIMAL/NUMERIC. A canonical decimal STRING, never a JSON number: \
527                 JSON numbers are parsed as f64 by most clients, which corrupts money.",
528            ))
529            .item(envelope(
530                "date",
531                SchemaType::String,
532                Some("date"),
533                "SQL DATE (YYYY-MM-DD).",
534            ))
535            .item(envelope(
536                "time",
537                SchemaType::String,
538                None,
539                "SQL TIME (HH:MM:SS.ffffff).",
540            ))
541            .item(envelope(
542                "timestamp",
543                SchemaType::String,
544                Some("date-time"),
545                "SQL TIMESTAMP WITH TIME ZONE, RFC3339. The UTC offset is part of the \
546                 value and is preserved as sent.",
547            ))
548            .item(envelope(
549                "uuid",
550                SchemaType::String,
551                Some("uuid"),
552                "SQL UUID/UNIQUEIDENTIFIER.",
553            ))
554            .item(envelope(
555                "bytes",
556                SchemaType::String,
557                Some("byte"),
558                "SQL BLOB/BYTEA, base64 (standard alphabet, padded).",
559            ))
560            // Recursive: the items are PropertyValues, which is what
561            // makes a typed array keep its element types in a
562            // generated client rather than degrading to `any[]`.
563            .item(arm(
564                "array",
565                utoipa::openapi::ArrayBuilder::new()
566                    .items(utoipa::openapi::Ref::from_schema_name("PropertyValue"))
567                    .into(),
568                "SQL array. Elements are themselves PropertyValues, so a typed array \
569                 keeps its element types.",
570            ))
571            .build();
572        (
573            "PropertyValue",
574            RefOr::T(utoipa::openapi::Schema::OneOf(schema)),
575        )
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    fn round_trip(v: &PropertyValue) -> PropertyValue {
584        let s = serde_json::to_string(v).unwrap();
585        serde_json::from_str(&s).unwrap()
586    }
587
588    #[test]
589    fn legacy_scalars_keep_their_exact_historical_wire_form() {
590        // Byte-for-byte what the untagged encoding produced. Stored
591        // records and existing clients depend on this.
592        for (v, json) in [
593            (PropertyValue::Null, "null"),
594            (PropertyValue::Bool(true), "true"),
595            (PropertyValue::Long(42), "42"),
596            (PropertyValue::Float(1.5), "1.5"),
597            (PropertyValue::Text("hi".into()), "\"hi\""),
598        ] {
599            assert_eq!(serde_json::to_string(&v).unwrap(), json);
600            assert_eq!(round_trip(&v), v);
601        }
602        let j = PropertyValue::Json(serde_json::json!({"a":[1,2]}));
603        assert_eq!(serde_json::to_string(&j).unwrap(), r#"{"a":[1,2]}"#);
604        assert_eq!(round_trip(&j), j);
605    }
606
607    #[test]
608    fn every_sql_type_round_trips_unchanged() {
609        for v in sample_values() {
610            assert_eq!(round_trip(&v), v, "{} did not round-trip", v.type_name());
611        }
612    }
613
614    fn sample_values() -> Vec<PropertyValue> {
615        vec![
616            PropertyValue::Int32(-2_147_483_648),
617            PropertyValue::Int16(-32_768),
618            PropertyValue::Decimal(Decimal::parse("12345678901234567.89").unwrap()),
619            PropertyValue::Date(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
620            PropertyValue::Time(NaiveTime::from_hms_micro_opt(12, 30, 45, 123456).unwrap()),
621            PropertyValue::Timestamp(
622                DateTime::parse_from_rfc3339("2024-03-01T12:00:00+02:00").unwrap(),
623            ),
624            PropertyValue::Uuid(
625                uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
626            ),
627            PropertyValue::Bytes(vec![0, 1, 2, 253, 254, 255]),
628            PropertyValue::Array(vec![
629                PropertyValue::Text("a".into()),
630                PropertyValue::Long(1),
631                PropertyValue::Float(2.5),
632            ]),
633        ]
634    }
635
636    #[test]
637    fn money_survives_the_wire_to_the_digit() {
638        let exact = "12345678901234567.89";
639        let v = PropertyValue::Decimal(Decimal::parse(exact).unwrap());
640        let wire = serde_json::to_string(&v).unwrap();
641        assert_eq!(wire, r#"{"$ant":"decimal","v":"12345678901234567.89"}"#);
642        match round_trip(&v) {
643            PropertyValue::Decimal(d) => assert_eq!(d.to_string(), exact),
644            other => panic!("became {other:?}"),
645        }
646    }
647
648    #[test]
649    fn timestamp_keeps_its_offset_rather_than_normalizing_to_utc() {
650        let v = PropertyValue::Timestamp(
651            DateTime::parse_from_rfc3339("2024-03-01T12:00:00+02:00").unwrap(),
652        );
653        assert!(serde_json::to_string(&v).unwrap().contains("+02:00"));
654        match round_trip(&v) {
655            PropertyValue::Timestamp(t) => {
656                assert_eq!(t.to_rfc3339(), "2024-03-01T12:00:00+02:00");
657                assert_eq!(t.offset().local_minus_utc(), 7200);
658            }
659            other => panic!("became {other:?}"),
660        }
661    }
662
663    #[test]
664    fn a_document_containing_the_tag_key_is_still_a_document() {
665        // The envelope guard: three keys, so not an envelope.
666        let doc = serde_json::json!({"$ant": "decimal", "v": "1.0", "mine": true});
667        assert_eq!(
668            PropertyValue::from_json(doc.clone()),
669            PropertyValue::Json(doc)
670        );
671        // Two keys but an unknown tag.
672        let unknown = serde_json::json!({"$ant": "wat", "v": 1});
673        assert_eq!(
674            PropertyValue::from_json(unknown.clone()),
675            PropertyValue::Json(unknown)
676        );
677        // Two keys, known tag, unparseable payload -> preserved, not lost.
678        let bad = serde_json::json!({"$ant": "date", "v": "not-a-date"});
679        assert_eq!(
680            PropertyValue::from_json(bad.clone()),
681            PropertyValue::Json(bad)
682        );
683    }
684
685    #[test]
686    fn compat_json_flattens_to_the_pre_parity_shape() {
687        use serde_json::json;
688        let cases = [
689            (PropertyValue::Int32(7), json!(7)),
690            (PropertyValue::Int16(7), json!(7)),
691            (
692                PropertyValue::Decimal(Decimal::parse("12.34").unwrap()),
693                json!("12.34"),
694            ),
695            (
696                PropertyValue::Date(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
697                json!("2024-03-01"),
698            ),
699            (
700                PropertyValue::Uuid(
701                    uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
702                ),
703                json!("6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
704            ),
705            (PropertyValue::Bytes(vec![1, 2, 3]), json!("AQID")),
706        ];
707        for (v, want) in cases {
708            assert_eq!(v.to_compat_json(), want, "{}", v.type_name());
709            // And no envelope ever escapes onto the compat surface.
710            assert!(!v.to_compat_json().to_string().contains(TAG));
711        }
712        // Arrays flatten element-wise.
713        let arr = PropertyValue::Array(vec![PropertyValue::Int32(1), PropertyValue::Int32(2)]);
714        assert_eq!(arr.to_compat_json(), json!([1, 2]));
715    }
716
717    #[test]
718    fn numerics_compare_exactly_across_widths() {
719        let long = PropertyValue::Long(10);
720        let i32v = PropertyValue::Int32(10);
721        let i16v = PropertyValue::Int16(10);
722        let dec = PropertyValue::Decimal(Decimal::parse("10.00").unwrap());
723        for a in [&long, &i32v, &i16v, &dec] {
724            for b in [&long, &i32v, &i16v, &dec] {
725                assert_eq!(a.cmp_value(b), Ordering::Equal, "{a:?} vs {b:?}");
726            }
727        }
728        assert_eq!(
729            PropertyValue::Int32(9).cmp_value(&PropertyValue::Long(10)),
730            Ordering::Less
731        );
732        // Exactness past the f64 wall: these two differ only in the
733        // 19th digit, which f64 cannot see.
734        let a = PropertyValue::Decimal(Decimal::parse("100000000000000000.01").unwrap());
735        let b = PropertyValue::Decimal(Decimal::parse("100000000000000000.02").unwrap());
736        assert_eq!(a.cmp_value(&b), Ordering::Less);
737    }
738
739    #[test]
740    fn each_type_orders_within_itself() {
741        let d = |s: &str| PropertyValue::Date(NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap());
742        assert_eq!(d("2024-01-01").cmp_value(&d("2024-06-01")), Ordering::Less);
743
744        let t = |s: &str| PropertyValue::Time(parse_time(s).unwrap());
745        assert_eq!(t("01:00:00").cmp_value(&t("23:59:59")), Ordering::Less);
746
747        // Same instant, different offsets: equal, not ordered by text.
748        let ts = |s: &str| PropertyValue::Timestamp(DateTime::parse_from_rfc3339(s).unwrap());
749        assert_eq!(
750            ts("2024-03-01T12:00:00+02:00").cmp_value(&ts("2024-03-01T10:00:00Z")),
751            Ordering::Equal
752        );
753        assert_eq!(
754            ts("2024-03-01T12:00:00+02:00").cmp_value(&ts("2024-03-01T12:00:00Z")),
755            Ordering::Less
756        );
757
758        assert_eq!(
759            PropertyValue::Bytes(vec![1, 2]).cmp_value(&PropertyValue::Bytes(vec![1, 3])),
760            Ordering::Less
761        );
762        assert_eq!(
763            PropertyValue::Bool(false).cmp_value(&PropertyValue::Bool(true)),
764            Ordering::Less
765        );
766        assert_eq!(
767            PropertyValue::Text("a".into()).cmp_value(&PropertyValue::Text("b".into())),
768            Ordering::Less
769        );
770        // Arrays: element-wise, then length.
771        let arr =
772            |v: Vec<i64>| PropertyValue::Array(v.into_iter().map(PropertyValue::Long).collect());
773        assert_eq!(arr(vec![1, 2]).cmp_value(&arr(vec![1, 3])), Ordering::Less);
774        assert_eq!(arr(vec![1]).cmp_value(&arr(vec![1, 0])), Ordering::Less);
775    }
776
777    #[test]
778    fn every_variant_is_comparable_against_every_other() {
779        // A value that can be stored but not compared is a trap: it
780        // makes a filter silently match nothing. Total order, no panic,
781        // no "incomparable".
782        let all: Vec<PropertyValue> = std::iter::once(PropertyValue::Null)
783            .chain([
784                PropertyValue::Bool(true),
785                PropertyValue::Long(1),
786                PropertyValue::Float(1.0),
787                PropertyValue::Text("x".into()),
788                PropertyValue::Json(serde_json::json!({})),
789            ])
790            .chain(sample_values())
791            .collect();
792        for a in &all {
793            for b in &all {
794                let ab = a.cmp_value(b);
795                assert_eq!(ab.reverse(), b.cmp_value(a), "asymmetric: {a:?} vs {b:?}");
796            }
797            assert_eq!(a.cmp_value(a), Ordering::Equal);
798        }
799        // And sorting the whole mixed set terminates in a stable order.
800        let mut sorted = all.clone();
801        sorted.sort_by(|a, b| a.cmp_value(b));
802        assert_eq!(sorted.len(), all.len());
803    }
804
805    #[test]
806    fn eq_value_is_by_value_but_not_across_kinds() {
807        assert!(PropertyValue::Long(1).eq_value(&PropertyValue::Int32(1)));
808        assert!(PropertyValue::Decimal(Decimal::parse("1.0").unwrap())
809            .eq_value(&PropertyValue::Long(1)));
810        // Different kinds that merely share a rank neighbour must not
811        // collapse: "1" is not 1.
812        assert!(!PropertyValue::Text("1".into()).eq_value(&PropertyValue::Long(1)));
813    }
814
815    #[test]
816    fn a_query_literal_is_pulled_to_the_stored_type() {
817        // `WHERE due = "2024-03-01"` against a DATE column.
818        let stored = PropertyValue::Date(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap());
819        let lit = PropertyValue::Text("2024-03-01".into());
820        assert!(lit.coerce_like(&stored).eq_value(&stored));
821
822        // `WHERE amount > "10.50"` against a DECIMAL column — and the
823        // comparison must be exact, not lexicographic ("9" > "10").
824        let amount = PropertyValue::Decimal(Decimal::parse("10.50").unwrap());
825        let nine = PropertyValue::Text("9".into()).coerce_like(&amount);
826        assert_eq!(nine.cmp_value(&amount), Ordering::Less);
827
828        // A numeric literal against a decimal column.
829        assert!(PropertyValue::Long(10)
830            .coerce_like(&amount)
831            .cmp_value(&amount)
832            .is_lt());
833
834        // Timestamps compare as instants even when written differently.
835        let ts =
836            PropertyValue::Timestamp(DateTime::parse_from_rfc3339("2024-03-01T10:00:00Z").unwrap());
837        let other = PropertyValue::Text("2024-03-01T12:00:00+02:00".into()).coerce_like(&ts);
838        assert!(other.eq_value(&ts));
839
840        // A literal that is not that type is left alone rather than
841        // being made to match something it isn't.
842        let junk = PropertyValue::Text("not-a-date".into());
843        assert_eq!(junk.coerce_like(&stored), junk);
844        assert!(!junk.coerce_like(&stored).eq_value(&stored));
845    }
846
847    #[test]
848    fn bytes_survive_arbitrary_binary() {
849        let raw: Vec<u8> = (0u8..=255).collect();
850        let v = PropertyValue::Bytes(raw.clone());
851        match round_trip(&v) {
852            PropertyValue::Bytes(b) => assert_eq!(b, raw),
853            other => panic!("became {other:?}"),
854        }
855    }
856
857    #[test]
858    fn nested_arrays_keep_element_types() {
859        let v = PropertyValue::Array(vec![
860            PropertyValue::Array(vec![PropertyValue::Decimal(
861                Decimal::parse("0.10").unwrap(),
862            )]),
863            PropertyValue::Uuid(uuid::Uuid::nil()),
864        ]);
865        assert_eq!(round_trip(&v), v);
866    }
867}