From 2cb2796e38de12187a3e2d3d8b9b0b2fd57f93b1 Mon Sep 17 00:00:00 2001 From: Jean Paul Duchens Pacheco Date: Sun, 5 Jul 2026 03:24:52 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(temporal):=20ISO=20=C2=A720.27=20DATE?= =?UTF-8?q?=20and=20LOCAL=5FDATETIME=20as=20query-time=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP of the ISO temporal subsystem, staged like Path values were: - Value::Date (days since epoch, proleptic Gregorian) and Value::LocalDatetime (millis, no timezone), with dependency-free civil<->days conversion, strict ISO-8601 parsing (leap years validated) and round-trippable formatting. - §20.27 constructors as soft keywords (call form only): DATE()/ LOCAL_DATETIME() = the CURRENT_* equivalents per SR 1-3, one-arg forms take a or a field record. Malformed input degrades to Null, the CAST failure-as-null convention. - Typechecker: SimpleType::Date / LocalDatetime are base types, orderable per §22.14 without GA04; provably wrong constructor arguments are hard errors. - Runtime: total order within each type (WHERE / ORDER BY / MIN/MAX), equality + hashing for GROUP BY and DISTINCT, cross-type pairs never compare, and §20.26 MVP arithmetic (LOCAL_DATETIME ± integer millis, the form the DURATION({...}) desugaring produces). - The generic Binop dispatch now routes temporal operand pairs past the numeric-cast coercion (which silently nulled them) and inlines the delta cast checks without re-evaluating operands. - Out of scope, documented: ZONED_* (timezones), typed durations, and temporal property storage (value_to_prop rejects them explicitly, same treatment as Path). Python/Node/WASM bindings return ISO-8601 strings; REPL and dumps print constructor syntax. 13 integration tests in temporal_test.rs. Co-Authored-By: Claude Fable 5 --- node/src/lib.rs | 4 + python/src/lib.rs | 2 + src/bin/frogql.rs | 10 ++ src/bin/ldbc_bench.rs | 2 + src/model/value.rs | 144 ++++++++++++++++++++++++++ src/parser/grammar.rs | 25 +++++ src/runtime/engine.rs | 211 ++++++++++++++++++++++++++++++++++---- src/runtime/mod.rs | 28 +++++ src/store/dump.rs | 12 +++ src/store/io.rs | 9 ++ src/typing/checker.rs | 31 +++++- src/typing/format.rs | 2 + src/typing/inference.rs | 4 + src/typing/simple_type.rs | 10 ++ src/typing/validate.rs | 4 + tests/temporal_test.rs | 175 +++++++++++++++++++++++++++++++ wasm/src/lib.rs | 2 + 17 files changed, 655 insertions(+), 20 deletions(-) create mode 100644 tests/temporal_test.rs diff --git a/node/src/lib.rs b/node/src/lib.rs index 4dc83f7..820ff15 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -671,6 +671,10 @@ fn value_to_json(store: &LazyGraphStore, v: &Value) -> JsonValue { Value::Float(x) => json!(*x), Value::Str(s) => JsonValue::String(s.clone()), Value::Bool(b) => JsonValue::Bool(*b), + Value::Date(d) => JsonValue::String(frogql_core::model::value::format_date(*d)), + Value::LocalDatetime(ms) => { + JsonValue::String(frogql_core::model::value::format_datetime(*ms)) + } Value::List(items) => { JsonValue::Array(items.iter().map(|v| value_to_json(store, v)).collect()) } diff --git a/python/src/lib.rs b/python/src/lib.rs index e16aef7..c3f7c50 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -595,6 +595,8 @@ fn value_to_py<'py>(py: Python<'py>, store: &LazyGraphStore, v: &Value) -> PyRes Value::Float(x) => x.into_py(py), Value::Str(s) => s.into_py(py), Value::Bool(b) => b.into_py(py), + Value::Date(d) => frogql_core::model::value::format_date(*d).into_py(py), + Value::LocalDatetime(ms) => frogql_core::model::value::format_datetime(*ms).into_py(py), Value::List(items) => { let lst = PyList::empty_bound(py); for it in items { diff --git a/src/bin/frogql.rs b/src/bin/frogql.rs index ea24f73..b5c0b1e 100644 --- a/src/bin/frogql.rs +++ b/src/bin/frogql.rs @@ -760,6 +760,8 @@ impl NodeType { frogql::model::value::Value::Float(_) => "float", frogql::model::value::Value::Str(_) => "str", frogql::model::value::Value::Bool(_) => "bool", + frogql::model::value::Value::Date(_) => "date", + frogql::model::value::Value::LocalDatetime(_) => "datetime", frogql::model::value::Value::List(_) => "list", frogql::model::value::Value::Record(_) => "record", frogql::model::value::Value::Node(_) => "node", @@ -829,6 +831,8 @@ fn print_schema(store: &LazyGraphStore) { frogql::model::value::Value::Float(_) => "float", frogql::model::value::Value::Str(_) => "str", frogql::model::value::Value::Bool(_) => "bool", + frogql::model::value::Value::Date(_) => "date", + frogql::model::value::Value::LocalDatetime(_) => "datetime", frogql::model::value::Value::List(_) => "list", frogql::model::value::Value::Record(_) => "record", frogql::model::value::Value::Node(_) => "node", @@ -923,6 +927,8 @@ fn print_schema_simple(store: &LazyGraphStore) { frogql::model::value::Value::Float(_) => "float", frogql::model::value::Value::Str(_) => "str", frogql::model::value::Value::Bool(_) => "bool", + frogql::model::value::Value::Date(_) => "date", + frogql::model::value::Value::LocalDatetime(_) => "datetime", frogql::model::value::Value::List(_) => "list", frogql::model::value::Value::Record(_) => "record", frogql::model::value::Value::Node(_) => "node", @@ -1000,6 +1006,8 @@ fn print_schema_simple(store: &LazyGraphStore) { frogql::model::value::Value::Float(_) => "float", frogql::model::value::Value::Str(_) => "str", frogql::model::value::Value::Bool(_) => "bool", + frogql::model::value::Value::Date(_) => "date", + frogql::model::value::Value::LocalDatetime(_) => "datetime", frogql::model::value::Value::List(_) => "list", frogql::model::value::Value::Record(_) => "record", frogql::model::value::Value::Node(_) => "node", @@ -1457,6 +1465,8 @@ fn color_simple_type(t: &SimpleType) -> String { SimpleType::S => format!("{C_GREEN}STRING{C_RESET}"), SimpleType::Star => format!("{C_MAGENTA}ANY{C_RESET}"), SimpleType::Zero => format!("{C_DIM}⊥{C_RESET}"), + SimpleType::Date => format!("{C_GREEN}DATE{C_RESET}"), + SimpleType::LocalDatetime => format!("{C_GREEN}LOCAL DATETIME{C_RESET}"), SimpleType::Union(a, b) => format!( "{} | {}", color_simple_type_atom(a), diff --git a/src/bin/ldbc_bench.rs b/src/bin/ldbc_bench.rs index 823e272..322372e 100644 --- a/src/bin/ldbc_bench.rs +++ b/src/bin/ldbc_bench.rs @@ -62,6 +62,8 @@ fn shape_of_value(v: &Value) -> &'static str { Value::Str(_) => "s", Value::Bool(_) => "b", Value::Float(_) => "f", + Value::Date(_) => "d", + Value::LocalDatetime(_) => "t", Value::List(_) => "l", Value::Record(_) => "r", Value::Node(_) => "N", diff --git a/src/model/value.rs b/src/model/value.rs index d42b3f6..83b10bc 100644 --- a/src/model/value.rs +++ b/src/model/value.rs @@ -27,6 +27,16 @@ pub enum Value { /// (`ELEMENTS`/`PATH_LENGTH`/`CARDINALITY`) read it. Never a /// property value: a path cannot be stored on a node or edge. Path(Vec), + /// ISO §4.16.6 DATE value, constructed by the §20.27 `DATE(...)` + /// function. Days since 1970-01-01, proleptic Gregorian. Query-time + /// value only in this phase: like `Path`, it is never a property + /// value (temporal property storage is future work). + Date(i32), + /// ISO §4.16.6 LOCAL DATETIME value, constructed by the §20.27 + /// `LOCAL_DATETIME(...)` function. Milliseconds since + /// 1970-01-01T00:00:00, no timezone (the ZONED flavors are future + /// work). Query-time value only, like `Date`. + LocalDatetime(i64), } impl Value { @@ -35,6 +45,138 @@ impl Value { } } +/// Days since 1970-01-01 for a proleptic-Gregorian civil date. +/// Howard Hinnant's `days_from_civil` algorithm. +pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u64; // [0, 399] + let mp = ((m + 9) % 12) as u64; // Mar=0 .. Feb=11 + let doy = (153 * mp + 2) / 5 + (d as u64 - 1); // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + era * 146097 + doe as i64 - 719468 +} + +/// Inverse of `days_from_civil`: `(year, month, day)` for days since epoch. +pub fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Whether `(y, m, d)` is a real proleptic-Gregorian calendar date. +pub fn valid_civil(y: i64, m: u32, d: u32) -> bool { + if !(1..=12).contains(&m) || d == 0 { + return false; + } + let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let dim = match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if leap { + 29 + } else { + 28 + } + } + _ => unreachable!(), + }; + d <= dim +} + +/// Parse an ISO-8601 `` (`YYYY-MM-DD`) to days since epoch. +pub fn parse_date_str(s: &str) -> Option { + let mut it = s.split('-'); + let (y, m, d) = (it.next()?, it.next()?, it.next()?); + if it.next().is_some() || y.len() != 4 || m.len() != 2 || d.len() != 2 { + return None; + } + let (y, m, d) = ( + y.parse::().ok()?, + m.parse::().ok()?, + d.parse::().ok()?, + ); + if !valid_civil(y, m, d) { + return None; + } + i32::try_from(days_from_civil(y, m, d)).ok() +} + +/// Parse an ISO-8601 `` (`YYYY-MM-DDTHH:MM[:SS[.mmm]]`) +/// to milliseconds since epoch, no timezone. +pub fn parse_datetime_str(s: &str) -> Option { + let (date, time) = s.split_once('T')?; + let days = parse_date_str(date)? as i64; + let mut parts = time.split(':'); + let (h, mi) = ( + parts.next()?.parse::().ok()?, + parts.next()?.parse::().ok()?, + ); + let (sec, ms) = match parts.next() { + None => (0u32, 0u32), + Some(sec_part) => { + if parts.next().is_some() { + return None; + } + match sec_part.split_once('.') { + None => (sec_part.parse::().ok()?, 0), + Some((s2, frac)) => { + if frac.is_empty() + || frac.len() > 3 + || !frac.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + let scale = 10u32.pow(3 - frac.len() as u32); + (s2.parse::().ok()?, frac.parse::().ok()? * scale) + } + } + } + }; + if h > 23 || mi > 59 || sec > 59 { + return None; + } + Some( + days * 86_400_000 + + (h as i64) * 3_600_000 + + (mi as i64) * 60_000 + + (sec as i64) * 1000 + + ms as i64, + ) +} + +/// Format days-since-epoch as `YYYY-MM-DD`. +pub fn format_date(days: i32) -> String { + let (y, m, d) = civil_from_days(days as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Format millis-since-epoch as `YYYY-MM-DDTHH:MM:SS[.mmm]`. +pub fn format_datetime(millis: i64) -> String { + let days = millis.div_euclid(86_400_000); + let rem = millis.rem_euclid(86_400_000); + let (y, m, d) = civil_from_days(days); + let (h, mi, sec, ms) = ( + rem / 3_600_000, + rem % 3_600_000 / 60_000, + rem % 60_000 / 1000, + rem % 1000, + ); + if ms == 0 { + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}") + } else { + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}.{ms:03}") + } +} + impl fmt::Display for Value { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -63,6 +205,8 @@ impl fmt::Display for Value { let parts: Vec = items.iter().map(|v| format!("{v}")).collect(); write!(f, "<{}>", parts.join(", ")) } + Value::Date(days) => write!(f, "{}", format_date(*days)), + Value::LocalDatetime(ms) => write!(f, "{}", format_datetime(*ms)), } } } diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs index 8b915b1..ed3627c 100644 --- a/src/parser/grammar.rs +++ b/src/parser/grammar.rs @@ -1804,6 +1804,20 @@ impl Parser { args: vec![arg], }); } + // ISO §20.27 temporal constructors, same soft-keyword + // discipline: only the call form is special, so DATE + // stays usable as a variable or label elsewhere. Zero + // args (`DATE()` = CURRENT_DATE per SR 1) or one arg (a + // or a field record). + if let Some(canon) = temporal_function_name(&name) { + self.advance(); // consume '(' + let mut args = Vec::new(); + if !matches!(self.peek(), Token::RParen) { + args.push(self.expr()?); + } + self.expect(&Token::RParen)?; + return Ok(Expr::Call { name: canon, args }); + } } // First dot: variable-to-property. Subsequent dots: field access // on the previous value (for nested records). @@ -2741,6 +2755,17 @@ impl Parser { /// `PATH_LENGTH` / `CARDINALITY` are ISO; `NODES` / `EDGES` are the /// non-standard translation helpers (documented as a divergence). Returns /// `None` for any other name so it stays a plain variable reference. +/// Canonical name for the §20.27 temporal constructors, matched +/// case-insensitively in call position only. +fn temporal_function_name(name: &str) -> Option { + for canon in ["DATE", "LOCAL_DATETIME"] { + if name.eq_ignore_ascii_case(canon) { + return Some(canon.to_string()); + } + } + None +} + fn path_function_name(name: &str) -> Option { let upper = name.to_ascii_uppercase(); matches!( diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index b5e05c4..8ab9d82 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use crate::model::graph::Props; use crate::model::graph_access::GraphAccess; -use crate::model::value::{Id, Path, PathValue, Value}; +use crate::model::value::{ + days_from_civil, parse_date_str, parse_datetime_str, Id, Path, PathValue, Value, +}; use crate::syntax::descriptor::Descriptor; use crate::syntax::expr::{BinOp, Expr, UnOp}; use crate::syntax::path_pattern::PathPattern; @@ -3118,6 +3120,8 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { Value::Float(_) => SimpleType::F, Value::Str(_) => SimpleType::S, Value::Bool(_) => SimpleType::B, + Value::Date(_) => SimpleType::Date, + Value::LocalDatetime(_) => SimpleType::LocalDatetime, Value::List(items) => { if items.is_empty() { SimpleType::List(Box::new(SimpleType::Star)) @@ -3284,26 +3288,30 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { } } _ => { - let (ty_l, _, _) = op.delta(&SimpleType::Star, &SimpleType::Star); - let cast_left = Expr::Binop { - op: BinOp::As, - left: left.clone(), - right: Box::new(Expr::Type(ty_l.clone())), - }; - let cast_right = Expr::Binop { - op: BinOp::As, - left: right.clone(), - right: Box::new(Expr::Type(ty_l)), + let l = self.run_expr(mu, left); + let r = self.run_expr(mu, right); + let (lv, rv) = match (&l, &r) { + (ExprResult::Success(lv), ExprResult::Success(rv)) => (lv, rv), + (ExprResult::Failure(_), _) => return l, + (_, ExprResult::Failure(_)) => return r, }; - let l = self.run_expr(mu, &cast_left); - let r = self.run_expr(mu, &cast_right); - match (&l, &r) { - (ExprResult::Success(lv), ExprResult::Success(rv)) => { - Self::eval_binop(op, lv, rv) - } - (ExprResult::Failure(_), _) => l, - (_, ExprResult::Failure(_)) => r, + // Temporal operands bypass the numeric-cast coercion + // below: the §22.14 comparisons and the §20.26 datetime + // arithmetic are typed directly in `eval_binop`. + if temporal_binop_applies(op, lv, rv) { + return Self::eval_binop(op, lv, rv); } + // Delta-driven coercion, inlined with the same + // `value_is_type` semantics the old `AS`-wrapping had, + // without re-evaluating the operands. + let (ty_l, _, _) = op.delta(&SimpleType::Star, &SimpleType::Star); + if !Expr::value_is_type(lv, &ty_l) { + return ExprResult::Failure(format!("cannot cast {lv} to {ty_l}")); + } + if !Expr::value_is_type(rv, &ty_l) { + return ExprResult::Failure(format!("cannot cast {rv} to {ty_l}")); + } + Self::eval_binop(op, lv, rv) } }, @@ -3527,6 +3535,56 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { _ => unreachable!("guarded by the outer match arm"), } } + // ISO §20.27 temporal constructors. `DATE()` / + // `LOCAL_DATETIME()` with no argument are the CURRENT_DATE / + // LOCAL_TIMESTAMP equivalents (SR 1-3); with one argument they + // accept a or a field record. A + // malformed string or record fails (→ Null under 3VL), the + // same failure-as-null convention as CAST. + "DATE" => match args.first() { + None => ExprResult::Success(Value::Date(now_epoch_days())), + Some(a) => match self.run_expr(mu, a) { + ExprResult::Success(Value::Str(s)) => match parse_date_str(&s) { + Some(d) => ExprResult::Success(Value::Date(d)), + None => ExprResult::Failure(format!("malformed date string '{s}'")), + }, + ExprResult::Success(Value::Record(fields)) => match date_from_record(&fields) { + Some(d) => ExprResult::Success(Value::Date(d)), + None => ExprResult::Failure( + "DATE record needs integer year/month/day fields".into(), + ), + }, + ExprResult::Success(Value::Null) => ExprResult::Success(Value::Null), + ExprResult::Success(_) => { + ExprResult::Failure("DATE expects a string or a field record".into()) + } + e @ ExprResult::Failure(_) => e, + }, + }, + "LOCAL_DATETIME" => match args.first() { + None => ExprResult::Success(Value::LocalDatetime(now_epoch_millis())), + Some(a) => match self.run_expr(mu, a) { + ExprResult::Success(Value::Str(s)) => match parse_datetime_str(&s) { + Some(ms) => ExprResult::Success(Value::LocalDatetime(ms)), + None => ExprResult::Failure(format!("malformed datetime string '{s}'")), + }, + ExprResult::Success(Value::Record(fields)) => { + match datetime_from_record(&fields) { + Some(ms) => ExprResult::Success(Value::LocalDatetime(ms)), + None => ExprResult::Failure( + "LOCAL_DATETIME record needs integer year/month/day \ + (and optional hour/minute/second/millisecond) fields" + .into(), + ), + } + } + ExprResult::Success(Value::Null) => ExprResult::Success(Value::Null), + ExprResult::Success(_) => ExprResult::Failure( + "LOCAL_DATETIME expects a string or a field record".into(), + ), + e @ ExprResult::Failure(_) => e, + }, + }, other => ExprResult::Failure(format!("unknown built-in function `{other}`")), } } @@ -4001,6 +4059,32 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { } } match op { + // §20.26 datetime arithmetic, MVP form: a LOCAL DATETIME plus / + // minus an integer count of milliseconds (the form the + // DURATION({...}) desugaring produces). DATE arithmetic and + // typed durations are future work. + BinOp::Add + if matches!( + (lv, rv), + (Value::LocalDatetime(_), Value::Int(_)) + | (Value::Int(_), Value::LocalDatetime(_)) + ) => + { + let (ms, delta) = match (lv, rv) { + (Value::LocalDatetime(ms), Value::Int(n)) + | (Value::Int(n), Value::LocalDatetime(ms)) => (*ms, *n), + _ => unreachable!("guarded above"), + }; + ExprResult::Success(Value::LocalDatetime(ms.saturating_add(delta))) + } + BinOp::Sub if matches!((lv, rv), (Value::LocalDatetime(_), Value::Int(_))) => { + match (lv, rv) { + (Value::LocalDatetime(ms), Value::Int(n)) => { + ExprResult::Success(Value::LocalDatetime(ms.saturating_sub(*n))) + } + _ => unreachable!("guarded above"), + } + } 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))) => { @@ -4044,6 +4128,16 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { }, _ => ExprResult::Failure("MOD requires integer operands".into()), }, + // §22.14 ordering comparisons for same-type temporal values. + BinOp::Gt | BinOp::Lt | BinOp::Ge | BinOp::Le + if matches!( + (lv, rv), + (Value::Date(_), Value::Date(_)) + | (Value::LocalDatetime(_), Value::LocalDatetime(_)) + ) => + { + ExprResult::Success(Value::Bool(cmp_values(lv, *op, rv))) + } 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)), @@ -4409,6 +4503,79 @@ fn dedup_preserving_order(rows: Vec>) -> Vec> { #[derive(Debug, Clone)] struct GroupKey(Vec); +/// Whether a binary operator over these operand values is one of the +/// temporal forms that `eval_binop` types directly: same-type §22.14 +/// comparisons, cross-type temporal equality (defined, always false), +/// and §20.26 datetime ± integer-millis arithmetic. +fn temporal_binop_applies(op: &BinOp, lv: &Value, rv: &Value) -> bool { + let cmp = matches!( + op, + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge + ); + match (lv, rv) { + (Value::Date(_), Value::Date(_)) + | (Value::LocalDatetime(_), Value::LocalDatetime(_)) + | (Value::Date(_), Value::LocalDatetime(_)) + | (Value::LocalDatetime(_), Value::Date(_)) => cmp, + (Value::LocalDatetime(_), Value::Int(_)) => matches!(op, BinOp::Add | BinOp::Sub), + (Value::Int(_), Value::LocalDatetime(_)) => matches!(op, BinOp::Add), + _ => false, + } +} + +/// Days since 1970-01-01 for the current UTC instant (`DATE()` — the +/// CURRENT_DATE equivalent, §20.27 SR 1). +fn now_epoch_days() -> i32 { + (now_epoch_millis() / 86_400_000) as i32 +} + +/// Milliseconds since 1970-01-01T00:00:00 UTC (`LOCAL_DATETIME()`). +fn now_epoch_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Read an integer field from a §20.27 constructor record. +fn record_int(fields: &std::collections::BTreeMap, key: &str) -> Option { + match fields.get(key) { + Some(Value::Int(n)) => Some(*n), + _ => None, + } +} + +/// `DATE({year:, month:, day:})` — all three fields required. +fn date_from_record(fields: &std::collections::BTreeMap) -> Option { + let (y, m, d) = ( + record_int(fields, "year")?, + record_int(fields, "month")?, + record_int(fields, "day")?, + ); + if !crate::model::value::valid_civil(y, u32::try_from(m).ok()?, u32::try_from(d).ok()?) { + return None; + } + i32::try_from(days_from_civil(y, m as u32, d as u32)).ok() +} + +/// `LOCAL_DATETIME({year:, month:, day:, ...})` — date fields required, +/// time fields default to zero. +fn datetime_from_record(fields: &std::collections::BTreeMap) -> Option { + let days = date_from_record(fields)? as i64; + let h = record_int(fields, "hour").unwrap_or(0); + let mi = record_int(fields, "minute").unwrap_or(0); + let sec = record_int(fields, "second").unwrap_or(0); + let ms = record_int(fields, "millisecond").unwrap_or(0); + if !(0..24).contains(&h) + || !(0..60).contains(&mi) + || !(0..60).contains(&sec) + || !(0..1000).contains(&ms) + { + return None; + } + Some(days * 86_400_000 + h * 3_600_000 + mi * 60_000 + sec * 1000 + ms) +} + fn hash_value(v: &Value, state: &mut H) { std::mem::discriminant(v).hash(state); match v { @@ -4431,6 +4598,8 @@ fn hash_value(v: &Value, state: &mut H) { } } Value::Node(id) | Value::Edge(id) => id.hash(state), + Value::Date(d) => d.hash(state), + Value::LocalDatetime(ms) => ms.hash(state), Value::Path(items) => { items.len().hash(state); for item in items { @@ -4443,6 +4612,8 @@ fn hash_value(v: &Value, state: &mut H) { fn eq_value(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Int(x), Value::Int(y)) => x == y, + (Value::Date(x), Value::Date(y)) => x == y, + (Value::LocalDatetime(x), Value::LocalDatetime(y)) => x == y, (Value::Float(x), Value::Float(y)) => normalize_float_bits(*x) == normalize_float_bits(*y), (Value::Str(x), Value::Str(y)) => x == y, (Value::Bool(x), Value::Bool(y)) => x == y, @@ -4993,6 +5164,8 @@ fn value_cmp(a: &Value, b: &Value) -> Option { (Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y), (Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)), (Value::Str(x), Value::Str(y)) => Some(x.cmp(y)), + (Value::Date(x), Value::Date(y)) => Some(x.cmp(y)), + (Value::LocalDatetime(x), Value::LocalDatetime(y)) => Some(x.cmp(y)), (Value::Bool(x), Value::Bool(y)) => Some(match (x, y) { (false, true) => Ordering::Less, (true, false) => Ordering::Greater, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ea917b3..d1e3f1e 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -21,6 +21,34 @@ pub fn cmp_values(lhs: &Value, op: BinOp, rhs: &Value) -> bool { if lhs.is_null() || rhs.is_null() { return false; } + // Temporal values (§4.16.6) are totally ordered within their own + // type; a DATE and a LOCAL DATETIME are distinct types and never + // compare (false for every operator, like any cross-type pair). + match (lhs, rhs) { + (Value::Date(x), Value::Date(y)) => { + return match op { + BinOp::Eq => x == y, + BinOp::Ne => x != y, + BinOp::Lt => x < y, + BinOp::Le => x <= y, + BinOp::Gt => x > y, + BinOp::Ge => x >= y, + _ => false, + }; + } + (Value::LocalDatetime(x), Value::LocalDatetime(y)) => { + return match op { + BinOp::Eq => x == y, + BinOp::Ne => x != y, + BinOp::Lt => x < y, + BinOp::Le => x <= y, + BinOp::Gt => x > y, + BinOp::Ge => x >= y, + _ => false, + }; + } + _ => {} + } // Composite values: structural equality via the derived `PartialEq`. // Ordering is undefined and yields false for `<`, `<=`, `>`, `>=`. match (lhs, rhs) { diff --git a/src/store/dump.rs b/src/store/dump.rs index 3d7c28f..733d7ae 100644 --- a/src/store/dump.rs +++ b/src/store/dump.rs @@ -94,6 +94,10 @@ fn props_to_json(props: &Props) -> Json { fn value_to_json(v: &Value) -> Json { match v { Value::Null => Json::Null, + // Temporal values never occur in stored properties (query-time + // only), but the mapping is total: ISO-8601 strings. + Value::Date(d) => Json::String(crate::model::value::format_date(*d)), + Value::LocalDatetime(ms) => Json::String(crate::model::value::format_datetime(*ms)), Value::Int(n) => Json::Number((*n).into()), Value::Float(x) => serde_json::Number::from_f64(*x) .map(Json::Number) @@ -266,6 +270,14 @@ fn format_descriptor(labels: &[String], props: &Props) -> Result fn format_gql_value(v: &Value) -> Result { Ok(match v { Value::Null => "NULL".to_string(), + // Round-trippable constructor syntax (§20.27). + Value::Date(d) => format!("DATE('{}')", crate::model::value::format_date(*d)), + Value::LocalDatetime(ms) => { + format!( + "LOCAL_DATETIME('{}')", + crate::model::value::format_datetime(*ms) + ) + } Value::Int(n) => n.to_string(), Value::Float(x) => { if x.is_finite() && x.fract() == 0.0 { diff --git a/src/store/io.rs b/src/store/io.rs index 3968783..e5b105b 100644 --- a/src/store/io.rs +++ b/src/store/io.rs @@ -383,6 +383,15 @@ fn value_to_prop(v: &Value, st: &mut StringTable, pager: &mut Pager) -> io::Resu "node / edge reference values cannot be stored as properties", )); } + // Temporal values are query-time only in this phase; persisting + // them as properties (with their own VALUE_TYPE_* tags) is the + // documented next step of the temporal roadmap. + Value::Date(_) | Value::LocalDatetime(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "temporal values cannot be stored as properties yet", + )); + } }) } diff --git a/src/typing/checker.rs b/src/typing/checker.rs index 22ff420..8cfc5cf 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -795,6 +795,27 @@ impl Typechecker { match name.as_str() { // FLOOR(numeric) → Float (the runtime narrows via CAST). "FLOOR" => SimpleType::F, + // ISO §20.27 temporal constructors. Zero args = current + // date/datetime; one arg = a or a + // field record. A provably wrong argument type is a hard + // error; `Star` is tolerated per the gradual rule. + "DATE" | "LOCAL_DATETIME" => { + if let Some(arg_t) = arg_types.first() { + let stringish = !SimpleType::meet(arg_t, &SimpleType::S).is_empty(); + let recordish = + matches!(arg_t, SimpleType::Record(_) | SimpleType::Star); + if !stringish && !recordish { + self.errors.push(format!( + "{name} expects a string or a field record, got {arg_t}" + )); + } + } + if name == "DATE" { + SimpleType::Date + } else { + SimpleType::LocalDatetime + } + } // CAST(operand AS ) → the target type, which // the parser carries as `Expr::Type` in args[1]. "CAST" => match args.get(1) { @@ -1148,6 +1169,8 @@ fn simple_type_of_value(v: &Value) -> SimpleType { Value::Node(_) => SimpleType::Node, Value::Edge(_) => SimpleType::Edge, Value::Path(_) => SimpleType::Path, + Value::Date(_) => SimpleType::Date, + Value::LocalDatetime(_) => SimpleType::LocalDatetime, } } @@ -1319,7 +1342,13 @@ fn short_var_type(t: &VariableType) -> String { /// would need Feature GA04. fn is_orderable_per_iso_22_14(t: &SimpleType) -> bool { match t { - SimpleType::Z | SimpleType::F | SimpleType::B | SimpleType::S | SimpleType::Star => true, + SimpleType::Z + | SimpleType::F + | SimpleType::B + | SimpleType::S + | SimpleType::Star + | SimpleType::Date + | SimpleType::LocalDatetime => true, SimpleType::Zero => false, SimpleType::Union(a, b) => is_orderable_per_iso_22_14(a) && is_orderable_per_iso_22_14(b), // ISO §4.4.4 says reference values of the same base type are diff --git a/src/typing/format.rs b/src/typing/format.rs index 4ebe286..8e2fc06 100644 --- a/src/typing/format.rs +++ b/src/typing/format.rs @@ -172,6 +172,8 @@ fn format_simple_type(t: &SimpleType) -> String { SimpleType::Node => "NODE".to_string(), SimpleType::Edge => "EDGE".to_string(), SimpleType::Path => "PATH".to_string(), + SimpleType::Date => "DATE".to_string(), + SimpleType::LocalDatetime => "LOCAL DATETIME".to_string(), } } diff --git a/src/typing/inference.rs b/src/typing/inference.rs index d1088f6..942df31 100644 --- a/src/typing/inference.rs +++ b/src/typing/inference.rs @@ -50,6 +50,10 @@ fn value_to_simple_type(v: &Value) -> SimpleType { Value::Float(_) => SimpleType::F, Value::Bool(_) => SimpleType::B, Value::Str(_) => SimpleType::S, + // Temporal values are query-time only in this phase; they never + // occur in stored properties, but the mapping is total anyway. + Value::Date(_) => SimpleType::Date, + Value::LocalDatetime(_) => SimpleType::LocalDatetime, Value::List(items) => { if items.is_empty() { SimpleType::List(Box::new(SimpleType::Star)) diff --git a/src/typing/simple_type.rs b/src/typing/simple_type.rs index 0ea7ba5..de65753 100644 --- a/src/typing/simple_type.rs +++ b/src/typing/simple_type.rs @@ -41,6 +41,14 @@ pub enum SimpleType { /// itself, never carries structure, and never appears in a persisted /// schema (a path cannot be a property value). Path, + /// ISO §4.16.6 DATE temporal type (§20.27 `DATE(...)`). A base type: + /// totally ordered, so it is a valid ORDER BY key. Query-time only in + /// this phase (never a persisted property type). + Date, + /// ISO §4.16.6 LOCAL DATETIME temporal type (§20.27 + /// `LOCAL_DATETIME(...)`). A base type, totally ordered. Query-time + /// only in this phase, like `Date`. + LocalDatetime, } impl SimpleType { @@ -154,6 +162,8 @@ impl fmt::Display for SimpleType { SimpleType::Node => write!(f, "node"), SimpleType::Edge => write!(f, "edge"), SimpleType::Path => write!(f, "path"), + SimpleType::Date => write!(f, "date"), + SimpleType::LocalDatetime => write!(f, "local datetime"), } } } diff --git a/src/typing/validate.rs b/src/typing/validate.rs index 496fba3..3264af3 100644 --- a/src/typing/validate.rs +++ b/src/typing/validate.rs @@ -238,6 +238,10 @@ fn value_to_simple_type(v: &Value) -> SimpleType { Value::Float(_) => SimpleType::F, Value::Bool(_) => SimpleType::B, Value::Str(_) => SimpleType::S, + // Temporal values are query-time only in this phase; they never + // occur in stored properties, but the mapping is total anyway. + Value::Date(_) => SimpleType::Date, + Value::LocalDatetime(_) => SimpleType::LocalDatetime, Value::List(items) => { if items.is_empty() { SimpleType::List(Box::new(SimpleType::Star)) diff --git a/tests/temporal_test.rs b/tests/temporal_test.rs new file mode 100644 index 0000000..9635ab9 --- /dev/null +++ b/tests/temporal_test.rs @@ -0,0 +1,175 @@ +//! ISO §4.16.6 / §20.27 temporal values — DATE and LOCAL_DATETIME MVP. +//! +//! Query-time values only in this phase: constructors from a string or a +//! field record, total ordering within each type, ORDER BY / GROUP BY +//! support, and datetime + integer-millis arithmetic (the form the +//! `DURATION({...})` desugaring produces). ZONED types, typed durations +//! and property storage are the documented next steps. + +use frogql::compile_query; +use frogql::model::graph::MemoryGraphStore; +use frogql::model::value::Value; +use frogql::runtime::engine::Runtime; +use frogql::runtime::result::QueryResult; + +fn dated_nodes() -> MemoryGraphStore { + let json = r#"{ + "nodes": [ + {"id": "n1", "labels": ["Ev"], "props": {"d": "2011-03-15", "name": "b"}}, + {"id": "n2", "labels": ["Ev"], "props": {"d": "2010-01-05", "name": "a"}}, + {"id": "n3", "labels": ["Ev"], "props": {"d": "2010-01-05", "name": "c"}} + ], + "edges": [] + }"#; + MemoryGraphStore::from_json_str(json).unwrap() +} + +fn run(g: &MemoryGraphStore, q: &str) -> Vec> { + let rt = Runtime::new(g); + let query = compile_query(q).unwrap(); + match rt.run_query(&query, 0) { + QueryResult::Projected(rs) => rs, + other => panic!("expected projected rows, got {other:?}"), + } +} + +// ---------------------------------------------------------- constructors --- + +#[test] +fn date_from_string_displays_iso() { + let g = dated_nodes(); + let rows = run(&g, "MATCH (x:Ev) WHERE x.name = 'a' RETURN DATE(x.d) AS d"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0].to_string(), "2010-01-05"); + assert!(matches!(rows[0][0], Value::Date(_))); +} + +#[test] +fn date_from_record_equals_date_from_string() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' \ + RETURN DATE({year: 2010, month: 1, day: 5}) = DATE(x.d) AS eq", + ); + assert_eq!(rows, vec![vec![Value::Bool(true)]]); +} + +#[test] +fn local_datetime_from_string_roundtrips() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' \ + RETURN LOCAL_DATETIME('2010-01-05T08:30:15') AS t", + ); + assert_eq!(rows[0][0].to_string(), "2010-01-05T08:30:15"); +} + +#[test] +fn malformed_date_string_degrades_to_null() { + // Failure-as-null, the same convention as CAST('hola' AS INTEGER): + // 2023 is not a leap year, so Feb 29 does not exist. + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' RETURN DATE('2023-02-29') AS d", + ); + assert_eq!(rows, vec![vec![Value::Null]]); +} + +#[test] +fn leap_day_is_a_valid_date() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' RETURN DATE('2024-02-29') AS d", + ); + assert_eq!(rows[0][0].to_string(), "2024-02-29"); +} + +#[test] +fn bare_date_name_stays_usable_as_variable() { + // Soft keyword: only the call form is special. + let g = dated_nodes(); + let rows = run(&g, "MATCH (date:Ev) WHERE date.name = 'a' RETURN date.d"); + assert_eq!(rows, vec![vec![Value::Str("2010-01-05".into())]]); +} + +// ------------------------------------------------- comparison + ordering --- + +#[test] +fn dates_compare_chronologically_in_where() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE DATE(x.d) < DATE('2011-01-01') RETURN x.name", + ); + assert_eq!(rows.len(), 2, "only the two 2010 events pass the filter"); +} + +#[test] +fn order_by_date_sorts_chronologically() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) RETURN x.name AS n, DATE(x.d) AS d ORDER BY d DESC, n ASC", + ); + let names: Vec = rows.iter().map(|r| r[0].to_string()).collect(); + assert_eq!(names, vec!["\"b\"", "\"a\"", "\"c\""]); +} + +#[test] +fn group_by_date_collapses_equal_dates() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) RETURN DATE(x.d) AS d, COUNT(*) AS c \ + GROUP BY DATE(x.d) ORDER BY c DESC", + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][1], Value::Int(2), "two events share 2010-01-05"); +} + +#[test] +fn date_and_datetime_do_not_cross_compare() { + // Distinct temporal types: every comparison operator yields false, + // so the filter drops the row (3VL-style). + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' \ + AND DATE('2010-01-05') = LOCAL_DATETIME('2010-01-05T00:00:00') \ + RETURN x.name", + ); + assert!(rows.is_empty()); +} + +// ----------------------------------------------------------- arithmetic --- + +#[test] +fn datetime_plus_duration_millis_advances_a_day() { + let g = dated_nodes(); + let rows = run( + &g, + "MATCH (x:Ev) WHERE x.name = 'a' \ + RETURN LOCAL_DATETIME('2010-01-05T23:30:00') + DURATION({days: 1}) AS t", + ); + assert_eq!(rows[0][0].to_string(), "2010-01-06T23:30:00"); +} + +// ----------------------------------------------------------- typechecker --- + +#[test] +fn typecheck_accepts_date_as_sort_key() { + // Temporal types are base types: orderable per §22.14 without GA04. + let r = compile_query("MATCH (x:Ev) RETURN DATE(x.d) AS d ORDER BY d"); + assert!(r.is_ok(), "got: {:?}", r.err()); +} + +#[test] +fn typecheck_rejects_provably_wrong_constructor_argument() { + let r = compile_query("MATCH (x:Ev) RETURN DATE(true) AS d"); + let err = r.expect_err("DATE(bool) must be a type error"); + assert!(err.contains("DATE"), "got: {err}"); +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 67b0339..b09d6ee 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -266,6 +266,8 @@ fn value_to_json(store: &MemoryGraphStore, v: &Value) -> Json { .map(Json::Number) .unwrap_or(Json::Null), Value::Str(s) => json!(s), + Value::Date(d) => json!(frogql_core::model::value::format_date(*d)), + Value::LocalDatetime(ms) => json!(frogql_core::model::value::format_datetime(*ms)), Value::Bool(b) => json!(b), Value::List(items) => { Json::Array(items.iter().map(|it| value_to_json(store, it)).collect()) From 4f37abdb7d5102f6a61ef0651762784d32391176 Mon Sep 17 00:00:00 2001 From: Jean Paul Duchens Pacheco Date: Sun, 5 Jul 2026 23:57:32 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(temporal):=20preservar=20la=20relajaci?= =?UTF-8?q?=C3=B3n=203VL=20de=20null=20al=20inlinear=20la=20coerci=C3=B3n?= =?UTF-8?q?=20de=20operandos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El merge de la 3VL (#73) rompía pushdown_null_test: el despacho de la rama temporal inlineó el cast de operandos pero omitió el caso 'null inhabits every type' que la 3VL agregó al brazo AS. Un operando null devolvía Failure en vez de fluir como Null a la lógica 3VL, de modo que 'x.a = 999 OR true' con x.a ausente tiraba la fila en vez de conservarla. Ahora la coerción inline trata null explícitamente, idéntico al brazo AS de main; los temporales conservan su bypass. Co-Authored-By: Claude Fable 5 --- src/runtime/engine.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index ed1207a..9a17566 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -3323,17 +3323,24 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { if temporal_binop_applies(op, lv, rv) { return Self::eval_binop(op, lv, rv); } - // Delta-driven coercion, inlined with the same - // `value_is_type` semantics the old `AS`-wrapping had, - // without re-evaluating the operands. + // Delta-driven coercion, inlined with the same semantics as + // the old `AS`-wrapping without re-evaluating the operands. + // The 3VL relaxation is preserved: null inhabits every type, + // so a null operand flows through to `eval_binop`'s 3VL + // logic; only a non-null value that fails the cast is an + // error. let (ty_l, _, _) = op.delta(&SimpleType::Star, &SimpleType::Star); - if !Expr::value_is_type(lv, &ty_l) { - return ExprResult::Failure(format!("cannot cast {lv} to {ty_l}")); - } - if !Expr::value_is_type(rv, &ty_l) { - return ExprResult::Failure(format!("cannot cast {rv} to {ty_l}")); + let coerce = |v: &Value| -> Result { + if v.is_null() || Expr::value_is_type(v, &ty_l) { + Ok(v.clone()) + } else { + Err(format!("cannot cast {v} to {ty_l}")) + } + }; + match (coerce(lv), coerce(rv)) { + (Ok(lc), Ok(rc)) => Self::eval_binop(op, &lc, &rc), + (Err(e), _) | (_, Err(e)) => ExprResult::Failure(e), } - Self::eval_binop(op, lv, rv) } },