Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
2 changes: 2 additions & 0 deletions python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/bin/frogql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/bin/ldbc_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 144 additions & 0 deletions src/model/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>),
/// 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 {
Expand All @@ -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 `<date string>` (`YYYY-MM-DD`) to days since epoch.
pub fn parse_date_str(s: &str) -> Option<i32> {
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::<i64>().ok()?,
m.parse::<u32>().ok()?,
d.parse::<u32>().ok()?,
);
if !valid_civil(y, m, d) {
return None;
}
i32::try_from(days_from_civil(y, m, d)).ok()
}

/// Parse an ISO-8601 `<datetime string>` (`YYYY-MM-DDTHH:MM[:SS[.mmm]]`)
/// to milliseconds since epoch, no timezone.
pub fn parse_datetime_str(s: &str) -> Option<i64> {
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::<u32>().ok()?,
parts.next()?.parse::<u32>().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::<u32>().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::<u32>().ok()?, frac.parse::<u32>().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 {
Expand Down Expand Up @@ -63,6 +205,8 @@ impl fmt::Display for Value {
let parts: Vec<String> = 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)),
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/parser/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <date string> 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).
Expand Down Expand Up @@ -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<String> {
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<String> {
let upper = name.to_ascii_uppercase();
matches!(
Expand Down
Loading
Loading