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 48b4077..9a17566 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)) @@ -3306,25 +3310,36 @@ 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) + // 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 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); + 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}")) } - (ExprResult::Failure(_), _) => l, - (_, ExprResult::Failure(_)) => r, + }; + match (coerce(lv), coerce(rv)) { + (Ok(lc), Ok(rc)) => Self::eval_binop(op, &lc, &rc), + (Err(e), _) | (_, Err(e)) => ExprResult::Failure(e), } } }, @@ -3555,6 +3570,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}`")), } } @@ -4034,6 +4099,8 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { // the operand-cast wrapper propagates a Failure before eval_binop, // so only genuine values (including null) arrive. Logical AND/OR are // handled below (null is absorbing-aware, not blanket-propagated). + // This guard runs before the temporal arms below; temporal operands + // are never null, so the two are mutually exclusive. BinOp::Add | BinOp::Sub | BinOp::Mul @@ -4049,6 +4116,32 @@ impl<'g, G: GraphAccess + 'g> Runtime<'g, G> { { ExprResult::Success(Value::Null) } + // §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))) => { @@ -4092,6 +4185,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)), @@ -4490,6 +4593,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 { @@ -4512,6 +4688,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 { @@ -4524,6 +4702,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, @@ -5074,6 +5254,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 9318653..45f7ccc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -35,6 +35,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 911539b..668ee61 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -809,6 +809,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) { @@ -1162,6 +1183,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, } } @@ -1333,7 +1356,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())