blob: 50486c9275503dea632dad20566f96beb1aa828d [file]
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use arrow::{
array::{Int64Array, StringArray},
datatypes::DataType,
};
use chrono::{DateTime, FixedOffset, LocalResult, NaiveDateTime, Offset, TimeZone, Utc};
use chrono_tz::{OffsetComponents, Tz};
use datafusion::{
common::{DataFusionError, Result, ScalarValue},
physical_plan::ColumnarValue,
};
use datafusion_ext_commons::arrow::cast::cast;
/// Native implementation of Flink SQL `UNIX_TIMESTAMP(value, format)`: parse a
/// formatted date-time string to a Unix timestamp in seconds, replicating
/// `java.text.SimpleDateFormat` lenient semantics.
///
/// Arguments are always `[value, chronoFormat, zoneId]` (arity 3). `value` is
/// the column of strings to parse; `chronoFormat` and `zoneId` are literal
/// scalars bound at plan time. The format is a `%`-specifier string built by
/// the JVM-side converter from `%Y %m %d %H %M %S` plus literal characters
/// (`%%` for a literal percent) — it is not the original Java pattern.
///
/// Arity 3 is the normalized form of Flink's two string-parsing arities: the
/// JVM-side converter supplies Flink's default `yyyy-MM-dd HH:mm:ss` for the
/// single-argument call, translates the literal pattern for the two-argument
/// call, and resolves the session time zone at plan time. A non-literal or
/// untranslatable format is rejected there and evaluated by Flink instead, so
/// every call reaching this function carries all three arguments already bound.
///
/// Flink's no-argument `UNIX_TIMESTAMP()` parses nothing and yields the current
/// wall-clock time. It is served by [`flink_unix_timestamp_now`], which shares
/// none of the parsing contract below.
///
/// An unparseable value yields `i64::MIN` (Flink's `Long.MIN_VALUE`), never
/// NULL and never an error; a NULL value yields NULL. Arity, format and
/// timezone problems are hard errors rather than silent defaults, so a plumbing
/// bug cannot surface as silently wrong data.
pub fn flink_unix_timestamp(args: &[ColumnarValue]) -> Result<ColumnarValue> {
if args.len() != 3 {
return Err(DataFusionError::Execution(format!(
"Flink_UnixTimestamp requires 3 arguments [value, chronoFormat, zoneId], got {}",
args.len()
)));
}
let format = utf8_scalar(&args[1]).ok_or_else(|| {
DataFusionError::Execution("Flink_UnixTimestamp: format must be a non-null string".into())
})?;
let zone_id = utf8_scalar(&args[2]).ok_or_else(|| {
DataFusionError::Execution("Flink_UnixTimestamp: zoneId must be a non-null string".into())
})?;
let zone = parse_zone_spec(&zone_id)?;
let tokens = parse_format(&format)?;
let num_rows = match &args[0] {
ColumnarValue::Array(array) => array.len(),
ColumnarValue::Scalar(_) => 1,
};
let value = cast(&args[0].clone().into_array(num_rows)?, &DataType::Utf8)?;
let value = value
.as_any()
.downcast_ref::<StringArray>()
.expect("internal cast to Utf8 must succeed");
let result = Int64Array::from_iter(
value
.iter()
.map(|opt_s| opt_s.map(|s| parse_datetime(s, &tokens, &zone).unwrap_or(i64::MIN))),
);
Ok(ColumnarValue::Array(Arc::new(result)))
}
/// Native implementation of Flink SQL `UNIX_TIMESTAMP()`: the current Unix
/// timestamp in seconds.
///
/// Takes no arguments. The result is a `ColumnarValue::Scalar`, which the
/// projection broadcasts to the width of the batch being evaluated, so no
/// operand is needed to size the output. The clock is therefore read once per
/// call site per batch, and every row of that batch carries the same second.
///
/// A non-empty argument list is a hard error rather than an ignored operand,
/// on the same grounds as the arity check in [`flink_unix_timestamp`]: nothing
/// enforces arity before this point, so a plumbing bug is only visible here.
///
/// The clock is read as `timestamp_millis() / 1000` so the truncation matches
/// Flink's `System.currentTimeMillis() / 1000` exactly.
pub fn flink_unix_timestamp_now(args: &[ColumnarValue]) -> Result<ColumnarValue> {
if !args.is_empty() {
return Err(DataFusionError::Execution(format!(
"Flink_UnixTimestampNow takes no arguments, got {}",
args.len()
)));
}
let secs = Utc::now().timestamp_millis() / 1000;
Ok(ColumnarValue::Scalar(ScalarValue::Int64(Some(secs))))
}
fn utf8_scalar(arg: &ColumnarValue) -> Option<String> {
match arg {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s)))
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(s))) => Some(s.clone()),
_ => None,
}
}
/// Resolve the session time zone id to a zone specification.
///
/// The id arrives already normalized by `ZoneId.getId()`, so the accepted forms
/// are an IANA region id, `GMT±HH:MM`, or a bare `±HH:MM`. Anything else is an
/// error rather than a default: the plan-time gate admits only what this
/// function resolves, so a disagreement between the two is a plumbing bug, and
/// returning an error keeps it from surfacing as silently wrong data.
fn parse_zone_spec(zone_id: &str) -> Result<ZoneSpec> {
if let Ok(tz) = zone_id.parse::<Tz>() {
return Ok(ZoneSpec::Iana(tz));
}
if let Some(secs) = parse_fixed_offset_secs(zone_id.strip_prefix("GMT").unwrap_or(zone_id))
&& let Some(offset) = FixedOffset::east_opt(secs)
{
return Ok(ZoneSpec::Fixed(offset));
}
Err(DataFusionError::Execution(format!(
"Flink_UnixTimestamp: invalid timezone {zone_id}"
)))
}
/// Parse exactly `±HH:MM`, rejecting any trailing input so that a
/// second-precision id cannot silently lose its seconds field.
fn parse_fixed_offset_secs(s: &str) -> Option<i32> {
let b = s.as_bytes();
if b.len() != 6 || b[3] != b':' {
return None;
}
let sign = match b[0] {
b'+' => 1,
b'-' => -1,
_ => return None,
};
if !b[1..3].iter().chain(&b[4..6]).all(u8::is_ascii_digit) {
return None;
}
let hours = i32::from(b[1] - b'0') * 10 + i32::from(b[2] - b'0');
let minutes = i32::from(b[4] - b'0') * 10 + i32::from(b[5] - b'0');
if minutes > 59 {
return None;
}
let secs = sign * (hours * 3600 + minutes * 60);
if secs.abs() > 18 * 3600 {
return None;
}
Some(secs)
}
/// A field is one of the six numeric SimpleDateFormat components; every
/// supported specifier maps to exactly one. `width` is the `obeyCount` scan
/// window used when the field is immediately followed by another numeric field.
#[derive(Clone, Copy)]
enum FieldKind {
Year,
Month,
Day,
Hour,
Minute,
Second,
}
impl FieldKind {
/// The `obeyCount` window width. Run length is erased by the Java→chrono
/// translation (both `M` and `MM` become `%m`), so a canonical width per
/// field is used: 4 for the year, 2 for the rest — matching the zero-padded
/// widths of Flink's default `yyyy-MM-dd HH:mm:ss`.
fn width(self) -> usize {
match self {
FieldKind::Year => 4,
_ => 2,
}
}
}
enum Token {
Field(FieldKind),
Literal(u8),
}
/// Tokenize the `%`-specifier format. Errors on an unknown specifier or a
/// dangling `%`, since the format is a plan-time constant and such a format is
/// a wiring bug, not bad row data.
fn parse_format(format: &str) -> Result<Vec<Token>> {
let bytes = format.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let spec = bytes.get(i + 1).ok_or_else(|| {
DataFusionError::Execution("Flink_UnixTimestamp: dangling '%' in format".into())
})?;
let token = match spec {
b'Y' => Token::Field(FieldKind::Year),
b'm' => Token::Field(FieldKind::Month),
b'd' => Token::Field(FieldKind::Day),
b'H' => Token::Field(FieldKind::Hour),
b'M' => Token::Field(FieldKind::Minute),
b'S' => Token::Field(FieldKind::Second),
b'%' => Token::Literal(b'%'),
other => {
return Err(DataFusionError::Execution(format!(
"Flink_UnixTimestamp: unsupported format specifier %{}",
*other as char
)));
}
};
tokens.push(token);
i += 2;
} else {
tokens.push(Token::Literal(bytes[i]));
i += 1;
}
}
Ok(tokens)
}
struct Fields {
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: i32,
}
impl Default for Fields {
fn default() -> Self {
Fields {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
}
}
}
impl Fields {
fn set(&mut self, kind: FieldKind, value: i32) {
match kind {
FieldKind::Year => self.year = value,
FieldKind::Month => self.month = value,
FieldKind::Day => self.day = value,
FieldKind::Hour => self.hour = value,
FieldKind::Minute => self.minute = value,
FieldKind::Second => self.second = value,
}
}
}
/// Walk the tokens over `input`. Returns the Unix timestamp in seconds, or
/// `None` on any parse failure (mapped to `i64::MIN` by the caller).
///
/// An empty token list consumes no input and is a failure, not a match on the
/// epoch defaults: `DateFormat.parse` throws whenever parsing advances the
/// position by zero characters, so Flink yields `Long.MIN_VALUE` for an empty
/// format. Every other token consumes at least one byte — a literal matches
/// one, a field needs at least one digit — so an empty token list is the only
/// way to finish having consumed nothing.
fn parse_datetime(input: &str, tokens: &[Token], zone: &ZoneSpec) -> Option<i64> {
if tokens.is_empty() {
return None;
}
let bytes = input.as_bytes();
let mut pos = 0usize;
let mut fields = Fields::default();
for (idx, token) in tokens.iter().enumerate() {
match token {
Token::Literal(lit) => {
if bytes.get(pos) != Some(lit) {
return None;
}
pos += 1;
}
Token::Field(kind) => {
// Skip ' ' and '\t' before the field, but anchor the obeyCount
// window at the pre-skip position so leading whitespace eats into
// the field's digit budget (a genuine SimpleDateFormat quirk).
let start0 = pos;
while matches!(bytes.get(pos), Some(b' ') | Some(b'\t')) {
pos += 1;
}
if pos >= bytes.len() {
return None;
}
// obeyCount holds when the next token is another numeric field;
// then the scan is bounded to `width` chars, otherwise it is greedy.
let obey_count = matches!(tokens.get(idx + 1), Some(Token::Field(_)));
let window_end = if obey_count {
let end = start0 + kind.width();
if end > bytes.len() {
return None;
}
end
} else {
bytes.len()
};
let (value, next) = scan_number(bytes, pos, window_end)?;
fields.set(*kind, value);
pos = next;
}
}
}
let local_sec = normalize(&fields);
let offset = zone.offset_secs(local_sec);
Some(local_sec - offset)
}
/// Scan an optional leading `-` then one or more ASCII digits within `[start,
/// end)`. A `+` is not a sign and terminates the scan before any digit. Digits
/// accumulate with wrapping and narrow to `i32` via low-32-bit truncation,
/// mirroring Java's `Number.intValue()`. Returns `None` when no digit is
/// consumed.
fn scan_number(bytes: &[u8], start: usize, end: usize) -> Option<(i32, usize)> {
let mut pos = start;
let negative = bytes.get(pos) == Some(&b'-');
if negative {
pos += 1;
}
let digits_start = pos;
let mut magnitude: i64 = 0;
while pos < end {
let b = bytes[pos];
if !b.is_ascii_digit() {
break;
}
magnitude = magnitude.wrapping_mul(10).wrapping_add((b - b'0') as i64);
pos += 1;
}
if pos == digits_start {
return None;
}
let signed = if negative {
magnitude.wrapping_neg()
} else {
magnitude
};
Some((signed as i32, pos))
}
/// Normalize the (possibly out-of-range, possibly negative) fields into a local
/// wall-clock time in seconds since the Unix epoch. Rollover falls out of a
/// single month-then-day computation, so hour/minute/second overflow needs no
/// special case. Dates before the 1582-10-15 Gregorian cutover use the Julian
/// calendar, matching `GregorianCalendar`'s hybrid behavior (chrono is
/// proleptic Gregorian).
fn normalize(fields: &Fields) -> i64 {
let year = fields.year as i64;
let month = fields.month as i64;
let day = fields.day as i64;
let hour = fields.hour as i64;
let minute = fields.minute as i64;
let second = fields.second as i64;
let year_adj = year + (month - 1).div_euclid(12);
let month_idx = (month - 1).rem_euclid(12) + 1;
let jdn_gregorian = gregorian_jdn(year_adj, month_idx) + (day - 1);
let jdn = if jdn_gregorian >= 2_299_161 {
jdn_gregorian
} else {
julian_jdn(year_adj, month_idx) + (day - 1)
};
let epoch_day = jdn - 2_440_588;
epoch_day * 86_400 + hour * 3_600 + minute * 60 + second
}
/// Julian Day Number of the first of month `(year, month)` in the proleptic
/// Gregorian calendar.
fn gregorian_jdn(year: i64, month: i64) -> i64 {
let a = (14 - month).div_euclid(12);
let y = year + 4800 - a;
let m = month + 12 * a - 3;
1 + (153 * m + 2).div_euclid(5) + 365 * y + y.div_euclid(4) - y.div_euclid(100)
+ y.div_euclid(400)
- 32045
}
/// Julian Day Number of the first of month `(year, month)` in the Julian
/// calendar.
fn julian_jdn(year: i64, month: i64) -> i64 {
let a = (14 - month).div_euclid(12);
let y = year + 4800 - a;
let m = month + 12 * a - 3;
1 + (153 * m + 2).div_euclid(5) + 365 * y + y.div_euclid(4) - 32083
}
/// UTC offset in seconds for a local wall-clock instant. When the local time is
/// ambiguous (fall-back overlap) or nonexistent (spring-forward gap), the
/// zone's standard (non-DST) offset is used, matching Flink.
/// Out-of-representable-range inputs (only reachable from far-future garbage)
/// fall back to UTC.
fn resolve_offset_secs(local_sec: i64, tz: Tz) -> i64 {
let naive: NaiveDateTime = match DateTime::from_timestamp(local_sec, 0) {
Some(dt) => dt.naive_utc(),
None => return 0,
};
match tz.offset_from_local_datetime(&naive) {
LocalResult::Single(offset) => offset.fix().local_minus_utc() as i64,
LocalResult::Ambiguous(offset, _) => offset.base_utc_offset().num_seconds(),
LocalResult::None => tz
.offset_from_utc_datetime(&naive)
.base_utc_offset()
.num_seconds(),
}
}
/// Session time zone as resolved at plan time: an IANA region from the time
/// zone database, or a constant UTC offset.
#[derive(Clone, Copy)]
enum ZoneSpec {
Iana(Tz),
Fixed(FixedOffset),
}
impl ZoneSpec {
/// UTC offset in seconds for a local wall-clock instant. A fixed offset is
/// constant by construction, so it ignores the instant entirely.
fn offset_secs(&self, local_sec: i64) -> i64 {
match self {
ZoneSpec::Iana(tz) => resolve_offset_secs(local_sec, *tz),
ZoneSpec::Fixed(offset) => offset.local_minus_utc() as i64,
}
}
}
#[cfg(test)]
mod tests {
use arrow::array::Array;
use super::*;
/// Run one `(value, chrono_format, timezone) -> expected_seconds` case
/// through the full function and assert the single-row Int64 result.
fn run(value: &str, format: &str, tz: &str) -> i64 {
let args = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some(value.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(format.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(tz.to_string()))),
];
let out = flink_unix_timestamp(&args)
.expect("flink_unix_timestamp must not error on valid scalar args")
.into_array(1)
.expect("result must materialize to an array");
let out = out
.as_any()
.downcast_ref::<Int64Array>()
.expect("result must be Int64Array");
assert_eq!(out.len(), 1);
out.value(0)
}
/// Whether the zone id is rejected outright. A rejected zone is an error
/// from the function itself, distinct from the `i64::MIN` sentinel that
/// a row-level parse failure produces.
fn zone_rejected(tz: &str) -> bool {
let args = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("2020-10-10 00:00:01".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DEFAULT.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(tz.to_string()))),
];
flink_unix_timestamp(&args).is_err()
}
const MIN: i64 = i64::MIN;
const DEFAULT: &str = "%Y-%m-%d %H:%M:%S";
// Happy path across formats and timezones.
#[test]
fn oracle_happy_path() {
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "UTC"), 1602288001);
assert_eq!(
run("2020-10-10 00:00:01", DEFAULT, "Asia/Shanghai"),
1602259201
);
assert_eq!(
run("2015-07-24 10:00:00", DEFAULT, "Asia/Tokyo"),
1437699600
);
assert_eq!(run("20201010000001", "%Y%m%d%H%M%S", "UTC"), 1602288001);
assert_eq!(run("2020-10-10", "%Y-%m-%d", "UTC"), 1602288000);
}
// Non-padded fields (1-digit values under 2-digit specifiers) — the core
// leniency requirement.
#[test]
fn oracle_non_padded() {
assert_eq!(run("2020-1-1 0:0:0", DEFAULT, "UTC"), 1577836800);
assert_eq!(run("2020-01-01 00:00:00", DEFAULT, "UTC"), 1577836800);
}
// Greedy scan (field bounded only by the next literal) and obeyCount windows
// (field bounded to its canonical width when followed by another numeric
// field).
#[test]
fn oracle_greedy_and_obey_count() {
assert_eq!(run("2020-115-01 00:00:00", DEFAULT, "UTC"), 1877558400);
assert_eq!(run("12020-01-01 00:00:00", DEFAULT, "UTC"), 317147356800);
assert_eq!(run("2020-0000001-01 00:00:00", DEFAULT, "UTC"), 1577836800);
assert_eq!(run("2020101000000", "%Y%m%d%H%M%S", "UTC"), 1602288000);
assert_eq!(run("202010100000012", "%Y%m%d%H%M%S", "UTC"), 1602288012);
}
// Field rollover across month/day/hour/minute/second, including the
// normalization order that rolls month before day.
#[test]
fn oracle_rollover() {
assert_eq!(run("2020-13-01 00:00:00", DEFAULT, "UTC"), 1609459200);
assert_eq!(run("2020-00-01 00:00:00", DEFAULT, "UTC"), 1575158400);
assert_eq!(run("2020-99-01 00:00:00", DEFAULT, "UTC"), 1835481600);
assert_eq!(run("2020-01-45 00:00:00", DEFAULT, "UTC"), 1581638400);
assert_eq!(run("2020-01-00 00:00:00", DEFAULT, "UTC"), 1577750400);
assert_eq!(run("2020-02-30 00:00:00", DEFAULT, "UTC"), 1583020800);
assert_eq!(run("2021-02-30 00:00:00", DEFAULT, "UTC"), 1614643200);
assert_eq!(run("2020-01-01 24:00:00", DEFAULT, "UTC"), 1577923200);
assert_eq!(run("2020-01-01 25:00:00", DEFAULT, "UTC"), 1577926800);
assert_eq!(run("2020-01-01 00:60:00", DEFAULT, "UTC"), 1577840400);
assert_eq!(run("2020-01-01 00:00:60", DEFAULT, "UTC"), 1577836860);
assert_eq!(run("2020-01-01 00:00:99", DEFAULT, "UTC"), 1577836899);
assert_eq!(run("2020-13-32 00:00:00", DEFAULT, "UTC"), 1612137600);
assert_eq!(run("2020-25-32 00:00:00", DEFAULT, "UTC"), 1643673600);
assert_eq!(run("2020-13-32 25:61:61", DEFAULT, "UTC"), 1612231321);
}
// Negative fields: a leading '-' is a valid sign (negative month, negative day,
// BC year through the Julian branch).
#[test]
fn oracle_negative_fields() {
assert_eq!(run("2020--5-10 00:00:01", DEFAULT, "UTC"), 1562716801);
assert_eq!(run("2020-10--5 00:00:01", DEFAULT, "UTC"), 1600992001);
assert_eq!(run("-2020-10-10 00:00:01", DEFAULT, "UTC"), -125889292799);
}
// Trailing input is ignored; leading/inner whitespace is skipped before a field
// (and eats the obeyCount budget) but never before a literal.
#[test]
fn oracle_whitespace_and_trailing() {
assert_eq!(run("2020-10-10 00:00:01XYZ", DEFAULT, "UTC"), 1602288001);
assert_eq!(run("2020-10-10 00:00:01 ", DEFAULT, "UTC"), 1602288001);
assert_eq!(run("2020-10-10 00:00:01", "%Y-%m-%d", "UTC"), 1602288000);
assert_eq!(run(" 2020-10-10 00:00:01", DEFAULT, "UTC"), 1602288001);
assert_eq!(run("\t2020-10-10 00:00:01", DEFAULT, "UTC"), 1602288001);
assert_eq!(run("X2020-10-10 00:00:01", DEFAULT, "UTC"), MIN);
assert_eq!(run("2020- 10-10 00:00:01", DEFAULT, "UTC"), 1602288001);
assert_eq!(run("2020 -10-10 00:00:01", DEFAULT, "UTC"), MIN);
assert_eq!(run(" 20200101", "%Y%m%d", "UTC"), -55786752000);
}
// Parse failures resolve to i64::MIN, never NULL and never an error: literal
// mismatch, short input, non-digit field, grouping separator, and a '+' sign.
#[test]
fn oracle_failures() {
assert_eq!(run("not a date", DEFAULT, "UTC"), MIN);
assert_eq!(run("", DEFAULT, "UTC"), MIN);
assert_eq!(run(" ", DEFAULT, "UTC"), MIN);
assert_eq!(run("2020-10-10", DEFAULT, "UTC"), MIN);
assert_eq!(run("2020/10/10 00:00:01", DEFAULT, "UTC"), MIN);
assert_eq!(run("2020-XX-10 00:00:01", DEFAULT, "UTC"), MIN);
assert_eq!(run("2,020-01-01 00:00:01", DEFAULT, "UTC"), MIN);
assert_eq!(run("+2020-10-10 00:00:01", DEFAULT, "UTC"), MIN);
}
// Epoch and pre-1970 negatives.
//
// Sub-second inputs (e.g. `... .SSS` with millisecond truncation) are not
// tested here: the supported specifiers carry no sub-second precision, and
// a format containing `S` is rejected by the upstream scanner, so such
// inputs fall back to Flink and never reach this function.
#[test]
fn oracle_epoch() {
assert_eq!(run("1970-01-01 00:00:00", DEFAULT, "UTC"), 0);
assert_eq!(run("1969-12-31 23:59:59", DEFAULT, "UTC"), -1);
assert_eq!(run("1900-01-01 00:00:00", DEFAULT, "UTC"), -2208988800);
}
// DST gap and overlap across several zones — the zone's standard (non-DST)
// offset wins, so a spring-forward gap collapses to the post-transition
// instant.
#[test]
fn oracle_dst() {
assert_eq!(run("2005-03-27 02:00:00", DEFAULT, "MET"), 1111885200);
assert_eq!(run("2005-03-27 03:00:00", DEFAULT, "MET"), 1111885200);
assert_eq!(
run("2021-03-14 02:30:00", DEFAULT, "America/New_York"),
1615707000
);
assert_eq!(run("2005-10-30 02:30:00", DEFAULT, "MET"), 1130635800);
assert_eq!(
run("2021-11-07 01:30:00", DEFAULT, "America/New_York"),
1636266600
);
assert_eq!(
run("2021-04-04 02:30:00", DEFAULT, "Australia/Sydney"),
1617467400
);
assert_eq!(
run("2021-10-03 02:30:00", DEFAULT, "Australia/Sydney"),
1633192200
);
}
// Every `GMT±HH:MM` zone applies the constant offset `sign × (hh·3600 +
// mm·60)`, across the whole family. The expected offset is recomputed from
// the loop indices that built the id, never re-parsed from the id string,
// so the assertion cannot degenerate into re-running the code under test.
//
// `GMT±00:00` is excluded because it normalizes to plain `GMT` and so never
// reaches this function in that spelling; anything past `±18:00` is not a zone
// id at all. The counter guards against a skip condition that silently empties
// the loop.
#[test]
fn oracle_fixed_offset_family_exhaustive() {
const VALUE: &str = "2020-10-10 00:00:01";
let local_sec = run(VALUE, DEFAULT, "UTC");
let mut covered = 0;
for sign in [1i64, -1i64] {
for hh in 0..=18i64 {
for mm in 0..=59i64 {
let magnitude = hh * 3600 + mm * 60;
if magnitude == 0 || magnitude > 18 * 3600 {
continue;
}
let sign_char = if sign > 0 { '+' } else { '-' };
let id = format!("GMT{sign_char}{hh:02}:{mm:02}");
let expected_offset = sign * magnitude;
assert_eq!(
run(VALUE, DEFAULT, &id),
local_sec - expected_offset,
"zone {id}"
);
covered += 1;
}
}
}
assert_eq!(covered, 2160);
}
// Spot values for the fixed-offset family, taken from the Java oracle rather
// than from the closed form, so the closed form itself is falsifiable: the
// exhaustive sweep above recomputes the same arithmetic and therefore cannot
// detect a sign or unit error shared by both sides.
//
// Covers plain `GMT` (which resolves as a region, not an offset), the smallest
// and largest offsets in both directions, a non-quarter-hour offset, a
// day-crossing at the epoch, a pre-1900 date, and the 1582 hybrid-calendar
// boundary.
//
// The last three rows are the bare `±HH:MM` spelling, and are the complete
// reachable set of it: the session zone is only unvalidated when it comes from
// `ZoneId.systemDefault()`, where `TZ=EST`/`MST`/`HST` map through
// `ZoneId.SHORT_IDS` to exactly these three. They assert the resolved offset
// rather than merely the absence of an error, so a resolver that accepted the
// bare form but mapped it to UTC would still fail here.
#[test]
fn oracle_fixed_offset() {
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT"), 1602288001);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT+00:01"), 1602287941);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT-00:01"), 1602288061);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT+18:00"), 1602223201);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT-18:00"), 1602352801);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT+05:07"), 1602269581);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT+05:45"), 1602267301);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT+08:00"), 1602259201);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "GMT-08:00"), 1602316801);
assert_eq!(run("1970-01-01 00:00:00", DEFAULT, "GMT+08:00"), -28800);
assert_eq!(
run("1900-01-01 00:00:00", DEFAULT, "GMT-08:00"),
-2208960000
);
assert_eq!(
run("1582-10-15 00:00:00", DEFAULT, "GMT-08:00"),
-12219264000
);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "-05:00"), 1602306001);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "-07:00"), 1602313201);
assert_eq!(run("2020-10-10 00:00:01", DEFAULT, "-10:00"), 1602324001);
}
// Literals from quoted patterns: a case-sensitive letter, a literal percent
// (`%%`), and a literal single quote.
#[test]
fn oracle_quoting() {
assert_eq!(
run("2020-10-10T00:00:01", "%Y-%m-%dT%H:%M:%S", "UTC"),
1602288001
);
assert_eq!(run("2020-10-10t00:00:01", "%Y-%m-%dT%H:%M:%S", "UTC"), MIN);
assert_eq!(run("2020%10", "%Y%%%m", "UTC"), 1601510400);
assert_eq!(run("2020'10", "%Y'%m", "UTC"), 1601510400);
}
// Julian/Gregorian hybrid calendar around the 1582-10-15 cutover: the last
// Julian day, the first Gregorian day, a nonexistent day mapped forward, and a
// deep-Julian date.
#[test]
fn oracle_hybrid_calendar() {
assert_eq!(run("1582-10-15 00:00:00", DEFAULT, "UTC"), -12219292800);
assert_eq!(run("1582-10-04 00:00:00", DEFAULT, "UTC"), -12219379200);
assert_eq!(run("1582-10-05 00:00:00", DEFAULT, "UTC"), -12219292800);
assert_eq!(run("1000-01-01 00:00:00", DEFAULT, "UTC"), -30609792000);
}
// A NULL value yields NULL output, distinct from the i64::MIN parse-failure
// sentinel.
#[test]
fn null_value_yields_null() {
let args = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(None)),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DEFAULT.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("UTC".to_string()))),
];
let out = flink_unix_timestamp(&args)
.expect("must not error")
.into_array(1)
.expect("must materialize");
let out = out
.as_any()
.downcast_ref::<Int64Array>()
.expect("result must be Int64Array");
assert_eq!(out.len(), 1);
assert!(out.is_null(0));
}
// Mixed array: NULL propagates per-row, valid rows parse, garbage → i64::MIN.
#[test]
fn array_with_null_and_garbage() {
let value = Arc::new(StringArray::from(vec![
Some("2020-10-10 00:00:01"),
None,
Some("garbage"),
]));
let args = vec![
ColumnarValue::Array(value),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DEFAULT.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("UTC".to_string()))),
];
let out = flink_unix_timestamp(&args)
.expect("must not error")
.into_array(3)
.expect("must materialize");
let out = out
.as_any()
.downcast_ref::<Int64Array>()
.expect("result must be Int64Array");
assert_eq!(out.value(0), 1602288001);
assert!(out.is_null(1));
assert_eq!(out.value(2), i64::MIN);
}
// In numeric adjacency, `%m` scans its canonical 2-digit obeyCount window: on
// `20201010` with `%Y%m%d`, `%Y` reads 4 (2020), `%m` reads 2 (10), `%d` reads
// the rest (10), giving 2020-10-10. The run length that would distinguish a
// 1-digit month is not carried in the chrono format, so the native canonical
// width defines the contract here (the JVM scanner decides which patterns reach
// native).
#[test]
fn adjacent_month_uses_canonical_width_two() {
assert_eq!(run("20201010", "%Y%m%d", "UTC"), 1602288000);
}
// The year field's canonical obeyCount window is 4: on `20200101` with
// `%Y%m%d`, `%Y` reads exactly `2020`, leaving `01`/`01` for month and day.
#[test]
fn year_uses_canonical_width_four() {
assert_eq!(run("20200101", "%Y%m%d", "UTC"), 1577836800);
}
#[test]
fn arity_mismatch_errors() {
let two = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("2020-10-10 00:00:01".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DEFAULT.to_string()))),
];
assert!(flink_unix_timestamp(&two).is_err());
let four = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("2020-10-10 00:00:01".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DEFAULT.to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("UTC".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("extra".to_string()))),
];
assert!(flink_unix_timestamp(&four).is_err());
let one = vec![ColumnarValue::Scalar(ScalarValue::Utf8(Some(
"unexpected".to_string(),
)))];
assert!(flink_unix_timestamp_now(&one).is_err());
}
// A zone id that is not resolvable is an error, not a silent default. Beyond
// outright nonsense, the rows here are spellings close enough to a real id that
// a permissive resolver would accept them: wrong case, surrounding whitespace,
// and the `SystemV/*` family, which is absent from the time zone database and
// must keep being rejected rather than being swept in with the fixed offsets.
#[test]
fn invalid_timezone_errors() {
assert!(zone_rejected("Mars/Olympus"));
assert!(zone_rejected("gmt+08:00"));
assert!(zone_rejected(" GMT+08:00"));
assert!(zone_rejected("GMT+08:00 "));
assert!(zone_rejected("SystemV/PST8"));
}
// The fixed-offset grammar is exactly `±HH:MM`, optionally prefixed by `GMT`,
// with nothing before or after it. Each row is a spelling a permissive offset
// parser would accept: a trailing seconds field (which a truncating parser
// silently drops, turning `+08:00:30` into `+08:00`), a separator that is not a
// colon, a repeated separator, a Unicode minus sign in place of the ASCII
// hyphen, a single-digit hour, and a single-digit minute.
#[test]
fn invalid_fixed_offset_zone_errors() {
assert!(zone_rejected("GMT+08:00:30"));
assert!(zone_rejected("GMT+08 00"));
assert!(zone_rejected("GMT+08::::00"));
assert!(zone_rejected("GMT\u{2212}08:00")); // U+2212 MINUS SIGN, not '-'
assert!(zone_rejected("GMT+8"));
assert!(zone_rejected("+08:0"));
}
// The two numeric bounds on a fixed offset: minutes are a sixtieth of an hour,
// not a free two-digit field, and the magnitude stops at ±18:00.
//
// Both are unreachable through the plan-time gate, which only ever emits ids
// built from a constructed `ZoneId` — `ZoneId.of` throws on all of these. They
// are asserted because the bounds are the parser's own guarantee rather than an
// inherited one, and a caller that does not hold the `ZoneId` invariant would
// otherwise resolve `GMT+08:60` to a well-formed offset an hour past where it
// should be. Each row fails for exactly one of the two checks, so neither can
// be deleted while the suite stays green.
#[test]
fn out_of_range_fixed_offset_zone_errors() {
assert!(zone_rejected("GMT+08:60"));
assert!(zone_rejected("GMT-08:60"));
assert!(zone_rejected("GMT+18:01"));
assert!(zone_rejected("GMT-18:01"));
assert!(zone_rejected("GMT+19:00"));
}
// An empty format consumes nothing, so it matches no input at all rather
// than matching everything at the epoch defaults.
#[test]
fn empty_format_never_matches() {
assert_eq!(run("2020-10-10 00:00:01", "", "UTC"), MIN);
assert_eq!(run("2020-10-10 00:00:01", "", "Asia/Shanghai"), MIN);
assert_eq!(run("", "", "UTC"), MIN);
}
// The no-argument form returns a Scalar, not an Array. That is what lets it
// take no operand: the projection broadcasts the scalar to the batch width,
// giving every row of the batch the same second.
#[test]
fn now_returns_scalar_broadcast_over_the_batch() {
let out = flink_unix_timestamp_now(&[]).expect("flink_unix_timestamp_now must not error");
assert!(matches!(
&out,
ColumnarValue::Scalar(ScalarValue::Int64(Some(_)))
));
let batch_len = 4;
let out = out.into_array(batch_len).expect("must materialize");
let out = out
.as_any()
.downcast_ref::<Int64Array>()
.expect("result must be Int64Array");
assert_eq!(out.len(), batch_len);
for i in 0..batch_len {
assert!(!out.is_null(i));
assert_eq!(out.value(i), out.value(0));
}
}
// The result is in seconds, not milliseconds: it lands inside the window
// bracketed by the test's own clock reads.
#[test]
fn now_returns_epoch_seconds() {
let before = Utc::now().timestamp();
let out = flink_unix_timestamp_now(&[]).expect("flink_unix_timestamp_now must not error");
let after = Utc::now().timestamp();
let secs = match out {
ColumnarValue::Scalar(ScalarValue::Int64(secs)) => secs,
_ => None,
}
.expect("result must be a non-null Int64 scalar");
assert!(
(before..=after).contains(&secs),
"expected {secs} within [{before}, {after}]"
);
}
#[test]
fn unsupported_specifier_errors() {
let args = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("2020".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("%Y-%Q".to_string()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("UTC".to_string()))),
];
assert!(flink_unix_timestamp(&args).is_err());
}
}