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}