lora-executor 0.20.0

Query-plan executor for LoraDB's Cypher implementation.
Documentation
//! `temporal.*` — date / time / datetime / duration operations.
//!
//! Value construction is handled by `cast.to(value, TYPE)` / `CAST(value AS
//! TYPE)`. This namespace is reserved for current-time helpers and temporal
//! operations.

use std::collections::BTreeMap;

use lora_store::{
    LoraDate, LoraDateTime, LoraDuration, LoraLocalDateTime, LoraLocalTime, LoraTime,
};

use crate::value::LoraValue;

use super::super::errors::set_eval_error;
use super::temporal_build::{self, Between, Kind};

pub(super) fn dispatch(op: &str, args: &[LoraValue]) -> Option<LoraValue> {
    Some(match op {
        // Current-instant
        "now" => now(args),
        "today" => LoraValue::Date(LoraDate::today()),
        "timestamp" => timestamp(),
        "timezone" => timezone(),
        // Operations
        "parse" => parse(args),
        "format" => format_temporal(args),
        "reformat" => reformat(args),
        "convert" => convert(args),
        "add" => add(args),
        "get" => get(args),
        "fields" => fields(args),
        "truncate" => truncate(args),
        "between" => between(args),
        "in_days" => in_days(args),
        "duration_between" => duration_between(args, Between::Full),
        "in_months" => duration_between(args, Between::Months),
        "in_seconds" => duration_between(args, Between::Seconds),
        "from_epoch" => from_epoch(args),
        "from_epoch_millis" => from_epoch_millis(args),
        _ => return None,
    })
}

// --- current-instant -------------------------------------------------------

fn now(args: &[LoraValue]) -> LoraValue {
    let raw = match args.first() {
        Some(LoraValue::String(s)) => Some(s.clone()),
        Some(LoraValue::Null) => return LoraValue::Null,
        _ => None,
    };
    let kind_lower = raw.as_deref().map(str::to_ascii_lowercase);
    match kind_lower.as_deref() {
        None => LoraValue::DateTime(LoraDateTime::now()),
        Some("date") => LoraValue::Date(LoraDate::today()),
        Some("datetime") => LoraValue::DateTime(LoraDateTime::now()),
        Some("time") => LoraValue::Time(LoraTime::now()),
        Some("local_time") => LoraValue::LocalTime(LoraLocalTime::now()),
        Some("local_datetime") => LoraValue::LocalDateTime(LoraLocalDateTime::now()),
        // Not a known "kind" keyword — interpret the argument as a
        // value to parse. This is what `datetime("2025-01-01T12:00:00Z")`
        // and `date("2025-01-01")` rely on.
        Some(_) => {
            let Some(s) = raw.as_deref() else {
                return LoraValue::Null;
            };
            if let Ok(dt) = LoraDateTime::parse(s) {
                LoraValue::DateTime(dt)
            } else if let Ok(d) = LoraDate::parse(s) {
                LoraValue::Date(d)
            } else if let Ok(d) = LoraDuration::parse(s) {
                LoraValue::Duration(d)
            } else {
                LoraValue::Null
            }
        }
    }
}

fn timestamp() -> LoraValue {
    use web_time::{SystemTime, UNIX_EPOCH};
    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0);
    LoraValue::Int(millis)
}

fn timezone() -> LoraValue {
    LoraValue::String("UTC".to_string())
}

// --- operations ------------------------------------------------------------

fn parse(args: &[LoraValue]) -> LoraValue {
    let Some(LoraValue::String(s)) = args.first() else {
        return LoraValue::Null;
    };
    let kind = match args.get(2) {
        Some(LoraValue::String(k)) => k.to_ascii_lowercase(),
        _ => detect_kind(s),
    };
    match kind.as_str() {
        "date" => LoraDate::parse(s)
            .map(LoraValue::Date)
            .unwrap_or(LoraValue::Null),
        "datetime" => LoraDateTime::parse(s)
            .map(LoraValue::DateTime)
            .unwrap_or(LoraValue::Null),
        "duration" => LoraDuration::parse(s)
            .map(LoraValue::Duration)
            .unwrap_or(LoraValue::Null),
        _ => LoraValue::Null,
    }
}

fn detect_kind(s: &str) -> String {
    if s.starts_with('P') {
        "duration".to_string()
    } else if s.contains('T') {
        "datetime".to_string()
    } else {
        "date".to_string()
    }
}

fn format_temporal(args: &[LoraValue]) -> LoraValue {
    let Some(v) = args.first() else {
        return LoraValue::Null;
    };
    LoraValue::String(match v {
        LoraValue::Date(d) => d.to_string(),
        LoraValue::DateTime(dt) => dt.to_string(),
        LoraValue::LocalDateTime(dt) => dt.to_string(),
        LoraValue::Time(t) => t.to_string(),
        LoraValue::LocalTime(t) => t.to_string(),
        LoraValue::Duration(d) => d.to_string(),
        _ => return LoraValue::Null,
    })
}

fn reformat(args: &[LoraValue]) -> LoraValue {
    let (Some(LoraValue::String(s)), Some(LoraValue::String(_from)), Some(LoraValue::String(_to))) =
        (args.first(), args.get(1), args.get(2))
    else {
        return LoraValue::Null;
    };
    if let Ok(dt) = LoraDateTime::parse(s) {
        return LoraValue::String(dt.to_string());
    }
    if let Ok(d) = LoraDate::parse(s) {
        return LoraValue::String(d.to_string());
    }
    LoraValue::Null
}

fn convert(args: &[LoraValue]) -> LoraValue {
    let (Some(n), Some(LoraValue::String(from)), Some(LoraValue::String(to))) = (
        args.first().and_then(LoraValue::as_i64),
        args.get(1),
        args.get(2),
    ) else {
        return LoraValue::Null;
    };
    match to_nanos(n, from).and_then(|ns| from_nanos(ns, to)) {
        Some(out) => LoraValue::Int(out),
        None => LoraValue::Null,
    }
}

fn to_nanos(n: i64, unit: &str) -> Option<i128> {
    let v: i64 = match unit.to_ascii_lowercase().as_str() {
        "ns" | "nanos" | "nanoseconds" => 1,
        "us" | "micros" | "microseconds" => 1_000,
        "ms" | "millis" | "milliseconds" => 1_000_000,
        "s" | "seconds" => 1_000_000_000,
        "m" | "minutes" => 60_000_000_000,
        "h" | "hours" => 3_600_000_000_000,
        "d" | "days" => 86_400_000_000_000,
        "w" | "weeks" => 604_800_000_000_000,
        _ => return None,
    };
    Some(n as i128 * v as i128)
}

fn from_nanos(ns: i128, unit: &str) -> Option<i64> {
    let divisor: i64 = match unit.to_ascii_lowercase().as_str() {
        "ns" | "nanos" | "nanoseconds" => 1,
        "us" | "micros" | "microseconds" => 1_000,
        "ms" | "millis" | "milliseconds" => 1_000_000,
        "s" | "seconds" => 1_000_000_000,
        "m" | "minutes" => 60_000_000_000,
        "h" | "hours" => 3_600_000_000_000,
        "d" | "days" => 86_400_000_000_000,
        "w" | "weeks" => 604_800_000_000_000,
        _ => return None,
    };
    i64::try_from(ns / divisor as i128).ok()
}

fn add(args: &[LoraValue]) -> LoraValue {
    let (Some(t), Some(LoraValue::Duration(d))) = (args.first(), args.get(1)) else {
        return LoraValue::Null;
    };
    match t {
        LoraValue::Date(date) => LoraValue::Date(date.add_duration(d)),
        LoraValue::DateTime(dt) => LoraValue::DateTime(dt.add_duration(d)),
        _ => LoraValue::Null,
    }
}

fn get(args: &[LoraValue]) -> LoraValue {
    let (Some(t), Some(LoraValue::String(field))) = (args.first(), args.get(1)) else {
        return LoraValue::Null;
    };
    let field = field.to_ascii_lowercase();
    match t {
        LoraValue::Date(d) => match field.as_str() {
            "year" => LoraValue::Int(d.year as i64),
            "month" => LoraValue::Int(d.month as i64),
            "day" => LoraValue::Int(d.day as i64),
            "day_of_week" => LoraValue::Int(d.day_of_week() as i64),
            "day_of_year" => LoraValue::Int(d.day_of_year() as i64),
            _ => LoraValue::Null,
        },
        LoraValue::DateTime(dt) => match field.as_str() {
            "year" => LoraValue::Int(dt.year as i64),
            "month" => LoraValue::Int(dt.month as i64),
            "day" => LoraValue::Int(dt.day as i64),
            "hour" => LoraValue::Int(dt.hour as i64),
            "minute" => LoraValue::Int(dt.minute as i64),
            "second" => LoraValue::Int(dt.second as i64),
            "nanosecond" => LoraValue::Int(dt.nanosecond as i64),
            "offset_seconds" => LoraValue::Int(dt.offset_seconds as i64),
            _ => LoraValue::Null,
        },
        LoraValue::Duration(d) => match field.as_str() {
            "years" => LoraValue::Int(d.years_component()),
            "months" => LoraValue::Int(d.months_component()),
            "days" => LoraValue::Int(d.days_component()),
            "hours" => LoraValue::Int(d.hours_component()),
            "minutes" => LoraValue::Int(d.minutes_component()),
            "seconds" => LoraValue::Int(d.seconds_component()),
            _ => LoraValue::Null,
        },
        _ => LoraValue::Null,
    }
}

fn fields(args: &[LoraValue]) -> LoraValue {
    let mut m: BTreeMap<String, LoraValue> = BTreeMap::new();
    match args.first() {
        Some(LoraValue::Date(d)) => {
            m.insert("year".into(), LoraValue::Int(d.year as i64));
            m.insert("month".into(), LoraValue::Int(d.month as i64));
            m.insert("day".into(), LoraValue::Int(d.day as i64));
        }
        Some(LoraValue::DateTime(dt)) => {
            m.insert("year".into(), LoraValue::Int(dt.year as i64));
            m.insert("month".into(), LoraValue::Int(dt.month as i64));
            m.insert("day".into(), LoraValue::Int(dt.day as i64));
            m.insert("hour".into(), LoraValue::Int(dt.hour as i64));
            m.insert("minute".into(), LoraValue::Int(dt.minute as i64));
            m.insert("second".into(), LoraValue::Int(dt.second as i64));
            m.insert("nanosecond".into(), LoraValue::Int(dt.nanosecond as i64));
            m.insert(
                "offset_seconds".into(),
                LoraValue::Int(dt.offset_seconds as i64),
            );
        }
        _ => return LoraValue::Null,
    }
    LoraValue::Map(m)
}

/// `temporal.truncate(unit, value)` (either order) keeps the value's type
/// and returns null for a unit or a value it cannot truncate.
///
/// The Cypher `<type>.truncate(unit, value[, map])` forms lower to
/// `temporal.truncate(unit, value, [map,] TYPE)`: they build `TYPE` and
/// fail on a bad unit, value or map. `temporal.truncate(unit, value, map)`
/// keeps the value's type.
fn truncate(args: &[LoraValue]) -> LoraValue {
    if args.len() < 2 {
        return LoraValue::Null;
    }
    if args.len() == 2 {
        let (unit, value) = match (&args[0], &args[1]) {
            (LoraValue::String(u), v) | (v, LoraValue::String(u)) => (u, v),
            _ => return LoraValue::Null,
        };
        return temporal_build::truncate(unit, value, None, None).unwrap_or(LoraValue::Null);
    }
    let (map, kind) = match &args[2..] {
        [LoraValue::String(ty)] => (&LoraValue::Null, Kind::from_type_name(ty)),
        [map] => (map, None),
        [map, LoraValue::String(ty)] => (map, Kind::from_type_name(ty)),
        _ => return LoraValue::Null,
    };
    let (unit, value) = match (&args[0], &args[1]) {
        (LoraValue::Null, _) | (_, LoraValue::Null) => return LoraValue::Null,
        (LoraValue::String(u), v) => (u, v),
        (other, _) => {
            set_eval_error(format!(
                "truncate unit must be a string, got {}",
                crate::errors::value_kind(other)
            ));
            return LoraValue::Null;
        }
    };
    let map = match map {
        LoraValue::Null => None,
        LoraValue::Map(m) => Some(m),
        other => {
            set_eval_error(format!(
                "truncate components must be a map, got {}",
                crate::errors::value_kind(other)
            ));
            return LoraValue::Null;
        }
    };
    temporal_build::truncate(unit, value, map, kind).unwrap_or_else(|e| {
        set_eval_error(e);
        LoraValue::Null
    })
}

/// `duration.between(a, b)` and `duration.inMonths / inDays /
/// inSeconds`; null when either is null.
fn duration_between(args: &[LoraValue], what: Between) -> LoraValue {
    match (args.first(), args.get(1)) {
        (Some(a), Some(b)) if !matches!(a, LoraValue::Null) && !matches!(b, LoraValue::Null) => {
            match temporal_build::between(a, b, what) {
                Ok(d) => LoraValue::Duration(d),
                Err(e) => {
                    set_eval_error(e);
                    LoraValue::Null
                }
            }
        }
        _ => LoraValue::Null,
    }
}

/// `datetime.fromepoch(seconds, nanoseconds)`.
fn from_epoch(args: &[LoraValue]) -> LoraValue {
    match (args.first(), args.get(1)) {
        (Some(LoraValue::Int(s)), Some(LoraValue::Int(ns))) => {
            report(temporal_build::from_epoch(*s, *ns))
        }
        (Some(LoraValue::Null), _) | (_, Some(LoraValue::Null)) => LoraValue::Null,
        _ => {
            set_eval_error("datetime.fromepoch expects two integers".to_string());
            LoraValue::Null
        }
    }
}

/// `datetime.fromepochmillis(milliseconds)`.
fn from_epoch_millis(args: &[LoraValue]) -> LoraValue {
    match args.first() {
        Some(LoraValue::Int(ms)) => report(temporal_build::from_epoch_millis(*ms)),
        Some(LoraValue::Null) => LoraValue::Null,
        _ => {
            set_eval_error("datetime.fromepochmillis expects an integer".to_string());
            LoraValue::Null
        }
    }
}

fn report(result: Result<LoraValue, String>) -> LoraValue {
    result.unwrap_or_else(|e| {
        set_eval_error(e);
        LoraValue::Null
    })
}

fn between(args: &[LoraValue]) -> LoraValue {
    match (args.first(), args.get(1)) {
        (Some(LoraValue::Date(a)), Some(LoraValue::Date(b))) => {
            LoraValue::Duration(LoraDuration::between_dates(a, b))
        }
        (Some(LoraValue::DateTime(a)), Some(LoraValue::DateTime(b))) => {
            LoraValue::Duration(LoraDuration::between_datetimes(a, b))
        }
        _ => LoraValue::Null,
    }
}

/// Whole days from `a` to `b`: `temporal.in_days` and Cypher's
/// `duration.inDays`.
fn in_days(args: &[LoraValue]) -> LoraValue {
    match (args.first(), args.get(1)) {
        (Some(LoraValue::Date(a)), Some(LoraValue::Date(b))) => {
            LoraValue::Duration(LoraDuration::in_days(a, b))
        }
        _ => duration_between(args, Between::Days),
    }
}