diff --git a/CLAUDE.md b/CLAUDE.md index 0a61eb8..9574633 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,12 +245,17 @@ When LTJ cannot decompose a join (unions, repetitions, any-direction edges), the ### Null semantics -`Value::Null` is a first-class variant. Properties that are absent from a node/edge map are treated as null at query time, and explicit nulls round-trip through the on-disk format. +`Value::Null` is a first-class variant. For a **bound** graph variable, **missing property keys** are read as `Success(Value::Null)` in `AttrLookup`; **missing record keys** in `Expr::FieldAccess` behave the same, and **field access with a null base** (`null.city`) yields **`Success(Null)`** — FPPC-style `ok null` / ISO default-nullable missing-property behavior. Explicit nulls round-trip through the on-disk format. -- **3VL in `cmp_values`** (`runtime/mod.rs`): null on either side yields `false`, so a predicate involving null is dropped from the result. Used by both the LTJ filter loop (`NodeAttrCmp`) and the standard scan (`filter_node`/`filter_edge`). +- **Residual `WHERE` and general expressions** (`engine.rs` `run_expr`, `eval_binop`): SQL/GQL-style **three-valued logic** — e.g. null comparisons yield unknown (success value `Null`), `AND`/`OR`/`NOT` follow SQL truth tables, `WHERE` keeps a binding only when the condition is definite `Bool(true)`. `BinOp::As` passes `Null` through so casts do not turn missing reads back into `Failure`. +- **Pushed-down value predicates** (`cmp_values` in `runtime/mod.rs`, `check_value_preds` in `engine.rs`): null on either side yields **`false`** (not full 3VL). Used by LTJ `NodeAttrCmp` and standard `filter_node`/`filter_edge`; arbitrary residual `WHERE` uses the path above. - **Aggregate null elimination** (`engine.rs` `collect_aggregate_values`): both `ExprResult::Failure` and `Success(Value::Null)` are dropped before the reducer runs. Empty aggregates emit `Value::Null`. - **Wire format**: `PropValue::Null` carries tag byte 6 (no payload). Nested nulls inside lists / records survive the round-trip. Top-level nulls are encoded as key absence — the property is omitted from the on-disk record. -- **Surface syntax**: the lexer accepts `null` / `NULL`. The parser emits `Expr::Const(Value::Null)`. The typechecker maps the literal to `SimpleType::Star` so `WHERE x = null` does not collapse the surrounding type derivation. `IS NULL` and `IS NOT NULL` (parsed via `try_is_null` lookahead) produce an `Expr::IsNull { operand, negated }` that returns `Value::Bool` regardless of operand type; missing-attribute and unbound-variable failures are treated as null. +- **Surface syntax**: the lexer accepts `null` / `NULL`. The parser emits `Expr::Const(Value::Null)`. The typechecker maps the literal to `SimpleType::Star` so `WHERE x = null` does not collapse the surrounding type derivation. `IS NULL` / `IS NOT NULL` → `Expr::IsNull`; operand `Failure` is still treated as null for the test (unchecked queries). + +**Follow-up (separate workstream):** pushed-down predicates (`cmp_values`, node scans, LTJ `NodeAttrCmp`) treat null comparisons as **false**, while residual `WHERE` uses full **3VL**. That split can change observable row sets for the same logical filter depending on optimization. Next step is to unify semantics or document proveably ISO-safe shortcuts (cf. ISO/IEC 39075:2024 subclause 5.3.2.4 observable effect). + +**Not modeled yet:** `` as a value predicate (ISO 19.13 — distinct syntax / feature). Descriptor typing covers some “must have shape” cases at match time — see `docs/iso-gql-gaps.md`. ### CSV loader diff --git a/docs/iso-gql-gaps.md b/docs/iso-gql-gaps.md index a0abbc4..9196037 100644 --- a/docs/iso-gql-gaps.md +++ b/docs/iso-gql-gaps.md @@ -13,6 +13,7 @@ gqlite ya implementa: - Operadores: `+`, `-`, comparaciones, `=`, `!=`, `AND`, `OR`, `NOT` (sobre booleanos), `IS`, `AS`, `IN`. - Storage: formato `.gdb` de una sola página-file, embedded. - Optimizer: predicate pushdown, label index selection, Leapfrog Triejoin. +- `Value::Null`, lecturas de propiedad ausente como null en `AttrLookup`, y lógica trivalente en expresiones generales de `WHERE` (`run_expr` / `eval_binop`). Los predicados empujados al scan/LTJ siguen usando `cmp_values` (null → false), no la misma 3VL que el `WHERE` residual. ## Tier 1: expresividad que rompe cosas si falta @@ -70,15 +71,14 @@ Cambios necesarios: ### 1.4 NULL y lógica trivalente -El `Value` actual no tiene NULL. Atributos faltantes producen `ExprResult::Failure` y la fila se descarta. GQL y SQL usan lógica trivalente (`TRUE`/`FALSE`/`UNKNOWN`). +**Parcialmente cubierto:** hay `Value::Null`, propiedades ausentes en entidades ligadas leen como null (no `Failure`), y `AND`/`OR`/`NOT`/comparaciones aritméticas siguen 3VL tipo SQL en el evaluador de expresiones del `WHERE` residual. -Sin NULL, OPTIONAL MATCH y las comparaciones con datos faltantes quedan inconsistentes. +Siguen fuera (features / alineación ISO futura): -Cambios necesarios: +- Predicado `` (ISO 19.13): sintaxis y runtime propios; no es el mismo tubo que `x.p`. +- **Follow-up de corrección:** unificar o justificar pushdown (`cmp_values`) vs 3VL del `WHERE` residual cuando el efecto observable del filtro pueda diferir. -- `Value::Null`. -- Reescribir `eval_binop` para propagar NULL (cualquier op con NULL da NULL, excepto `IS NULL`/`IS NOT NULL`). -- `Value::Null` se trata como FALSE en contexto booleano (WHERE). +OPTIONAL MATCH sigue dependiendo de semántica de variables opcionales además del null en expresiones. ## Tier 2: útiles, hay workaround diff --git a/src/model/value.rs b/src/model/value.rs index 243af55..15f0f6a 100644 --- a/src/model/value.rs +++ b/src/model/value.rs @@ -2,9 +2,9 @@ use std::fmt; /// A property value. Scalars (Int/Float/Str/Bool/Null) plus the GQL /// constructed types List (ordered collection) and Record (named fields, can -/// nest). `Null` is the SQL-style absent value: comparisons with it produce -/// false (predicate-is-null → row drops), aggregators skip it, and it is -/// distinct from any string, including `"NULL"`. +/// nest). `Null` is the SQL-style absent value; in general `WHERE` expressions, +/// comparisons involving null follow SQL three-valued logic. Aggregators skip null, +/// and it is distinct from any string, including `"NULL"`. #[derive(Debug, Clone, PartialEq)] pub enum Value { Null, diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index e6c29a3..5fc3565 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -22,7 +22,6 @@ use super::ltj::triple_index::TripleIndex; use super::result::{ExprResult, IntermediateResult, QueryResult, ResultRow}; /// Apply value predicates pushed down by the optimizer to raw graph properties. -/// Missing key → predicate is null → reject. fn check_value_preds(preds: &[(String, BinOp, Value)], props: &Props) -> bool { preds .iter() @@ -128,8 +127,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { /// join with the accumulated binding table; each Optional is a left /// outer join — for every accumulated row, either emit all unifying /// extensions from the optional pattern, or emit the original row - /// padded with `PathValue::Nothing` for new variables (those then - /// project as `Value::Null` via the existing AttrLookup-failure path). + /// padded with `PathValue::Nothing` for new variables (they project as null). fn run_match_chain(&self, query: &Query, limit: usize) -> IntermediateResult { if !query.has_any_optional() { return self.run_path_pattern(&query.collapsed_pattern(), limit); @@ -294,8 +292,8 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } } - /// Evaluate inner expr per row, drop ISO nulls (Failure), optionally - /// dedup via HashSet when quantifier is DISTINCT. + /// Evaluate the aggregate argument per row; skip null and failed evaluations. + /// Optionally deduplicate when the quantifier is DISTINCT. fn collect_aggregate_values( &self, expr: &Expr, @@ -307,9 +305,9 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let mut seen: HashSet = HashSet::new(); for &idx in row_idxs { let v = match self.run_expr(&rows[idx].assignment, expr) { - ExprResult::Success(Value::Null) => continue, // null-eliminated + ExprResult::Success(Value::Null) => continue, ExprResult::Success(v) => v, - ExprResult::Failure(_) => continue, // null-eliminated + ExprResult::Failure(_) => continue, }; if matches!(quantifier, SetQuantifier::Distinct) { let key = GroupKey::from_values(vec![v.clone()]); @@ -1042,15 +1040,15 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { }; match props.get(attr) { Some(v) => ExprResult::Success(v.clone()), - None => ExprResult::Failure(format!("attribute '{attr}' not found")), + None => ExprResult::Success(Value::Null), } } Expr::FieldAccess { base, field } => match self.run_expr(mu, base) { - ExprResult::Success(Value::Record(m)) => match m.get(field) { - Some(v) => ExprResult::Success(v.clone()), - None => ExprResult::Failure(format!("field '{field}' not found")), - }, + ExprResult::Success(Value::Record(m)) => ExprResult::Success( + m.get(field).cloned().unwrap_or(Value::Null), + ), + ExprResult::Success(Value::Null) => ExprResult::Success(Value::Null), ExprResult::Success(other) => { ExprResult::Failure(format!("field access on non-record value: {other}")) } @@ -1072,7 +1070,9 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let l = self.run_expr(mu, left); match (&l, right.as_ref()) { (ExprResult::Success(val), Expr::Type(ty)) => { - if Expr::value_is_type(val, ty) { + if val.is_null() { + ExprResult::Success(Value::Null) + } else if Expr::value_is_type(val, ty) { ExprResult::Success(val.clone()) } else { ExprResult::Failure(format!("cannot cast {val} to {ty}")) @@ -1119,11 +1119,13 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { UnOp::Neg => match val { Value::Int(n) => ExprResult::Success(Value::Int(-n)), Value::Float(x) => ExprResult::Success(Value::Float(-x)), - _ => ExprResult::Failure("neg requires int or float".into()), + Value::Null => ExprResult::Success(Value::Null), + _ => ExprResult::Failure("invalid operand for unary -".into()), }, UnOp::Not => match val { Value::Bool(b) => ExprResult::Success(Value::Bool(!b)), - _ => ExprResult::Failure("not requires bool".into()), + Value::Null => ExprResult::Success(Value::Null), + _ => ExprResult::Failure("invalid operand for NOT".into()), }, }, ExprResult::Failure(_) => r, @@ -1134,9 +1136,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let is_null = match self.run_expr(mu, operand) { ExprResult::Success(Value::Null) => true, ExprResult::Success(_) => false, - // Failure (missing attribute, unbound variable) is - // treated as null — the same convention as the rest - // of the engine. + // Failure (e.g. unbound variable): treat as null for this test. ExprResult::Failure(_) => true, }; ExprResult::Success(Value::Bool(if *negated { !is_null } else { is_null })) @@ -1159,59 +1159,132 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } } match op { - BinOp::Add => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Int(a + b)), - Some((Value::Float(a), Value::Float(b))) => { - ExprResult::Success(Value::Float(a + b)) + BinOp::Add => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); } - _ => ExprResult::Failure("+ requires numeric operands".into()), - }, - BinOp::Sub => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Int(a - b)), - Some((Value::Float(a), Value::Float(b))) => { - ExprResult::Success(Value::Float(a - b)) + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Int(a + b)), + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Float(a + b)) + } + _ => ExprResult::Failure("invalid operands for +".into()), } - _ => ExprResult::Failure("- requires numeric operands".into()), - }, - BinOp::Gt => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a > b)), - Some((Value::Float(a), Value::Float(b))) => ExprResult::Success(Value::Bool(a > b)), - _ => ExprResult::Failure("> requires numeric operands".into()), - }, - BinOp::Lt => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a < b)), - Some((Value::Float(a), Value::Float(b))) => ExprResult::Success(Value::Bool(a < b)), - _ => ExprResult::Failure("< requires numeric operands".into()), - }, - BinOp::Ge => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a >= b)), - Some((Value::Float(a), Value::Float(b))) => { - ExprResult::Success(Value::Bool(a >= b)) + } + BinOp::Sub => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); } - _ => ExprResult::Failure(">= requires numeric operands".into()), - }, - BinOp::Le => match as_num_pair(lv, rv) { - Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a <= b)), - Some((Value::Float(a), Value::Float(b))) => { - ExprResult::Success(Value::Bool(a <= b)) + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Int(a - b)), + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Float(a - b)) + } + _ => ExprResult::Failure("invalid operands for -".into()), } - _ => ExprResult::Failure("<= requires numeric operands".into()), - }, - BinOp::Eq => ExprResult::Success(Value::Bool(lv == rv)), - BinOp::Ne => ExprResult::Success(Value::Bool(lv != rv)), + } + BinOp::Gt => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); + } + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a > b)), + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Bool(a > b)) + } + _ => ExprResult::Failure("invalid operands for >".into()), + } + } + BinOp::Lt => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); + } + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => ExprResult::Success(Value::Bool(a < b)), + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Bool(a < b)) + } + _ => ExprResult::Failure("invalid operands for <".into()), + } + } + BinOp::Ge => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); + } + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => { + ExprResult::Success(Value::Bool(a >= b)) + } + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Bool(a >= b)) + } + _ => ExprResult::Failure("invalid operands for >=".into()), + } + } + BinOp::Le => { + if lv.is_null() || rv.is_null() { + return ExprResult::Success(Value::Null); + } + match as_num_pair(lv, rv) { + Some((Value::Int(a), Value::Int(b))) => { + ExprResult::Success(Value::Bool(a <= b)) + } + Some((Value::Float(a), Value::Float(b))) => { + ExprResult::Success(Value::Bool(a <= b)) + } + _ => ExprResult::Failure("invalid operands for <=".into()), + } + } + BinOp::Eq => { + if lv.is_null() || rv.is_null() { + ExprResult::Success(Value::Null) + } else { + ExprResult::Success(Value::Bool(lv == rv)) + } + } + BinOp::Ne => { + if lv.is_null() || rv.is_null() { + ExprResult::Success(Value::Null) + } else { + ExprResult::Success(Value::Bool(lv != rv)) + } + } BinOp::In => match rv { Value::List(items) => { - ExprResult::Success(Value::Bool(items.iter().any(|x| x == lv))) + if lv.is_null() { + return ExprResult::Success(Value::Null); + } + let has_null = items.iter().any(Value::is_null); + let found = items.iter().any(|x| x == lv); + if found { + ExprResult::Success(Value::Bool(true)) + } else if has_null { + ExprResult::Success(Value::Null) + } else { + ExprResult::Success(Value::Bool(false)) + } } - _ => ExprResult::Failure("'in' requires a list on the right".into()), + _ => ExprResult::Failure("invalid right-hand side for IN (expected list)".into()), }, BinOp::And => match (lv, rv) { - (Value::Bool(a), Value::Bool(b)) => ExprResult::Success(Value::Bool(*a && *b)), - _ => ExprResult::Failure("and requires bools".into()), + (Value::Bool(false), _) | (_, Value::Bool(false)) => { + ExprResult::Success(Value::Bool(false)) + } + (Value::Bool(true), Value::Bool(true)) => ExprResult::Success(Value::Bool(true)), + (Value::Null, Value::Bool(true)) + | (Value::Bool(true), Value::Null) + | (Value::Null, Value::Null) => ExprResult::Success(Value::Null), + _ => ExprResult::Failure("invalid operands for AND".into()), }, BinOp::Or => match (lv, rv) { - (Value::Bool(a), Value::Bool(b)) => ExprResult::Success(Value::Bool(*a || *b)), - _ => ExprResult::Failure("or requires bools".into()), + (Value::Bool(true), _) | (_, Value::Bool(true)) => { + ExprResult::Success(Value::Bool(true)) + } + (Value::Bool(false), Value::Bool(false)) => ExprResult::Success(Value::Bool(false)), + (Value::Null, Value::Bool(false)) + | (Value::Bool(false), Value::Null) + | (Value::Null, Value::Null) => ExprResult::Success(Value::Null), + _ => ExprResult::Failure("invalid operands for OR".into()), }, _ => ExprResult::Failure(format!("unexpected op {op} in eval_binop")), } @@ -1288,9 +1361,7 @@ fn natural_join( /// natural join). If at least one matches, emit all unified rows (success /// branch). If none match, emit the left row alone with every variable in /// `new_vars \ bound_vars` set to `PathValue::Nothing` (unsuccess branch). -/// `Nothing` flows into projection as `Value::Null` because `pv.id()` -/// returns `None`, which `AttrLookup` turns into `Failure`, which -/// `eval_expr_item` maps to `Value::Null`. +/// `Nothing` pads variables from an unmatched optional arm; they project as null. fn left_outer_join( left: &IntermediateResult, right: &IntermediateResult, diff --git a/src/runtime/ltj/algorithm.rs b/src/runtime/ltj/algorithm.rs index bf39380..3e21102 100644 --- a/src/runtime/ltj/algorithm.rs +++ b/src/runtime/ltj/algorithm.rs @@ -341,7 +341,7 @@ impl<'a, G: GraphAccess> LtjRunner<'a, G> { return false; } } - // Missing property → predicate is null → reject + // Missing property None => return false, } } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index c4974ec..53d23af 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -7,13 +7,9 @@ pub mod result; use crate::model::value::Value; use crate::syntax::expr::BinOp; -/// Evaluate `lhs rhs` with GQL-leaning semantics for pushed value -/// predicates. Null on either side yields false (3VL: predicate is null → -/// row drops). Numeric comparison promotes Int→Float; comparison across -/// incompatible kinds yields false. Records and lists support structural -/// `Eq`/`Ne` only — ordering on composite values is not defined here and -/// returns false. Shared between the LTJ filter loop and the standard -/// node/edge scan. +/// Compare two values under `op` for pushed predicates (scans / LTJ filters). +/// Null on either side yields false. Numeric operands widen mixed int/float. +/// Records and lists: `Eq`/`Ne` only; ordering ops yield false. pub fn cmp_values(lhs: &Value, op: BinOp, rhs: &Value) -> bool { use std::cmp::Ordering; if lhs.is_null() || rhs.is_null() { diff --git a/src/syntax/expr.rs b/src/syntax/expr.rs index 00fd498..82fd7bc 100644 --- a/src/syntax/expr.rs +++ b/src/syntax/expr.rs @@ -144,9 +144,7 @@ pub enum Expr { op: UnOp, operand: Box, }, - /// SQL-style null test: `expr IS NULL` (negated=false) or - /// `expr IS NOT NULL` (negated=true). Returns Bool. Distinct from - /// `expr = null`, which would silently produce false under 3VL. + /// `IS NULL` / `IS NOT NULL`; always produces a boolean (contrast with `= null`). IsNull { operand: Box, negated: bool, diff --git a/src/typing/checker.rs b/src/typing/checker.rs index f790fd7..39af5a7 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -566,10 +566,7 @@ fn create_context(desc: &Option, t: VariableType) -> TypeEnvironment /// `docs/typechecker_migration.md` as a phase-1 punt. fn simple_type_of_value(v: &Value) -> SimpleType { match v { - // Null literal is the SQL untyped null: it inhabits every type for - // the purpose of static checks. Mapping to `Star` keeps comparisons - // like `x.attr = null` from collapsing the surrounding type - // derivation to bottom; the runtime still drops the row via 3VL. + // Untyped null literal: map to Star so it does not force bottom in static typing. Value::Null => SimpleType::Star, Value::Int(_) => SimpleType::Z, Value::Float(_) => SimpleType::F, diff --git a/tests/count_test.rs b/tests/count_test.rs index a97bed4..4d0ea83 100644 --- a/tests/count_test.rs +++ b/tests/count_test.rs @@ -90,8 +90,8 @@ fn test_count_star_with_alias() { // COUNT(expr) — ISO §20.9 , kind = Count // // Differs from COUNT(*): null inputs are eliminated before counting. -// In gqlite that means "Failure" results from run_expr (e.g. attribute -// not present on the node) drop out instead of counting as 1. +// In gqlite, `Success(Null)` (including missing properties) and `Failure` +// from `run_expr` are skipped — neither contributes to the count. // ======================================================================= #[test] @@ -105,7 +105,7 @@ fn test_count_expr_total() { } #[test] -fn test_count_expr_skips_failures() { +fn test_count_expr_skips_nulls() { // Two of the three users have a "nickname" property; the third // doesn't. ISO null-elimination drops that row from COUNT(x.nickname). let json = r#"{ @@ -146,7 +146,7 @@ fn test_count_distinct_with_alias() { } #[test] -fn test_count_distinct_skips_failures() { +fn test_count_distinct_skips_nulls() { // DISTINCT applies AFTER null-elimination, so missing nickname // doesn't count as "a distinct null". Two users have nicknames // ("Al" and "Bo"), both distinct → 2. diff --git a/tests/null_test.rs b/tests/null_test.rs index 1f8f35d..986b1ec 100644 --- a/tests/null_test.rs +++ b/tests/null_test.rs @@ -36,6 +36,18 @@ fn run(g: &Graph, q: &str) -> Vec> { } } +fn sorted_names(rows: Vec>) -> Vec { + let mut names: Vec = rows + .into_iter() + .map(|row| match &row[0] { + Value::Str(s) => s.clone(), + other => panic!("expected Str, got {other:?}"), + }) + .collect(); + names.sort(); + names +} + #[test] fn test_is_null_matches_missing_property() { let g = graph_with_optional_email(); @@ -62,13 +74,124 @@ fn test_is_not_null_matches_present_property() { #[test] fn test_eq_null_drops_all_rows_under_3vl() { - // Comparison against null literal yields false → predicate is null → - // no row passes. This is SQL behavior; users must use IS NULL. let g = graph_with_optional_email(); let rows = run(&g, "MATCH (x: User) WHERE x.email = null RETURN x.name"); assert!(rows.is_empty(), "expected no rows, got {rows:?}"); } +#[test] +fn test_missing_property_or_true_keeps_all_rows() { + let g = graph_with_optional_email(); + assert_eq!( + sorted_names(run( + &g, + "MATCH (x: User) WHERE (x.email = 'nobody@x.test') OR (1 = 1) RETURN x.name", + )), + vec!["Alice", "Bob", "Carol"] + ); +} + +#[test] +fn test_where_null_eq_null_no_rows() { + let g = graph_with_optional_email(); + let rows = run(&g, "MATCH (x: User) WHERE null = null RETURN x.name"); + assert!(rows.is_empty(), "expected no rows, got {rows:?}"); +} + +#[test] +fn test_return_null_eq_null_is_null() { + let g = graph_with_optional_email(); + let rows = run(&g, "MATCH (x: User) RETURN null = null"); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r == &vec![Value::Null])); +} + +#[test] +fn test_where_not_eliminates_unknown_from_missing_property() { + let g = graph_with_optional_email(); + assert_eq!( + sorted_names(run( + &g, + "MATCH (x: User) WHERE NOT (x.email = 'nobody@x.test') RETURN x.name", + )), + vec!["Alice", "Bob"] + ); +} + +#[test] +fn test_where_false_and_unknown_is_false() { + let g = graph_with_optional_email(); + let rows = run( + &g, + "MATCH (x: User) WHERE false AND (x.email = 'nobody@x.test') RETURN x.name", + ); + assert!(rows.is_empty()); +} + +#[test] +fn test_where_unknown_and_true_is_unknown() { + let g = graph_with_optional_email(); + let rows = run( + &g, + "MATCH (x: User) WHERE (x.email = 'nobody@x.test') AND true RETURN x.name", + ); + assert!(rows.is_empty()); +} + +#[test] +fn test_where_true_or_null_keeps_all_rows() { + let g = graph_with_optional_email(); + assert_eq!( + sorted_names(run(&g, "MATCH (x: User) WHERE true OR null RETURN x.name",)), + vec!["Alice", "Bob", "Carol"] + ); +} + +#[test] +fn test_where_unknown_or_false_is_unknown() { + let g = graph_with_optional_email(); + let rows = run( + &g, + "MATCH (x: User) WHERE (x.email = 'nobody@x.test') OR false RETURN x.name", + ); + assert!(rows.is_empty()); +} + +#[test] +fn test_return_null_plus_int_is_null() { + let g = graph_with_optional_email(); + let rows = run(&g, "MATCH (x: User) RETURN null + 1"); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r == &vec![Value::Null])); +} + +#[test] +fn test_return_not_null_is_null() { + let g = graph_with_optional_email(); + let rows = run(&g, "MATCH (x: User) RETURN NOT null"); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r == &vec![Value::Null])); +} + +#[test] +fn test_where_in_list_null_argument_unknown() { + let g = graph_with_optional_email(); + assert_eq!( + sorted_names(run( + &g, + "MATCH (x: User) WHERE x.email in ['alice@example.com', 'bob@example.com'] RETURN x.name", + )), + vec!["Alice", "Bob"] + ); +} + +#[test] +fn test_where_ne_null_all_unknown() { + let g = graph_with_optional_email(); + let rows = run(&g, "MATCH (x: User) WHERE x.email != null RETURN x.name"); + assert!(rows.is_empty()); +} + #[test] fn test_null_literal_in_return() { let g = graph_with_optional_email(); diff --git a/tests/record_test.rs b/tests/record_test.rs index cf98502..17660b2 100644 --- a/tests/record_test.rs +++ b/tests/record_test.rs @@ -23,6 +23,9 @@ fn graph_with_records() -> Graph { {"id": "u3", "labels": ["User"], "props": { "name": "Carol", "address": {"street": "Pine", "city": "Seattle", "zip": 98101} + }}, + {"id": "u4", "labels": ["User"], "props": { + "name": "Dave" }} ], "edges": [] @@ -64,6 +67,26 @@ fn test_nested_field_access() { assert_eq!(r, vec![vec![Value::Str("Carol".into())]]); } +#[test] +fn test_missing_record_field_reads_as_null_like_attr_lookup() { + let g = graph_with_records(); + // Covers missing nested key on a record and null base (no address / Dave): + // both must be Success(Null), not Failure, so OR (1=1) keeps the row. + let r = run( + &g, + "MATCH (x: User) WHERE (x.address.nonexistent = true) OR (1 = 1) RETURN x.name", + ); + let mut names: Vec<&str> = r + .iter() + .map(|row| match &row[0] { + Value::Str(s) => s.as_str(), + _ => panic!("expected string name"), + }) + .collect(); + names.sort(); + assert_eq!(names, vec!["Alice", "Bob", "Carol", "Dave"]); +} + #[test] fn test_nested_field_in_return() { let g = graph_with_records();