diff --git a/COMPAT.md b/COMPAT.md index 796c94620..7224075d6 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -24,6 +24,7 @@ This document describes the compatibility of Limbo with SQLite. - [UUID](#uuid) - [regexp](#regexp) - [Vector](#vector) + - [Time](#time) ## Features @@ -630,3 +631,56 @@ The `vector` extension is compatible with libSQL native vector search. | vector64(x) | Yes | | | vector_extract(x) | Yes | | | vector_distance_cos(x, y) | Yes | | + +### Time + +The `time` extension is compatible with [sqlean-time](https://github.com/nalgeon/sqlean/blob/main/docs/time.md). + + +| Function | Status | Comment | +| ------------------------------------------------------------------- | ------ | ---------------------------- | +| time_now() | Yes | | +| time_date(year, month, day[, hour, min, sec[, nsec[, offset_sec]]]) | Yes | offset_sec is not normalized | +| time_get_year(t) | Yes | | +| time_get_month(t) | Yes | | +| time_get_day(t) | Yes | | +| time_get_hour(t) | Yes | | +| time_get_minute(t) | Yes | | +| time_get_second(t) | Yes | | +| time_get_nano(t) | Yes | | +| time_get_weekday(t) | Yes | | +| time_get_yearday(t) | Yes | | +| time_get_isoyear(t) | Yes | | +| time_get_isoweek(t) | Yes | | +| time_get(t, field) | Yes | | +| time_unix(sec[, nsec]) | Yes | | +| time_milli(msec) | Yes | | +| time_micro(usec) | Yes | | +| time_nano(nsec) | Yes | | +| time_to_unix(t) | Yes | | +| time_to_milli(t) | Yes | | +| time_to_micro(t) | Yes | | +| time_to_nano(t) | Yes | | +| time_after(t, u) | Yes | | +| time_before(t, u) | Yes | | +| time_compare(t, u) | Yes | | +| time_equal(t, u) | Yes | | +| time_add(t, d) | Yes | | +| time_add_date(t, years[, months[, days]]) | Yes | | +| time_sub(t, u) | Yes | | +| time_since(t) | Yes | | +| time_until(t) | Yes | | +| time_trunc(t, field) | Yes | | +| time_trunc(t, d) | Yes | | +| time_round(t, d) | Yes | | +| time_fmt_iso(t[, offset_sec]) | Yes | | +| time_fmt_datetime(t[, offset_sec]) | Yes | | +| time_fmt_date(t[, offset_sec]) | Yes | | +| time_fmt_time(t[, offset_sec]) | Yes | | +| time_parse(s) | Yes | | +| dur_ns() | Yes | | +| dur_us() | Yes | | +| dur_ms() | Yes | | +| dur_s() | Yes | | +| dur_m() | Yes | | +| dur_h() | Yes | | diff --git a/Cargo.lock b/Cargo.lock index c87b395d2..63f0cc3ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1583,6 +1583,7 @@ dependencies = [ "limbo_macros", "limbo_percentile", "limbo_regexp", + "limbo_time", "limbo_uuid", "limbo_vector", "log", @@ -1678,6 +1679,18 @@ dependencies = [ "log", ] +[[package]] +name = "limbo_time" +version = "0.0.13" +dependencies = [ + "chrono", + "limbo_ext", + "mimalloc", + "strum", + "strum_macros", + "thiserror 2.0.11", +] + [[package]] name = "limbo_uuid" version = "0.0.14" diff --git a/Cargo.toml b/Cargo.toml index 82b79995d..85b8c46d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,8 @@ members = [ "sqlite3", "tests", "extensions/percentile", - "extensions/vector", + "extensions/vector", + "extensions/time", ] exclude = ["perf/latency/limbo"] diff --git a/Makefile b/Makefile index b66e80d76..e1fdf6d29 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,10 @@ test-vector: SQLITE_EXEC=$(SQLITE_EXEC) ./testing/vector.test .PHONY: test-vector +test-time: + SQLITE_EXEC=$(SQLITE_EXEC) ./testing/time.test +.PHONY: test-time + test-sqlite3: limbo-c LIBS="$(SQLITE_LIB)" HEADERS="$(SQLITE_LIB_HEADERS)" make -C sqlite3/tests test .PHONY: test-sqlite3 diff --git a/core/Cargo.toml b/core/Cargo.toml index d2172af34..de6dc7dfe 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,7 +14,7 @@ name = "limbo_core" path = "lib.rs" [features] -default = ["fs", "json", "uuid", "vector", "io_uring"] +default = ["fs", "json", "uuid", "vector", "io_uring", "time"] fs = [] json = [ "dep:jsonb", @@ -26,6 +26,7 @@ vector = ["limbo_vector/static"] io_uring = ["dep:io-uring", "rustix/io_uring"] percentile = ["limbo_percentile/static"] regexp = ["limbo_regexp/static"] +time = ["limbo_time/static"] [target.'cfg(target_os = "linux")'.dependencies] io-uring = { version = "0.6.1", optional = true } @@ -65,6 +66,7 @@ limbo_uuid = { path = "../extensions/uuid", optional = true, features = ["static limbo_vector = { path = "../extensions/vector", optional = true, features = ["static"] } limbo_regexp = { path = "../extensions/regexp", optional = true, features = ["static"] } limbo_percentile = { path = "../extensions/percentile", optional = true, features = ["static"] } +limbo_time = { path = "../extensions/time", optional = true, features = ["static"] } miette = "7.4.0" strum = "0.26" diff --git a/core/ext/mod.rs b/core/ext/mod.rs index c38a99f9c..8a9212556 100644 --- a/core/ext/mod.rs +++ b/core/ext/mod.rs @@ -92,6 +92,10 @@ impl Database { if unsafe { !limbo_regexp::register_extension_static(&ext_api).is_ok() } { return Err("Failed to register regexp extension".to_string()); } + #[cfg(feature = "time")] + if unsafe { !limbo_time::register_extension_static(&ext_api).is_ok() } { + return Err("Failed to register time extension".to_string()); + } Ok(()) } } diff --git a/extensions/time/Cargo.toml b/extensions/time/Cargo.toml new file mode 100644 index 000000000..6220ddffd --- /dev/null +++ b/extensions/time/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "limbo_time" +repository.workspace = true +version.workspace = true + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +static = ["limbo_ext/static"] + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +mimalloc = { version = "*", default-features = false } + +[dependencies] +chrono = "0.4.39" +limbo_ext = { path = "../core", features = ["static"] } +strum = "0.26.3" +strum_macros = "0.26.3" +thiserror = "2.0.11" diff --git a/extensions/time/src/lib.rs b/extensions/time/src/lib.rs new file mode 100644 index 000000000..5c4d5a383 --- /dev/null +++ b/extensions/time/src/lib.rs @@ -0,0 +1,1019 @@ +use std::str::FromStr as _; + +use chrono::prelude::*; +use core::cmp::Ordering; +use limbo_ext::ValueType; +use thiserror::Error; + +use limbo_ext::{register_extension, scalar, ResultCode, Value}; + +mod time; + +use time::*; + +register_extension! { + scalars: { + time_now, + time_date, + make_date, + make_timestamp, + time_get, + time_get_year, + time_get_month, + time_get_day, + time_get_hour, + time_get_minute, + time_get_second, + time_get_nano, + time_get_weekday, + time_get_yearday, + time_get_isoyear, + time_get_isoweek, + time_unix, + to_timestamp, + time_milli, + time_micro, + time_nano, + time_to_unix, + time_to_milli, + time_to_micro, + time_to_nano, + time_after, + time_before, + time_compare, + time_equal, + dur_ns, + dur_us, + dur_ms, + dur_s, + dur_m, + dur_h, + time_add, + time_add_date, + time_sub, + time_since, + time_until, + time_trunc, + time_round, + time_fmt_iso, + time_fmt_datetime, + time_fmt_date, + time_fmt_time, + time_parse, + }, +} + +macro_rules! ok_tri { + ($e:expr) => { + match $e { + Some(val) => val, + None => return Value::error(ResultCode::Error), + } + }; + ($e:expr, $msg:expr) => { + match $e { + Some(val) => val, + None => return Value::error_with_message($msg.to_string()), + } + }; +} + +macro_rules! tri { + ($e:expr) => { + match $e { + Ok(val) => val, + Err(err) => return Value::error_with_message(err.to_string()), + } + }; + ($e:expr, $msg:expr) => { + match $e { + Ok(val) => val, + Err(_) => return Value::error_with_message($msg.to_string()), + } + }; +} + +/// Checks to see if e's enum is of type val +macro_rules! value_tri { + ($e:expr, $val:pat) => { + match $e { + $val => (), + _ => return Value::error(ResultCode::InvalidArgs), + } + }; + ($e:expr, $val:pat, $msg:expr) => { + match $e { + $val => (), + _ => return Value::error_with_message($msg.to_string()), + } + }; +} + +#[derive(Error, Debug)] +pub enum TimeError { + /// Timezone offset is invalid + #[error("invalid timezone offset")] + InvalidOffset, + #[error("invalid datetime format")] + InvalidFormat, + /// Blob is not size of `TIME_BLOB_SIZE` + #[error("invalid time blob size")] + InvalidSize, + /// Blob time version not matching + #[error("mismatch time blob version")] + MismatchVersion, + #[error("unknown field")] + UnknownField(#[from] ::Err), + #[error("rounding error")] + RoundingError(#[from] chrono::RoundingError), + #[error("time creation error")] + CreationError, +} + +type Result = core::result::Result; + +#[scalar(name = "time_now", alias = "now")] +fn time_now(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + let t = Time::new(); + + t.into_blob() +} + +/// ```text +/// time_date(year, month, day[, hour, min, sec[, nsec[, offset_sec]]]) +/// ``` +/// +/// Returns the Time corresponding to a given date/time. The time part (hour+minute+second), the nanosecond part, and the timezone offset part are all optional. +/// +/// The `month`, `day`, `hour`, `min`, `sec`, and `nsec` values may be outside their usual ranges and will be normalized during the conversion. For example, October 32 converts to November 1. +/// +/// If `offset_sec` is not 0, the source time is treated as being in a given timezone (with an offset in seconds east of UTC) and converted back to UTC. +fn time_date_internal(args: &[Value]) -> Value { + if args.len() != 3 && args.len() != 6 && args.len() != 7 && args.len() != 8 { + return Value::error(ResultCode::InvalidArgs); + } + + for arg in args { + value_tri!( + arg.value_type(), + ValueType::Integer, + "all parameters should be integers" + ); + } + + let year = ok_tri!(args[0].to_integer()); + let month = ok_tri!(args[1].to_integer()); + let day = ok_tri!(args[2].to_integer()); + let mut hour = 0; + let mut minutes = 0; + let mut seconds = 0; + let mut nano_secs = 0; + let mut offset = FixedOffset::east_opt(0).unwrap(); + + if args.len() >= 6 { + hour = ok_tri!(args[3].to_integer()); + minutes = ok_tri!(args[4].to_integer()); + seconds = ok_tri!(args[5].to_integer()); + } + + if args.len() >= 7 { + nano_secs = ok_tri!(args[6].to_integer()); + } + + if args.len() == 8 { + let offset_sec = ok_tri!(args[7].to_integer()) as i32; + // TODO offset is not normalized. Maybe could just increase/decrease the number of seconds + // instead of relying in this offset + offset = ok_tri!(FixedOffset::east_opt(offset_sec)); + } + + let t = Time::time_date( + year as i32, + month as i32, + day, + hour, + minutes, + seconds, + nano_secs, + offset, + ); + + let t = tri!(t); + + t.into_blob() +} + +#[scalar(name = "time_date")] +fn time_date(args: &[Value]) { + time_date_internal(args) +} + +#[scalar(name = "make_date")] +fn make_date(args: &[Value]) -> Value { + if args.len() != 3 { + return Value::error(ResultCode::InvalidArgs); + } + + time_date_internal(args) +} + +#[scalar(name = "make_timestamp")] +fn make_timestamp(args: &[Value]) -> Value { + if args.len() != 6 { + return Value::error(ResultCode::InvalidArgs); + } + + time_date_internal(args) +} + +#[scalar(name = "time_get", alias = "date_part")] +fn time_get(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let field = ok_tri!(args[1].to_text(), "2nd parameter: should be a field name"); + + let field = tri!(TimeField::from_str(field)); + + t.time_get(field) +} + +#[scalar(name = "time_get_year")] +fn time_get_year(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::Year) +} + +#[scalar(name = "time_get_month")] +fn time_get_month(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::Month) +} + +#[scalar(name = "time_get_day")] +fn time_get_day(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::Day) +} + +#[scalar(name = "time_get_hour")] +fn time_get_hour(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::Hour) +} + +#[scalar(name = "time_get_minute")] +fn time_get_minute(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::Minute) +} + +#[scalar(name = "time_get_second")] +fn time_get_second(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(t.get_second()) +} + +#[scalar(name = "time_get_nano")] +fn time_get_nano(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(t.get_nanosecond()) +} + +#[scalar(name = "time_get_weekday")] +fn time_get_weekday(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::WeekDay) +} + +#[scalar(name = "time_get_yearday")] +fn time_get_yearday(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::YearDay) +} + +#[scalar(name = "time_get_isoyear")] +fn time_get_isoyear(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::IsoYear) +} + +#[scalar(name = "time_get_isoweek")] +fn time_get_isoweek(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + t.time_get(TimeField::IsoWeek) +} + +fn time_unix_internal(args: &[Value]) -> Value { + if args.len() != 1 && args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + for arg in args { + value_tri!( + arg.value_type(), + ValueType::Integer, + "all parameters should be integers" + ); + } + + let seconds = ok_tri!(args[0].to_integer()); + + let mut nano_sec = 0; + + if args.len() == 2 { + nano_sec = ok_tri!(args[1].to_integer()); + } + + let dt = ok_tri!(DateTime::from_timestamp(seconds, nano_sec as u32)); + + let t = Time::from_datetime(dt); + + t.into_blob() +} + +#[scalar(name = "time_unix")] +fn time_unix(args: &[Value]) -> Value { + time_unix_internal(args) +} + +#[scalar(name = "to_timestamp")] +fn to_timestamp(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + time_unix_internal(args) +} + +#[scalar(name = "time_milli")] +fn time_milli(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + value_tri!( + &args[0].value_type(), + ValueType::Integer, + "parameter should be an integer" + ); + + let millis = ok_tri!(args[0].to_integer()); + + let dt = ok_tri!(DateTime::from_timestamp_millis(millis)); + + let t = Time::from_datetime(dt); + + t.into_blob() +} + +#[scalar(name = "time_micro")] +fn time_micro(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + value_tri!( + &args[0].value_type(), + ValueType::Integer, + "parameter should be an integer" + ); + + let micros = ok_tri!(args[0].to_integer()); + + let dt = ok_tri!(DateTime::from_timestamp_micros(micros)); + + let t = Time::from_datetime(dt); + + t.into_blob() +} + +#[scalar(name = "time_nano")] +fn time_nano(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + value_tri!( + &args[0].value_type(), + ValueType::Integer, + "parameter should be an integer" + ); + + let nanos = ok_tri!(args[0].to_integer()); + + let dt = DateTime::from_timestamp_nanos(nanos); + + let t = Time::from_datetime(dt); + + t.into_blob() +} + +#[scalar(name = "time_to_unix")] +fn time_to_unix(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(t.to_unix()) +} + +#[scalar(name = "time_to_milli")] +fn time_to_milli(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(t.to_unix_milli()) +} + +#[scalar(name = "time_to_micro")] +fn time_to_micro(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(t.to_unix_micro()) +} + +#[scalar(name = "time_to_nano")] +fn time_to_nano(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + Value::from_integer(ok_tri!(t.to_unix_nano())) +} + +// Comparisons + +#[scalar(name = "time_after")] +fn time_after(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let blob = ok_tri!(args[1].to_blob(), "2nd parameter: should be a time blob"); + + let u = tri!(Time::try_from(blob)); + + Value::from_integer((t > u).into()) +} + +#[scalar(name = "time_before")] +fn time_before(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let blob = ok_tri!(args[1].to_blob(), "2nd parameter: should be a time blob"); + + let u = tri!(Time::try_from(blob)); + + Value::from_integer((t < u).into()) +} + +#[scalar(name = "time_compare")] +fn time_compare(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let blob = ok_tri!(args[1].to_blob(), "2nd parameter: should be a time blob"); + + let u = tri!(Time::try_from(blob)); + + let cmp = match ok_tri!(t.partial_cmp(&u)) { + Ordering::Less => -1, + Ordering::Greater => 1, + Ordering::Equal => 0, + }; + + Value::from_integer(cmp) +} + +#[scalar(name = "time_equal")] +fn time_equal(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let blob = ok_tri!(args[1].to_blob(), "2nd parameter: should be a time blob"); + + let u = tri!(Time::try_from(blob)); + + Value::from_integer(t.eq(&u).into()) +} + +// Duration Constants + +/// 1 nanosecond +#[scalar(name = "dur_ns")] +fn dur_ns(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::nanoseconds(1).num_nanoseconds().unwrap()) +} + +/// 1 microsecond +#[scalar(name = "dur_us")] +fn dur_us(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::microseconds(1).num_nanoseconds().unwrap()) +} + +/// 1 millisecond +#[scalar(name = "dur_ms")] +fn dur_ms(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::milliseconds(1).num_nanoseconds().unwrap()) +} + +/// 1 second +#[scalar(name = "dur_s")] +fn dur_s(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::seconds(1).num_nanoseconds().unwrap()) +} + +/// 1 minute +#[scalar(name = "dur_m")] +fn dur_m(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::minutes(1).num_nanoseconds().unwrap()) +} + +/// 1 hour +#[scalar(name = "dur_h")] +fn dur_h(args: &[Value]) -> Value { + if !args.is_empty() { + return Value::error(ResultCode::InvalidArgs); + } + + Value::from_integer(chrono::Duration::hours(1).num_nanoseconds().unwrap()) +} + +// Time Arithmetic + +/// Do not use `time_add` to add days, months or years. Use `time_add_date` instead. +#[scalar(name = "time_add", alias = "date_add")] +fn time_add(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + let t = tri!(Time::try_from(blob)); + + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + + let d = ok_tri!(args[1].to_integer()); + + let d = Duration::from(d); + + t.add_duration(d).into_blob() +} + +#[scalar(name = "time_add_date")] +fn time_add_date(args: &[Value]) -> Value { + if args.len() != 2 && args.len() != 3 && args.len() != 4 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + let t = tri!(Time::try_from(blob)); + + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + + let years = ok_tri!(args[1].to_integer()); + let mut months = 0; + let mut days = 0; + + if args.len() >= 3 { + value_tri!( + args[2].value_type(), + ValueType::Integer, + "3rd parameter: should be an integer" + ); + + months = ok_tri!(args[2].to_integer()); + } + + if args.len() == 4 { + value_tri!( + args[3].value_type(), + ValueType::Integer, + "4th parameter: should be an integer" + ); + + days = ok_tri!(args[3].to_integer()); + } + + let t: Time = tri!(t.time_add_date(years as i32, months as i32, days)); + + t.into_blob() +} + +/// Returns the duration between two time values t and u (in nanoseconds). +/// If the result exceeds the maximum (or minimum) value that can be stored in a Duration, +/// the maximum (or minimum) duration will be returned. +fn time_sub_internal(t: Time, u: Time) -> Value { + let cmp = ok_tri!(t.partial_cmp(&u)); + + let diff = t - u; + + let nano_secs = match diff.num_nanoseconds() { + Some(nano) => nano, + None => match cmp { + Ordering::Equal => ok_tri!(diff.num_nanoseconds()), + Ordering::Less => i64::MIN, + Ordering::Greater => i64::MAX, + }, + }; + + Value::from_integer(nano_secs) +} + +#[scalar(name = "time_sub", alias = "age")] +fn time_sub(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + let t = tri!(Time::try_from(blob)); + + let blob = ok_tri!(args[1].to_blob(), "2nd parameter: should be a time blob"); + let u = tri!(Time::try_from(blob)); + + time_sub_internal(t, u) +} + +#[scalar(name = "time_since")] +fn time_since(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let now = Time::new(); + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + let t = tri!(Time::try_from(blob)); + + time_sub_internal(now, t) +} + +#[scalar(name = "time_until")] +fn time_until(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let now = Time::new(); + + let blob = ok_tri!(args[0].to_blob(), "parameter should be a time blob"); + let t = tri!(Time::try_from(blob)); + + time_sub_internal(t, now) +} + +// Rouding + +#[scalar(name = "time_trunc", alias = "date_trunc")] +fn time_trunc(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + match args[1].value_type() { + ValueType::Text => { + let field = ok_tri!(args[1].to_text()); + + let field = tri!(TimeRoundField::from_str(field)); + + tri!(t.trunc_field(field)).into_blob() + } + ValueType::Integer => { + let duration = ok_tri!(args[1].to_integer()); + let duration = Duration::from(duration); + + tri!(t.trunc_duration(duration)).into_blob() + } + _ => Value::error_with_message("2nd parameter: should be a field name".to_string()), + } +} + +#[scalar(name = "time_round")] +fn time_round(args: &[Value]) -> Value { + if args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + + let duration = ok_tri!(args[1].to_integer()); + let duration = Duration::from(duration); + + tri!(t.round_duration(duration)).into_blob() +} + +// Formatting + +#[scalar(name = "time_fmt_iso")] +fn time_fmt_iso(args: &[Value]) -> Value { + if args.len() != 1 && args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let offset_sec = { + if args.len() == 2 { + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + ok_tri!(args[1].to_integer()) as i32 + } else { + 0 + } + }; + + let fmt_str = tri!(t.fmt_iso(offset_sec)); + + Value::from_text(fmt_str) +} + +#[scalar(name = "time_fmt_datetime")] +fn time_fmt_datetime(args: &[Value]) -> Value { + if args.len() != 1 && args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let offset_sec = { + if args.len() == 2 { + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + ok_tri!(args[1].to_integer()) as i32 + } else { + 0 + } + }; + + let fmt_str = tri!(t.fmt_datetime(offset_sec)); + + Value::from_text(fmt_str) +} + +#[scalar(name = "time_fmt_date")] +fn time_fmt_date(args: &[Value]) -> Value { + if args.len() != 1 && args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let offset_sec = { + if args.len() == 2 { + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + ok_tri!(args[1].to_integer()) as i32 + } else { + 0 + } + }; + + let fmt_str = tri!(t.fmt_date(offset_sec)); + + Value::from_text(fmt_str) +} + +#[scalar(name = "time_fmt_time")] +fn time_fmt_time(args: &[Value]) -> Value { + if args.len() != 1 && args.len() != 2 { + return Value::error(ResultCode::InvalidArgs); + } + let blob = ok_tri!(args[0].to_blob(), "1st parameter: should be a time blob"); + + let t = tri!(Time::try_from(blob)); + + let offset_sec = { + if args.len() == 2 { + value_tri!( + args[1].value_type(), + ValueType::Integer, + "2nd parameter: should be an integer" + ); + ok_tri!(args[1].to_integer()) as i32 + } else { + 0 + } + }; + + let fmt_str = tri!(t.fmt_time(offset_sec)); + + Value::from_text(fmt_str) +} + +#[scalar(name = "time_parse")] +fn time_parse(args: &[Value]) -> Value { + if args.len() != 1 { + return Value::error(ResultCode::InvalidArgs); + } + + let dt_str = ok_tri!(args[0].to_text()); + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(dt_str) { + return Time::from_datetime(dt.to_utc()).into_blob(); + } + + if let Ok(mut dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S") { + // Unwrap is safe here + dt = dt.with_nanosecond(0).unwrap(); + return Time::from_datetime(dt.and_utc()).into_blob(); + } + + if let Ok(date) = chrono::NaiveDate::parse_from_str(dt_str, "%Y-%m-%d") { + // Unwrap is safe here + + let dt = date + .and_hms_opt(0, 0, 0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + return Time::from_datetime(dt.and_utc()).into_blob(); + } + + let time = tri!( + chrono::NaiveTime::parse_from_str(dt_str, "%H:%M:%S"), + "error parsing datetime string" + ); + let dt = NaiveDateTime::new(NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), time) + .with_nanosecond(0) + .unwrap(); + + Time::from_datetime(dt.and_utc()).into_blob() +} diff --git a/extensions/time/src/time.rs b/extensions/time/src/time.rs new file mode 100644 index 000000000..238d599d5 --- /dev/null +++ b/extensions/time/src/time.rs @@ -0,0 +1,560 @@ +use std::ops::{Deref, Sub}; + +use chrono::{self, DateTime, Timelike, Utc}; +use chrono::{prelude::*, DurationRound}; + +use limbo_ext::Value; + +use crate::{Result, TimeError}; + +const DAYS_BEFORE_EPOCH: i64 = 719162; +const TIME_BLOB_SIZE: usize = 13; +const VERSION: u8 = 1; + +#[derive(Debug, PartialEq, PartialOrd, Eq)] +pub struct Time { + inner: DateTime, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd)] +pub struct Duration { + inner: chrono::Duration, +} + +#[derive(strum_macros::Display, strum_macros::EnumString)] +pub enum TimeField { + #[strum(to_string = "millennium")] + Millennium, + #[strum(to_string = "century")] + Century, + #[strum(to_string = "decade")] + Decade, + #[strum(to_string = "year")] + Year, + #[strum(to_string = "quarter")] + Quarter, + #[strum(to_string = "month")] + Month, + #[strum(to_string = "day")] + Day, + #[strum(to_string = "hour")] + Hour, + #[strum(to_string = "minute")] + Minute, + #[strum(to_string = "second")] + Second, + #[strum(to_string = "millisecond")] + MilliSecond, + #[strum(to_string = "milli")] + Milli, + #[strum(to_string = "microsecond")] + MicroSecond, + #[strum(to_string = "micro")] + Micro, + #[strum(to_string = "nanosecond")] + NanoSecond, + #[strum(to_string = "nano")] + Nano, + #[strum(to_string = "isoyear")] + IsoYear, + #[strum(to_string = "isoweek")] + IsoWeek, + #[strum(to_string = "isodow")] + IsoDow, + #[strum(to_string = "yearday")] + YearDay, + #[strum(to_string = "weekday")] + WeekDay, + #[strum(to_string = "epoch")] + Epoch, +} + +#[derive(strum_macros::Display, strum_macros::EnumString)] +pub enum TimeRoundField { + #[strum(to_string = "millennium")] + Millennium, + #[strum(to_string = "century")] + Century, + #[strum(to_string = "decade")] + Decade, + #[strum(to_string = "year")] + Year, + #[strum(to_string = "quarter")] + Quarter, + #[strum(to_string = "month")] + Month, + #[strum(to_string = "week")] + Week, + #[strum(to_string = "day")] + Day, + #[strum(to_string = "hour")] + Hour, + #[strum(to_string = "minute")] + Minute, + #[strum(to_string = "second")] + Second, + #[strum(to_string = "millisecond")] + MilliSecond, + #[strum(to_string = "milli")] + Milli, + #[strum(to_string = "microsecond")] + MicroSecond, + #[strum(to_string = "micro")] + Micro, +} + +impl Time { + /// Returns a new instance of Time with tracking UTC::now + pub fn new() -> Self { + Self { inner: Utc::now() } + } + + pub fn into_blob(self) -> Value { + let blob: [u8; 13] = self.into(); + Value::from_blob(blob.to_vec()) + } + + pub fn fmt_iso(&self, offset_sec: i32) -> Result { + if offset_sec == 0 { + if self.inner.nanosecond() == 0 { + return Ok(self.inner.format("%FT%TZ").to_string()); + } else { + return Ok(self.inner.format("%FT%T%.9fZ").to_string()); + } + } + // I do not see how this can error + let offset = &FixedOffset::east_opt(offset_sec).ok_or(TimeError::InvalidFormat)?; + + let timezone_date = self.inner.with_timezone(offset); + + if timezone_date.nanosecond() == 0 { + Ok(timezone_date.format("%FT%T%:z").to_string()) + } else { + Ok(timezone_date.format("%FT%T%.9f%:z").to_string()) + } + } + + pub fn fmt_datetime(&self, offset_sec: i32) -> Result { + let fmt = "%F %T"; + + if offset_sec == 0 { + return Ok(self.inner.format(fmt).to_string()); + } + // I do not see how this can error + let offset = &FixedOffset::east_opt(offset_sec).ok_or(TimeError::InvalidFormat)?; + + let timezone_date = self.inner.with_timezone(offset); + + Ok(timezone_date.format(fmt).to_string()) + } + + pub fn fmt_date(&self, offset_sec: i32) -> Result { + let fmt = "%F"; + + if offset_sec == 0 { + return Ok(self.inner.format(fmt).to_string()); + } + // I do not see how this can error + let offset = &FixedOffset::east_opt(offset_sec).ok_or(TimeError::InvalidFormat)?; + + let timezone_date = self.inner.with_timezone(offset); + + Ok(timezone_date.format(fmt).to_string()) + } + + pub fn fmt_time(&self, offset_sec: i32) -> Result { + let fmt = "%T"; + + if offset_sec == 0 { + return Ok(self.inner.format(fmt).to_string()); + } + // I do not see how this can error + let offset = &FixedOffset::east_opt(offset_sec).ok_or(TimeError::InvalidFormat)?; + + let timezone_date = self.inner.with_timezone(offset); + + Ok(timezone_date.format(fmt).to_string()) + } + + /// Adjust the datetime to the offset + pub fn from_datetime(dt: DateTime) -> Self { + Self { inner: dt } + } + + // + #[allow(clippy::too_many_arguments)] + pub fn time_date( + year: i32, + month: i32, + day: i64, + hour: i64, + minutes: i64, + seconds: i64, + nano_secs: i64, + offset: FixedOffset, + ) -> Result { + let mut dt: NaiveDateTime = NaiveDate::from_ymd_opt(1, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + + match year.cmp(&0) { + std::cmp::Ordering::Greater => { + dt = dt + .checked_add_months(chrono::Months::new((year - 1).unsigned_abs() * 12)) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Less => { + dt = dt + .checked_sub_months(chrono::Months::new((year - 1).unsigned_abs() * 12)) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Equal => (), + }; + + match month.cmp(&0) { + std::cmp::Ordering::Greater => { + dt = dt + .checked_add_months(chrono::Months::new((month - 1).unsigned_abs())) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Less => { + dt = dt + .checked_sub_months(chrono::Months::new((month - 1).unsigned_abs())) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Equal => (), + }; + + dt += chrono::Duration::try_days(day - 1).ok_or(TimeError::CreationError)?; + + dt += chrono::Duration::try_hours(hour).ok_or(TimeError::CreationError)?; + dt += chrono::Duration::try_minutes(minutes).ok_or(TimeError::CreationError)?; + dt += chrono::Duration::try_seconds(seconds).ok_or(TimeError::CreationError)?; + + dt += chrono::Duration::nanoseconds(nano_secs); + + dt = dt + .and_local_timezone(offset) + .single() + .ok_or(TimeError::CreationError)? + .naive_utc(); + + Ok(dt.into()) + } + + pub fn time_add_date(self, years: i32, months: i32, days: i64) -> Result { + let mut dt: NaiveDateTime = self.into(); + + match years.cmp(&0) { + std::cmp::Ordering::Greater => { + dt = dt + .checked_add_months(chrono::Months::new(years.unsigned_abs() * 12)) + .ok_or(TimeError::CreationError)?; + } + std::cmp::Ordering::Less => { + dt = dt + .checked_sub_months(chrono::Months::new(years.unsigned_abs() * 12)) + .ok_or(TimeError::CreationError)?; + } + std::cmp::Ordering::Equal => (), + }; + + match months.cmp(&0) { + std::cmp::Ordering::Greater => { + dt = dt + .checked_add_months(chrono::Months::new(months.unsigned_abs())) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Less => { + dt = dt + .checked_sub_months(chrono::Months::new(months.unsigned_abs())) + .ok_or(TimeError::CreationError)? + } + std::cmp::Ordering::Equal => (), + }; + + dt += chrono::Duration::try_days(days).ok_or(TimeError::CreationError)?; + + Ok(dt.into()) + } + + pub fn get_second(&self) -> i64 { + self.inner.second() as i64 + } + + pub fn get_nanosecond(&self) -> i64 { + self.inner.timestamp_subsec_nanos() as i64 + } + + pub fn to_unix(&self) -> i64 { + self.inner.timestamp() + } + + pub fn to_unix_milli(&self) -> i64 { + self.inner.timestamp_millis() + } + + pub fn to_unix_micro(&self) -> i64 { + self.inner.timestamp_micros() + } + + pub fn to_unix_nano(&self) -> Option { + self.inner.timestamp_nanos_opt() + } + + pub fn add_duration(&self, d: Duration) -> Self { + Self { + inner: self.inner + d.inner, + } + } + + pub fn sub_duration(&self, d: Duration) -> Self { + Self { + inner: self.inner - d.inner, + } + } + + pub fn trunc_duration(&self, d: Duration) -> Result { + Ok(Self { + inner: self.inner.duration_trunc(d.inner)?, + }) + } + + pub fn trunc_field(&self, field: TimeRoundField) -> Result { + use TimeRoundField::*; + + let year: i32; + let mut month: i32 = 1; + let mut week: i32 = 0; + let mut day: i64 = 1; + let mut hour: i64 = 0; + let mut minutes: i64 = 0; + let mut seconds: i64 = 0; + let mut nano_secs: i64 = 0; + let offset = FixedOffset::east_opt(0).unwrap(); // UTC + + match field { + Millennium => { + let millennium = (self.inner.year() / 1000) * 1000; + year = millennium; + } + Century => { + let century = (self.inner.year() / 100) * 100; + year = century; + } + Decade => { + let decade = (self.inner.year() / 10) * 10; + year = decade; + } + Year => { + year = self.inner.year(); + } + Quarter => { + let quarter = ((self.inner.month() - 1) / 3) as i32; + year = self.inner.year(); + month = (quarter * 3) + 1; + } + Month => { + year = self.inner.year(); + month = self.inner.month() as i32; + } + Week => { + let isoweek = self.inner.iso_week(); + year = isoweek.year(); + week = isoweek.week() as i32; + } + Day => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + } + Hour => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + hour = self.inner.hour() as i64; + } + Minute => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + hour = self.inner.hour() as i64; + minutes = self.inner.minute() as i64; + } + Second => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + hour = self.inner.hour() as i64; + minutes = self.inner.minute() as i64; + seconds = self.inner.second() as i64; + } + MilliSecond | Milli => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + hour = self.inner.hour() as i64; + minutes = self.inner.minute() as i64; + seconds = self.inner.second() as i64; + nano_secs = (self.inner.nanosecond() / 1_000_000 * 1_000_000) as i64; + } + MicroSecond | Micro => { + year = self.inner.year(); + month = self.inner.month() as i32; + day = self.inner.day() as i64; + hour = self.inner.hour() as i64; + minutes = self.inner.minute() as i64; + seconds = self.inner.second() as i64; + nano_secs = (self.inner.nanosecond() / 1_000 * 1_000) as i64; + } + }; + + let mut ret = Self::time_date(year, month, day, hour, minutes, seconds, nano_secs, offset)?; + + // Means we have to adjust for the week + if week != 0 { + ret = ret.time_add_date(0, 0, ((week - 1) * 7) as i64)?; + } + + Ok(ret) + } + + pub fn round_duration(&self, d: Duration) -> Result { + Ok(Self { + inner: self.inner.duration_round(d.inner)?, + }) + } + + pub fn time_get(&self, field: TimeField) -> Value { + use TimeField::*; + + match field { + Millennium => Value::from_integer((self.inner.year() / 1000) as i64), + Century => Value::from_integer((self.inner.year() / 100) as i64), + Decade => Value::from_integer((self.inner.year() / 10) as i64), + Year => Value::from_integer(self.inner.year() as i64), + Quarter => Value::from_integer(self.inner.month().div_ceil(3) as i64), + Month => Value::from_integer(self.inner.month() as i64), + Day => Value::from_integer(self.inner.day() as i64), + Hour => Value::from_integer(self.inner.hour() as i64), + Minute => Value::from_integer(self.inner.minute() as i64), + Second => Value::from_float( + self.inner.second() as f64 + (self.inner.nanosecond() as f64) / (1_000_000_000_f64), + ), + MilliSecond | Milli => { + Value::from_integer((self.inner.nanosecond() / 1_000_000 % 1_000) as i64) + } + MicroSecond | Micro => { + Value::from_integer((self.inner.nanosecond() / 1_000 % 1_000_000) as i64) + } + NanoSecond | Nano => { + Value::from_integer((self.inner.nanosecond() % 1_000_000_000) as i64) + } + IsoYear => Value::from_integer(self.inner.iso_week().year() as i64), + IsoWeek => Value::from_integer(self.inner.iso_week().week() as i64), + IsoDow => Value::from_integer(self.inner.weekday().days_since(Weekday::Sun) as i64), + YearDay => Value::from_integer(self.inner.ordinal() as i64), + WeekDay => Value::from_integer(self.inner.weekday().num_days_from_sunday() as i64), + Epoch => Value::from_float( + self.inner.timestamp() as f64 + self.inner.nanosecond() as f64 / 1_000_000_000_f64, + ), + } + } +} + +impl From