Skip to main content

graphforge_ir/
expr.rs

1//! IR-level expression types and the flat [`ExprArena`] that stores them.
2//!
3//! Expressions are represented as a flat vector of [`IrExpr`] nodes indexed by
4//! [`ExprId`].  This avoids deep recursive types and makes expression sharing,
5//! substitution, and serialisation straightforward.
6//!
7//! Unlike the AST expression types in `graphforge-ast`, these types:
8//! - carry **no source spans** (semantic-only)
9//! - use resolved IDs ([`VarId`], [`PropId`]) instead of raw name strings
10//! - collapse AST-level sugar (`IsNull`, `InList`, `StringOp`, `RegexMatch`)
11//!   into `UnaryOp` / `BinaryOp` variants handled uniformly by the executor
12
13use serde::de::{self, MapAccess, Visitor};
14use serde::ser::SerializeMap;
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16
17use crate::{ExprId, PropId, VarId};
18
19// ---------------------------------------------------------------------------
20// IrLiteral
21// ---------------------------------------------------------------------------
22
23/// A scalar constant value in the Graph IR.
24///
25/// Temporal values are stored as microseconds since the Unix epoch (UTC),
26/// consistent with the Arrow `Timestamp(Microsecond, "UTC")` convention used
27/// throughout the project.
28///
29/// ## Float serialisation
30///
31/// Finite `Float` values serialise as JSON numbers.  Non-finite IEEE-754 values
32/// (`NaN`, `+Infinity`, `-Infinity`) that `serde_json` cannot represent as JSON
33/// numbers are encoded as a tagged object `{"$float": "<tag>"}` where `<tag>` is
34/// `"NaN"`, `"+Infinity"`, or `"-Infinity"`.  This preserves full round-trip
35/// fidelity rather than silently collapsing to `null`.
36#[derive(Debug, Clone, PartialEq)]
37pub enum IrLiteral {
38    /// The Cypher `null` value.
39    Null,
40    /// A boolean constant.
41    Bool(bool),
42    /// A 64-bit integer constant.
43    Int(i64),
44    /// A 64-bit floating-point constant.
45    ///
46    /// Non-finite values (NaN, ±Infinity) are supported and round-trip through
47    /// JSON via a tagged encoding; see the enum-level docs.
48    Float(f64),
49    /// A UTF-8 string constant.
50    Str(String),
51    /// A typed UUID query parameter, stored as its canonical 16-byte identity.
52    /// This is not Cypher syntax and must not be inferred from strings.
53    Uuid([u8; 16]),
54    /// A Cypher duration as signed months/days/nanos (ADR 0009): months and days
55    /// kept distinct from sub-day time. Persisted as a `Struct{months,days,nanos}`
56    /// (Parquet cannot store Arrow `Interval`). (#920)
57    Duration {
58        /// Signed whole months.
59        months: i64,
60        /// Signed whole days.
61        days: i64,
62        /// Signed whole sub-day seconds (split from `nanos` so billion-year spans
63        /// fit `i64`). (#1011)
64        seconds: i64,
65        /// Signed nanoseconds-of-second, `(-1e9, 1e9)`, same sign as `seconds`.
66        nanos: i64,
67    },
68    /// A point-in-time expressed as microseconds since the Unix epoch (UTC).
69    DateTime(i64),
70    /// A calendar date as **i64 days** since the Unix epoch — the full openCypher
71    /// year range −999,999,999..+999,999,999. Persisted as a self-describing
72    /// `Struct{epoch_day: Int64}` (a bare Int64 is indistinguishable from an
73    /// integer property). (#920/#1011)
74    Date(i64),
75    /// A Cypher `localdatetime` (ADR 0009): a date (`days` since the Unix epoch)
76    /// plus a time-of-day (`nanos` since midnight), with no zone. Persisted as a
77    /// `Struct{date: Int64, time: Time64(ns)}`. (#920/#1011)
78    LocalDateTime {
79        /// Days since the Unix epoch (i64, full year range).
80        days: i64,
81        /// Nanoseconds since midnight (Arrow `Time64(ns)`).
82        nanos: i64,
83    },
84    /// A Cypher `localtime` (ADR 0009): a time-of-day in nanoseconds since
85    /// midnight, no zone. Persisted as a native Arrow `Time64(ns)` column. (#920)
86    Time(i64),
87    /// A Cypher `time` (ADR 0009): a time-of-day plus its UTC offset in seconds.
88    /// Persisted as a `Struct{time: Time64(ns), offset: Int32}`. (#920)
89    ZonedTime {
90        /// Nanoseconds since midnight (Arrow `Time64(ns)`).
91        nanos: i64,
92        /// UTC offset in seconds.
93        offset: i32,
94    },
95    /// A Cypher `datetime` (ADR 0009): a date+time, its UTC offset in seconds,
96    /// and an optional named IANA zone. Persisted as a
97    /// `Struct{date: Date32, time: Time64(ns), offset: Int32, zone: Utf8}`.
98    /// Distinct from [`IrLiteral::DateTime`] (a bare UTC micros instant). (#920/#1011)
99    ZonedDateTime {
100        /// Days since the Unix epoch (i64, full year range).
101        days: i64,
102        /// Nanoseconds since midnight (Arrow `Time64(ns)`).
103        nanos: i64,
104        /// UTC offset in seconds.
105        offset: i32,
106        /// Named IANA zone, or `None` for an offset-only datetime.
107        zone: Option<String>,
108    },
109    /// A homogeneous list of values, persisted as an Arrow `List<inner>` column
110    /// (the inner type is inferred from the elements). Stores e.g. a property
111    /// whose value is `[date(…), date(…)]`. Heterogeneous lists are out of scope
112    /// (#1005). (#1006)
113    List(Vec<IrLiteral>),
114    /// A query-parameter map value. Map literals in parsed Cypher still lower as
115    /// [`IrExpr::MapLiteral`]; this variant lets callers bind `$param` to a map
116    /// through `execute_with_params` and then use Cypher value access on it.
117    Map(Vec<(String, IrLiteral)>),
118}
119
120// ---------------------------------------------------------------------------
121// IrLiteral — custom Serialize
122// ---------------------------------------------------------------------------
123
124impl Serialize for IrLiteral {
125    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
126        // Delegate all variants except Float to the derived representation by
127        // using an internal helper that derives Serialize on a mirrored enum.
128        match self {
129            Self::Null => IrLiteralSer::Null.serialize(s),
130            Self::Bool(b) => IrLiteralSer::Bool(*b).serialize(s),
131            Self::Int(i) => IrLiteralSer::Int(*i).serialize(s),
132            Self::Str(v) => IrLiteralSer::Str(v).serialize(s),
133            Self::Uuid(v) => IrLiteralSer::Uuid(v).serialize(s),
134            Self::Duration {
135                months,
136                days,
137                seconds,
138                nanos,
139            } => IrLiteralSer::Duration(*months, *days, *seconds, *nanos).serialize(s),
140            Self::DateTime(dt) => IrLiteralSer::DateTime(*dt).serialize(s),
141            Self::Date(d) => IrLiteralSer::Date(*d).serialize(s),
142            Self::LocalDateTime { days, nanos } => {
143                IrLiteralSer::LocalDateTime(*days, *nanos).serialize(s)
144            }
145            Self::Time(n) => IrLiteralSer::Time(*n).serialize(s),
146            Self::ZonedTime { nanos, offset } => {
147                IrLiteralSer::ZonedTime(*nanos, *offset).serialize(s)
148            }
149            Self::ZonedDateTime {
150                days,
151                nanos,
152                offset,
153                zone,
154            } => IrLiteralSer::ZonedDateTime(*days, *nanos, *offset, zone.clone()).serialize(s),
155            // Each element serialises via `IrLiteral`'s own impl (so nested
156            // non-finite floats keep their tagged encoding).
157            Self::List(items) => IrLiteralSer::List(items).serialize(s),
158            Self::Map(entries) => IrLiteralSer::Map(entries).serialize(s),
159            Self::Float(f) => {
160                if f.is_finite() {
161                    IrLiteralSer::Float(*f).serialize(s)
162                } else {
163                    // Encode non-finite as {"$float": "<tag>"}
164                    let tag = if f.is_nan() {
165                        "NaN"
166                    } else if *f > 0.0 {
167                        "+Infinity"
168                    } else {
169                        "-Infinity"
170                    };
171                    let mut map = s.serialize_map(Some(1))?;
172                    map.serialize_entry("$float", tag)?;
173                    map.end()
174                }
175            }
176        }
177    }
178}
179
180/// Internal mirror enum used purely to drive derived `Serialize` for the
181/// finite/non-float variants of [`IrLiteral`].
182#[derive(Serialize)]
183#[serde(tag = "type", content = "value")]
184enum IrLiteralSer<'a> {
185    Null,
186    Bool(bool),
187    Int(i64),
188    Float(f64),
189    Str(&'a str),
190    Uuid(&'a [u8; 16]),
191    Duration(i64, i64, i64, i64),
192    DateTime(i64),
193    Date(i64),
194    LocalDateTime(i64, i64),
195    Time(i64),
196    ZonedTime(i64, i32),
197    ZonedDateTime(i64, i64, i32, Option<String>),
198    List(&'a [IrLiteral]),
199    Map(&'a [(String, IrLiteral)]),
200}
201
202// ---------------------------------------------------------------------------
203// IrLiteral — custom Deserialize
204// ---------------------------------------------------------------------------
205
206impl<'de> Deserialize<'de> for IrLiteral {
207    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
208        d.deserialize_any(IrLiteralVisitor)
209    }
210}
211
212struct IrLiteralVisitor;
213
214impl<'de> Visitor<'de> for IrLiteralVisitor {
215    type Value = IrLiteral;
216
217    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(
219            f,
220            "an IrLiteral (tagged object with \"type\"/\"value\" fields, \
221             or {{\"$float\": \"NaN\"/\"+Infinity\"/\"-Infinity\"}})"
222        )
223    }
224
225    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
226        // Two shapes are valid:
227        //   {"$float": "<tag>"}  — non-finite float
228        //   {"type": "<Variant>", "value": <data>}  — everything else
229
230        let first_key: String = map
231            .next_key()?
232            .ok_or_else(|| de::Error::custom("expected at least one map key"))?;
233
234        if first_key == "$float" {
235            let tag: String = map.next_value()?;
236            let f = match tag.as_str() {
237                "NaN" => f64::NAN,
238                "+Infinity" => f64::INFINITY,
239                "-Infinity" => f64::NEG_INFINITY,
240                other => {
241                    return Err(de::Error::unknown_variant(
242                        other,
243                        &["NaN", "+Infinity", "-Infinity"],
244                    ));
245                }
246            };
247            return Ok(IrLiteral::Float(f));
248        }
249
250        if first_key != "type" {
251            return Err(de::Error::unknown_field(&first_key, &["type", "$float"]));
252        }
253
254        let variant: String = map.next_value()?;
255        // Scan forward through remaining map entries for "value", ignoring
256        // any unknown fields along the way so field-order is irrelevant.
257        match variant.as_str() {
258            "Null" => Ok(IrLiteral::Null),
259            "Bool" => Ok(IrLiteral::Bool(read_value_field(&mut map)?)),
260            "Int" => Ok(IrLiteral::Int(read_value_field(&mut map)?)),
261            "Float" => Ok(IrLiteral::Float(read_value_field(&mut map)?)),
262            "Str" => Ok(IrLiteral::Str(read_value_field(&mut map)?)),
263            "Uuid" => Ok(IrLiteral::Uuid(read_value_field(&mut map)?)),
264            "Duration" => {
265                let (months, days, seconds, nanos) = read_value_field(&mut map)?;
266                Ok(IrLiteral::Duration {
267                    months,
268                    days,
269                    seconds,
270                    nanos,
271                })
272            }
273            "DateTime" => Ok(IrLiteral::DateTime(read_value_field(&mut map)?)),
274            "Date" => Ok(IrLiteral::Date(read_value_field(&mut map)?)),
275            "LocalDateTime" => {
276                let (days, nanos) = read_value_field(&mut map)?;
277                Ok(IrLiteral::LocalDateTime { days, nanos })
278            }
279            "Time" => Ok(IrLiteral::Time(read_value_field(&mut map)?)),
280            "ZonedTime" => {
281                let (nanos, offset) = read_value_field(&mut map)?;
282                Ok(IrLiteral::ZonedTime { nanos, offset })
283            }
284            "ZonedDateTime" => {
285                let (days, nanos, offset, zone) = read_value_field(&mut map)?;
286                Ok(IrLiteral::ZonedDateTime {
287                    days,
288                    nanos,
289                    offset,
290                    zone,
291                })
292            }
293            "List" => Ok(IrLiteral::List(read_value_field(&mut map)?)),
294            "Map" => Ok(IrLiteral::Map(read_value_field(&mut map)?)),
295            other => Err(de::Error::unknown_variant(
296                other,
297                &[
298                    "Null",
299                    "Bool",
300                    "Int",
301                    "Float",
302                    "Str",
303                    "Uuid",
304                    "Duration",
305                    "DateTime",
306                    "Date",
307                    "LocalDateTime",
308                    "Time",
309                    "ZonedTime",
310                    "ZonedDateTime",
311                    "List",
312                    "Map",
313                ],
314            )),
315        }
316    }
317}
318
319/// Scan `map` for a key named `"value"`, skipping any unrecognised keys, and
320/// deserialise its value as `T`.  Returns `de::Error::missing_field("value")`
321/// if the key is not found.
322fn read_value_field<'de, T, A>(map: &mut A) -> Result<T, A::Error>
323where
324    T: Deserialize<'de>,
325    A: MapAccess<'de>,
326{
327    while let Some(key) = map.next_key::<String>()? {
328        if key == "value" {
329            return map.next_value();
330        }
331        let _: de::IgnoredAny = map.next_value()?;
332    }
333    Err(de::Error::missing_field("value"))
334}
335
336// ---------------------------------------------------------------------------
337// BinaryOpKind
338// ---------------------------------------------------------------------------
339
340/// The operator in a [`IrExpr::BinaryOp`] expression.
341///
342/// String and collection predicates that appear as separate `Expr` variants in
343/// the AST are normalised to binary ops here so the executor handles them
344/// uniformly.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
346pub enum BinaryOpKind {
347    // ---- comparison ----
348    /// `=`
349    Eq,
350    /// `<>`
351    Neq,
352    /// `<`
353    Lt,
354    /// `<=`
355    Lte,
356    /// `>`
357    Gt,
358    /// `>=`
359    Gte,
360
361    // ---- logical ----
362    /// `AND`
363    And,
364    /// `OR`
365    Or,
366    /// `XOR`
367    Xor,
368
369    // ---- arithmetic ----
370    /// `+`
371    Add,
372    /// `-`
373    Sub,
374    /// `*`
375    Mul,
376    /// `/`
377    Div,
378    /// `%`
379    Mod,
380    /// `^`
381    Pow,
382
383    // ---- collection / string predicates ----
384    /// `expr IN list`
385    In,
386    /// `STARTS WITH`
387    StartsWith,
388    /// `ENDS WITH`
389    EndsWith,
390    /// `CONTAINS`
391    Contains,
392    /// `=~` (regular expression match)
393    RegexMatch,
394}
395
396// ---------------------------------------------------------------------------
397// UnaryOpKind
398// ---------------------------------------------------------------------------
399
400/// The operator in a [`IrExpr::UnaryOp`] expression.
401///
402/// `IsNull` and `IsNotNull` live here only — there are no top-level
403/// `IrExpr::IsNull` / `IrExpr::IsNotNull` variants.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
405pub enum UnaryOpKind {
406    /// `NOT expr`
407    Not,
408    /// Arithmetic negation: `-expr`
409    Neg,
410    /// `expr IS NULL`
411    IsNull,
412    /// `expr IS NOT NULL`
413    IsNotNull,
414}
415
416// ---------------------------------------------------------------------------
417// CaseArm
418// ---------------------------------------------------------------------------
419
420/// A single WHEN/THEN arm in a [`IrExpr::Case`] expression.
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422pub struct CaseArm {
423    /// The WHEN condition (or comparison value in a simple CASE).
424    pub when: ExprId,
425    /// The THEN result expression.
426    pub then: ExprId,
427}
428
429// ---------------------------------------------------------------------------
430// IrExpr
431// ---------------------------------------------------------------------------
432
433/// A single node in the [`ExprArena`].
434///
435/// Every `ExprId` child is an index into the same arena; the arena owns all
436/// nodes and is the authoritative source of truth for expression structure.
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438pub enum IrExpr {
439    /// A scalar constant.
440    Literal(IrLiteral),
441    /// A reference to a pattern variable (e.g. `n` in `MATCH (n:Person)`).
442    VarRef(VarId),
443    /// A property read: `base.prop`.
444    PropertyAccess {
445        /// The expression whose property is read.
446        base: ExprId,
447        /// The property type ID (resolved by the binder).
448        prop: PropId,
449    },
450    /// A binary infix expression.
451    BinaryOp {
452        /// The operator.
453        op: BinaryOpKind,
454        /// Left-hand operand.
455        left: ExprId,
456        /// Right-hand operand.
457        right: ExprId,
458    },
459    /// A unary prefix expression.
460    UnaryOp {
461        /// The operator.
462        op: UnaryOpKind,
463        /// The operand.
464        expr: ExprId,
465    },
466    /// A built-in or user-defined function call.
467    FunctionCall {
468        /// Fully-qualified function name (e.g. `"toUpper"`, `"apoc.text.join"`).
469        name: String,
470        /// Argument expressions.
471        args: Vec<ExprId>,
472    },
473    /// A named query parameter (e.g. `$name`).
474    Parameter(String),
475    /// A CASE expression.
476    Case {
477        /// Simple-CASE operand; `None` for searched CASE.
478        operand: Option<ExprId>,
479        /// The WHEN/THEN arms.
480        arms: Vec<CaseArm>,
481        /// The ELSE expression, if present.
482        else_expr: Option<ExprId>,
483    },
484    /// A list literal: `[e0, e1, …]`.
485    ListLiteral(Vec<ExprId>),
486    /// A map literal: `{k0: e0, k1: e1, …}`.
487    ///
488    /// Keys are plain strings; values are `ExprId` references.
489    MapLiteral(Vec<(String, ExprId)>),
490    /// A quantifier predicate `all/any/none/single(loop_var IN list WHERE pred)`.
491    /// `loop_var` is bound (only) within `predicate`. (#955)
492    Quantifier {
493        /// Which quantifier (all/any/none/single).
494        kind: graphforge_ast::QuantifierKind,
495        /// The loop variable bound per element while evaluating `predicate`.
496        loop_var: VarId,
497        /// The list expression iterated over.
498        list: ExprId,
499        /// The per-element predicate.
500        predicate: ExprId,
501    },
502    /// A list comprehension `[loop_var IN list WHERE filter | projection]`.
503    /// `loop_var` is bound (only) within `filter` and `projection`. Either of
504    /// `filter`/`projection` may be absent (a bare `[x IN list]` is the list
505    /// itself; `[x IN list WHERE p]` filters; `[x IN list | e]` maps). (#955)
506    ListComprehension {
507        /// The loop variable bound per element while evaluating the clauses.
508        loop_var: VarId,
509        /// The list expression iterated over.
510        list: ExprId,
511        /// Optional per-element filter; only elements where it is true are kept.
512        filter: Option<ExprId>,
513        /// Optional per-element projection; absent ⇒ the element itself.
514        projection: Option<ExprId>,
515    },
516}
517
518// ---------------------------------------------------------------------------
519// ExprArena
520// ---------------------------------------------------------------------------
521
522/// Flat, index-addressed storage for all [`IrExpr`] nodes in a query plan.
523///
524/// Nodes are appended with [`push`](ExprArena::push), which returns a stable
525/// [`ExprId`] index.  Because the arena is a plain `Vec`, serialisation
526/// produces a JSON array where each element's position is its `ExprId`.
527///
528/// # Example
529///
530/// ```
531/// use graphforge_ir::{ExprArena, IrExpr, IrLiteral, VarId, PropId};
532/// use graphforge_ir::expr::BinaryOpKind;
533///
534/// let mut arena = ExprArena::new();
535///
536/// // Build: name = "Alice"
537/// let var = arena.push(IrExpr::VarRef(VarId(0)));
538/// let prop = arena.push(IrExpr::PropertyAccess { base: var, prop: PropId(1) });
539/// let lit = arena.push(IrExpr::Literal(IrLiteral::Str("Alice".into())));
540/// let eq  = arena.push(IrExpr::BinaryOp { op: BinaryOpKind::Eq, left: prop, right: lit });
541///
542/// assert_eq!(arena.get(eq), &IrExpr::BinaryOp { op: BinaryOpKind::Eq, left: prop, right: lit });
543/// ```
544#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
545pub struct ExprArena {
546    nodes: Vec<IrExpr>,
547}
548
549impl ExprArena {
550    /// Creates an empty arena.
551    #[must_use]
552    pub fn new() -> Self {
553        Self::default()
554    }
555
556    /// Appends `expr` to the arena and returns its stable [`ExprId`].
557    pub fn push(&mut self, expr: IrExpr) -> ExprId {
558        let id =
559            ExprId(u32::try_from(self.nodes.len()).expect("ExprArena exceeded u32::MAX capacity"));
560        self.nodes.push(expr);
561        id
562    }
563
564    /// Returns a reference to the expression at `id`.
565    ///
566    /// # Panics
567    ///
568    /// Panics if `id` is out of bounds (i.e. was not returned by this arena's
569    /// [`push`](Self::push)).
570    #[must_use]
571    pub fn get(&self, id: ExprId) -> &IrExpr {
572        &self.nodes[id.0 as usize]
573    }
574
575    /// Replace named parameter nodes with supplied literal values.
576    pub fn substitute_parameters(&mut self, params: &std::collections::HashMap<String, IrLiteral>) {
577        for node in &mut self.nodes {
578            if let IrExpr::Parameter(name) = node
579                && let Some(value) = params.get(name)
580            {
581                *node = IrExpr::Literal(value.clone());
582            }
583        }
584    }
585
586    /// Returns the number of expressions in the arena.
587    #[must_use]
588    pub fn len(&self) -> usize {
589        self.nodes.len()
590    }
591
592    /// Returns `true` if the arena contains no expressions.
593    #[must_use]
594    pub fn is_empty(&self) -> bool {
595        self.nodes.is_empty()
596    }
597}
598
599// ---------------------------------------------------------------------------
600// Tests
601// ---------------------------------------------------------------------------
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::{PropId, VarId};
607
608    /// Build `a.name = "Alice" AND a.age > 30` and verify round-trip.
609    #[test]
610    fn non_trivial_expression_tree_roundtrip() {
611        let mut arena = ExprArena::new();
612
613        // a (VarId 0)
614        let a = arena.push(IrExpr::VarRef(VarId(0)));
615
616        // a.name (PropId 1)
617        let a_name = arena.push(IrExpr::PropertyAccess {
618            base: a,
619            prop: PropId(1),
620        });
621
622        // "Alice"
623        let alice = arena.push(IrExpr::Literal(IrLiteral::Str("Alice".into())));
624
625        // a.name = "Alice"
626        let eq = arena.push(IrExpr::BinaryOp {
627            op: BinaryOpKind::Eq,
628            left: a_name,
629            right: alice,
630        });
631
632        // a.age (PropId 2)
633        let a_age = arena.push(IrExpr::PropertyAccess {
634            base: a,
635            prop: PropId(2),
636        });
637
638        // 30
639        let thirty = arena.push(IrExpr::Literal(IrLiteral::Int(30)));
640
641        // a.age > 30
642        let gt = arena.push(IrExpr::BinaryOp {
643            op: BinaryOpKind::Gt,
644            left: a_age,
645            right: thirty,
646        });
647
648        // (a.name = "Alice") AND (a.age > 30)
649        let and = arena.push(IrExpr::BinaryOp {
650            op: BinaryOpKind::And,
651            left: eq,
652            right: gt,
653        });
654
655        // Serde round-trip
656        let json = serde_json::to_string(&arena).unwrap();
657        let restored: ExprArena = serde_json::from_str(&json).unwrap();
658
659        assert_eq!(arena, restored);
660
661        // Verify the root node is correct after restore
662        assert_eq!(
663            restored.get(and),
664            &IrExpr::BinaryOp {
665                op: BinaryOpKind::And,
666                left: eq,
667                right: gt,
668            }
669        );
670    }
671
672    #[test]
673    fn push_returns_sequential_ids() {
674        let mut arena = ExprArena::new();
675        let id0 = arena.push(IrExpr::Literal(IrLiteral::Null));
676        let id1 = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
677        let id2 = arena.push(IrExpr::Literal(IrLiteral::Int(42)));
678        assert_eq!(id0, ExprId(0));
679        assert_eq!(id1, ExprId(1));
680        assert_eq!(id2, ExprId(2));
681    }
682
683    #[test]
684    fn get_retrieves_correct_expression() {
685        let mut arena = ExprArena::new();
686        let id = arena.push(IrExpr::Literal(IrLiteral::Str("hello".into())));
687        assert_eq!(
688            arena.get(id),
689            &IrExpr::Literal(IrLiteral::Str("hello".into()))
690        );
691    }
692
693    #[test]
694    fn empty_arena_len_and_is_empty() {
695        let arena = ExprArena::new();
696        assert_eq!(arena.len(), 0);
697        assert!(arena.is_empty());
698    }
699
700    #[test]
701    fn arena_all_ir_expr_variants() {
702        let mut arena = ExprArena::new();
703
704        let lit_null = arena.push(IrExpr::Literal(IrLiteral::Null));
705        let lit_bool = arena.push(IrExpr::Literal(IrLiteral::Bool(false)));
706        let lit_int = arena.push(IrExpr::Literal(IrLiteral::Int(-1)));
707        let lit_float = arena.push(IrExpr::Literal(IrLiteral::Float(2.71)));
708        let lit_str = arena.push(IrExpr::Literal(IrLiteral::Str("x".into())));
709        let lit_dur = arena.push(IrExpr::Literal(IrLiteral::Duration {
710            months: 0,
711            days: 0,
712            seconds: 0,
713            nanos: 1_000_000,
714        }));
715        let lit_dt = arena.push(IrExpr::Literal(IrLiteral::DateTime(0)));
716
717        let var = arena.push(IrExpr::VarRef(VarId(7)));
718
719        let prop = arena.push(IrExpr::PropertyAccess {
720            base: var,
721            prop: PropId(3),
722        });
723
724        let binop = arena.push(IrExpr::BinaryOp {
725            op: BinaryOpKind::Add,
726            left: lit_int,
727            right: lit_float,
728        });
729
730        let unop = arena.push(IrExpr::UnaryOp {
731            op: UnaryOpKind::IsNull,
732            expr: prop,
733        });
734
735        let call = arena.push(IrExpr::FunctionCall {
736            name: "toUpper".into(),
737            args: vec![lit_str],
738        });
739
740        let param = arena.push(IrExpr::Parameter("name".into()));
741
742        let arm = CaseArm {
743            when: lit_bool,
744            then: lit_int,
745        };
746        let case = arena.push(IrExpr::Case {
747            operand: Some(var),
748            arms: vec![arm],
749            else_expr: Some(lit_null),
750        });
751
752        let list = arena.push(IrExpr::ListLiteral(vec![lit_int, lit_float]));
753
754        let map = arena.push(IrExpr::MapLiteral(vec![("key".into(), lit_str)]));
755
756        // Serde round-trip over the full arena
757        let json = serde_json::to_string(&arena).unwrap();
758        let restored: ExprArena = serde_json::from_str(&json).unwrap();
759        assert_eq!(arena, restored);
760
761        // Spot-check a few nodes
762        assert_eq!(restored.get(binop), arena.get(binop));
763        assert_eq!(restored.get(unop), arena.get(unop));
764        assert_eq!(restored.get(call), arena.get(call));
765        assert_eq!(restored.get(case), arena.get(case));
766        assert_eq!(restored.get(list), arena.get(list));
767        assert_eq!(restored.get(map), arena.get(map));
768        assert_eq!(restored.get(param), arena.get(param));
769        assert_eq!(restored.get(lit_dur), arena.get(lit_dur));
770        assert_eq!(restored.get(lit_dt), arena.get(lit_dt));
771    }
772
773    #[test]
774    fn case_arm_roundtrip() {
775        let arm = CaseArm {
776            when: ExprId(0),
777            then: ExprId(1),
778        };
779        let json = serde_json::to_string(&arm).unwrap();
780        let back: CaseArm = serde_json::from_str(&json).unwrap();
781        assert_eq!(arm, back);
782    }
783
784    #[test]
785    fn unary_is_null_not_top_level_variant() {
786        // IS NULL must be expressed as UnaryOp { op: IsNull, ... }
787        // (the issue explicitly forbids a top-level IrExpr::IsNull variant)
788        let mut arena = ExprArena::new();
789        let var = arena.push(IrExpr::VarRef(VarId(0)));
790        let is_null = arena.push(IrExpr::UnaryOp {
791            op: UnaryOpKind::IsNull,
792            expr: var,
793        });
794        assert!(matches!(
795            arena.get(is_null),
796            IrExpr::UnaryOp {
797                op: UnaryOpKind::IsNull,
798                ..
799            }
800        ));
801    }
802
803    #[test]
804    fn binary_op_kind_serde_roundtrip() {
805        for op in [
806            BinaryOpKind::Eq,
807            BinaryOpKind::Neq,
808            BinaryOpKind::Lt,
809            BinaryOpKind::Lte,
810            BinaryOpKind::Gt,
811            BinaryOpKind::Gte,
812            BinaryOpKind::And,
813            BinaryOpKind::Or,
814            BinaryOpKind::Xor,
815            BinaryOpKind::Add,
816            BinaryOpKind::Sub,
817            BinaryOpKind::Mul,
818            BinaryOpKind::Div,
819            BinaryOpKind::Mod,
820            BinaryOpKind::Pow,
821            BinaryOpKind::In,
822            BinaryOpKind::StartsWith,
823            BinaryOpKind::EndsWith,
824            BinaryOpKind::Contains,
825            BinaryOpKind::RegexMatch,
826        ] {
827            let json = serde_json::to_string(&op).unwrap();
828            let back: BinaryOpKind = serde_json::from_str(&json).unwrap();
829            assert_eq!(op, back);
830        }
831    }
832
833    #[test]
834    fn unary_op_kind_serde_roundtrip() {
835        for op in [
836            UnaryOpKind::Not,
837            UnaryOpKind::Neg,
838            UnaryOpKind::IsNull,
839            UnaryOpKind::IsNotNull,
840        ] {
841            let json = serde_json::to_string(&op).unwrap();
842            let back: UnaryOpKind = serde_json::from_str(&json).unwrap();
843            assert_eq!(op, back);
844        }
845    }
846
847    #[test]
848    fn float_finite_roundtrip() {
849        let lit = IrLiteral::Float(2.71_f64);
850        let json = serde_json::to_string(&lit).unwrap();
851        let back: IrLiteral = serde_json::from_str(&json).unwrap();
852        assert_eq!(lit, back);
853    }
854
855    #[test]
856    fn uuid_literal_serde_roundtrip() {
857        let lit = IrLiteral::Uuid([0x5a; 16]);
858        let json = serde_json::to_string(&lit).unwrap();
859        assert!(json.contains("Uuid"));
860        assert_eq!(serde_json::from_str::<IrLiteral>(&json).unwrap(), lit);
861    }
862
863    #[test]
864    fn localdatetime_literal_serde_roundtrip() {
865        // #920: the new temporal-storage variant round-trips through serde.
866        let lit = IrLiteral::LocalDateTime {
867            days: 5_393,
868            nanos: 45_074_645_876_123,
869        };
870        let json = serde_json::to_string(&lit).unwrap();
871        assert!(json.contains("LocalDateTime"), "tagged variant: {json}");
872        let back: IrLiteral = serde_json::from_str(&json).unwrap();
873        assert_eq!(lit, back);
874    }
875
876    #[test]
877    fn temporal_storage_variants_serde_roundtrip() {
878        // #920: time / time-with-zone / datetime (with and without a named zone)
879        // all round-trip through serde.
880        for lit in [
881            IrLiteral::Time(45_074_645_876_123),
882            IrLiteral::ZonedTime {
883                nanos: 45_074_645_876_123,
884                offset: 3_600,
885            },
886            IrLiteral::ZonedDateTime {
887                days: 5_393,
888                nanos: 45_074_645_876_123,
889                offset: 3_600,
890                zone: Some("Europe/Stockholm".to_owned()),
891            },
892            IrLiteral::ZonedDateTime {
893                days: 5_393,
894                nanos: 0,
895                offset: -3_600,
896                zone: None,
897            },
898        ] {
899            let json = serde_json::to_string(&lit).unwrap();
900            let back: IrLiteral = serde_json::from_str(&json).unwrap();
901            assert_eq!(lit, back, "round-trip {json}");
902        }
903    }
904
905    #[test]
906    fn float_nan_roundtrip() {
907        let lit = IrLiteral::Float(f64::NAN);
908        let json = serde_json::to_string(&lit).unwrap();
909        assert!(json.contains("NaN"), "NaN should be tagged: {json}");
910        let back: IrLiteral = serde_json::from_str(&json).unwrap();
911        assert!(
912            matches!(back, IrLiteral::Float(f) if f.is_nan()),
913            "should deserialise back to NaN"
914        );
915    }
916
917    #[test]
918    fn float_positive_infinity_roundtrip() {
919        let lit = IrLiteral::Float(f64::INFINITY);
920        let json = serde_json::to_string(&lit).unwrap();
921        assert!(json.contains("+Infinity"), "should be tagged: {json}");
922        let back: IrLiteral = serde_json::from_str(&json).unwrap();
923        assert_eq!(back, IrLiteral::Float(f64::INFINITY));
924    }
925
926    #[test]
927    fn float_negative_infinity_roundtrip() {
928        let lit = IrLiteral::Float(f64::NEG_INFINITY);
929        let json = serde_json::to_string(&lit).unwrap();
930        assert!(json.contains("-Infinity"), "should be tagged: {json}");
931        let back: IrLiteral = serde_json::from_str(&json).unwrap();
932        assert_eq!(back, IrLiteral::Float(f64::NEG_INFINITY));
933    }
934
935    #[test]
936    fn ir_literal_deser_extra_field_before_value() {
937        // The deserialiser must locate "value" regardless of field order.
938        let json = r#"{"type":"Bool","extra":99,"value":true}"#;
939        let lit: IrLiteral = serde_json::from_str(json).unwrap();
940        assert_eq!(lit, IrLiteral::Bool(true));
941    }
942
943    #[test]
944    fn ir_literal_deser_value_before_type_not_supported() {
945        // Our serializer always emits {"type":...,"value":...} so the normal
946        // round-trip is always in-order.  This test documents current behaviour:
947        // a map where "value" precedes "type" will fail (unknown field "value").
948        let json = r#"{"value":42,"type":"Int"}"#;
949        let result: Result<IrLiteral, _> = serde_json::from_str(json);
950        // Either succeeds or errors — we just ensure no panic.
951        let _ = result;
952    }
953}