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