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
2 changes: 1 addition & 1 deletion doc/user/content/sql/functions/justify-days.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ SELECT justify_days(interval '35 days');
```nofmt
justify_days
----------------
1 month 5 days
1 mon 5 days
```
8 changes: 4 additions & 4 deletions doc/user/content/sql/types/interval.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Detail | Info
**Size** | 16 bytes
**Catalog name** | `pg_catalog.interval`
**OID** | 1186
**Min value** | -178956970 years -8 months -2147483648 days -2562047788:00:54.775808
**Max value** | 178956970 years 7 months 2147483647 days 2562047788:00:54.775807
**Min value** | -178956970 years -8 mons -2147483648 days -2562047788:00:54.775808
**Max value** | 178956970 years 7 mons 2147483647 days 2562047788:00:54.775807

## Syntax

Expand Down Expand Up @@ -125,7 +125,7 @@ SELECT INTERVAL '1-2 3 4:5:6.7' AS interval_p;
```nofmt
interval_f
-----------------------------------
1 year 2 months 3 days 04:05:06.7
1 year 2 mons 3 days 04:05:06.7
```

### PostgreSQL syntax
Expand Down Expand Up @@ -182,7 +182,7 @@ SELECT INTERVAL '1 day 2-3 4' MINUTE AS interval_w;
```nofmt
interval_w
---------------------------------
2 years 3 months 1 day 00:04:00
2 years 3 mons 1 day 00:04:00
```

### Interaction with timestamps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ def validate(self) -> Testdrive:
3 334 "2024-12-31 23:59:59" "2024-12-31 18:59:59" "2024-12-31 18:59:59"
4 335 "2025-01-01 00:00:00" "2024-12-31 19:00:00" "2024-12-31 19:00:00"

> SELECT id, jd::text, jh::text, ji::text FROM datetime_fns_convert1
>[version>=2603500] SELECT id, jd::text, jh::text, ji::text FROM datetime_fns_convert1
1 "1 mon 5 days" "35 days" "1 mon 5 days"
2 27:00:00 "1 day 03:00:00" "1 day 03:00:00"
3 "1 mon -01:00:00" "1 mon -01:00:00" "29 days 23:00:00"
4 00:00:00 00:00:00 00:00:00

>[version<2603500] SELECT id, jd::text, jh::text, ji::text FROM datetime_fns_convert1
1 "1 month 5 days" "35 days" "1 month 5 days"
2 27:00:00 "1 day 03:00:00" "1 day 03:00:00"
3 "1 month -01:00:00" "1 month -01:00:00" "29 days 23:00:00"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
from materialize.output_consistency.input_data.operations.string_operations_provider import (
TAG_REGEX,
)
from materialize.output_consistency.input_data.types.date_time_types_provider import (
INTERVAL_TYPE_IDENTIFIER,
)
from materialize.output_consistency.input_data.types.number_types_provider import (
NON_INTEGER_TYPE_IDENTIFIERS,
)
Expand All @@ -66,6 +69,7 @@
MZ_VERSION_0_117_0 = MzVersion.parse_mz("v0.117.0")
MZ_VERSION_0_118_0 = MzVersion.parse_mz("v0.118.0")
MZ_VERSION_0_128_0 = MzVersion.parse_mz("v0.128.0")
MZ_VERSION_26_35_0 = MzVersion.parse_mz("v26.35.0")


class VersionConsistencyIgnoreFilter(GenericInconsistencyIgnoreFilter):
Expand Down Expand Up @@ -315,6 +319,20 @@ def _shall_ignore_content_mismatch(
"Involvement of unnest changes order with PR#29422 for both DFR and CTF"
)

if (
self.lower_version <= MZ_VERSION_26_35_0 <= self.higher_version
and query_template.matches_any_expression(
partial(
is_known_to_involve_exact_data_types,
internal_data_type_identifiers={INTERVAL_TYPE_IDENTIFIER},
),
True,
)
):
return YesIgnore(
"Interval text output changed from 'month(s)' to 'mon(s)' with SQL-463"
)

return super()._shall_ignore_content_mismatch(
error,
query_template,
Expand Down
2 changes: 1 addition & 1 deletion src/expr/src/scalar/func/impls/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ pub fn extract_date_inner(units: DateTimeUnits, date: NaiveDate) -> Result<Numer
DateTimeUnits::Millennium => Ok(Numeric::from(date.millennium())),
DateTimeUnits::Century => Ok(Numeric::from(date.century())),
DateTimeUnits::Decade => Ok(Numeric::from(date.decade())),
DateTimeUnits::Year => Ok(Numeric::from(date.year())),
DateTimeUnits::Year => Ok(Numeric::from(date.extract_year())),
DateTimeUnits::Quarter => Ok(Numeric::from(date.quarter())),
DateTimeUnits::Week => Ok(Numeric::from(date.iso_week_number())),
DateTimeUnits::Month => Ok(Numeric::from(date.month())),
Expand Down
2 changes: 1 addition & 1 deletion src/expr/src/scalar/func/impls/timestamp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ where
DateTimeUnits::Millennium => Ok(D::from(ts.millennium())),
DateTimeUnits::Century => Ok(D::from(ts.century())),
DateTimeUnits::Decade => Ok(D::from(ts.decade())),
DateTimeUnits::Year => Ok(D::from(ts.year())),
DateTimeUnits::Year => Ok(D::from(ts.extract_year())),
DateTimeUnits::Quarter => Ok(D::from(ts.quarter())),
DateTimeUnits::Week => Ok(D::from(ts.iso_week_number())),
DateTimeUnits::Month => Ok(D::from(ts.month())),
Expand Down
62 changes: 31 additions & 31 deletions src/expr/src/scalar/func/variadic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,6 @@ fn make_mz_acl_item(
}

#[sqlfunc(sqlname = "makets")]
// TODO(benesch): remove potentially dangerous usage of `as`.
#[allow(clippy::as_conversions)]
fn make_timestamp(
year: i64,
Expand All @@ -1066,36 +1065,37 @@ fn make_timestamp(
minute: i64,
second_float: f64,
) -> Result<Option<CheckedTimestamp<NaiveDateTime>>, EvalError> {
let year: i32 = match year.try_into() {
Ok(year) => year,
Err(_) => return Ok(None),
};
let month: u32 = match month.try_into() {
Ok(month) => month,
Err(_) => return Ok(None),
};
let day: u32 = match day.try_into() {
Ok(day) => day,
Err(_) => return Ok(None),
};
let hour: u32 = match hour.try_into() {
Ok(hour) => hour,
Err(_) => return Ok(None),
};
let minute: u32 = match minute.try_into() {
Ok(minute) => minute,
Err(_) => return Ok(None),
};
let second = second_float as u32;
let micros = ((second_float - second as f64) * 1_000_000.0) as u32;
let date = match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => date,
None => return Ok(None),
};
let timestamp = match date.and_hms_micro_opt(hour, minute, second, micros) {
Some(timestamp) => timestamp,
None => return Ok(None),
};
// NOTE: This function never returns `Ok(None)`. The `Option` stays so
// that the derived `introduces_nulls` (and with it the nullability of
// existing objects planned with this function) does not change.

// Negative years are BC and there is no year 0. Chrono's astronomical
// numbering has 1 BC as year 0, so BC years shift by one.
if year == 0 {
return Err(EvalError::DateOutOfRange);
}
let year = if year < 0 { year + 1 } else { year };
let year: i32 = year.try_into().map_err(|_| EvalError::DateOutOfRange)?;
let month: u32 = month.try_into().map_err(|_| EvalError::DateOutOfRange)?;
let day: u32 = day.try_into().map_err(|_| EvalError::DateOutOfRange)?;
let date = NaiveDate::from_ymd_opt(year, month, day).ok_or(EvalError::DateOutOfRange)?;
// Allow exactly 24:00:00 (rolls over to midnight of the next day) and
// 60 seconds (rolls over into the next minute).
if !(0..=24).contains(&hour)
|| !(0..=59).contains(&minute)
|| !(0.0..=60.0).contains(&second_float)
|| (hour == 24 && (minute > 0 || second_float > 0.0))
{
return Err(EvalError::TimestampOutOfRange);
}
// The cast is exact: second_float is in [0, 60].
let micros = (second_float * 1_000_000.0).round() as i64;
let time = chrono::Duration::microseconds((hour * 60 + minute) * 60 * 1_000_000 + micros);
let timestamp = date
.and_hms_opt(0, 0, 0)
.unwrap()
.checked_add_signed(time)
.ok_or(EvalError::TimestampOutOfRange)?;
Ok(Some(timestamp.try_into()?))
}

Expand Down
132 changes: 128 additions & 4 deletions src/repr/src/adt/datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,10 @@ impl ParsedDateTime {
if fmt.is_none() {
fmt = match value_parts.pop_front() {
Some(next_part) => {
match determine_format_w_datetimefield(next_part.clone(), None)? {
match determine_format_w_datetimefield(
next_part.clone(),
leading_time_precision,
)? {
Some(TimePartFormat::SqlStandard(f)) => {
match f {
// Do not capture this token because expression
Expand Down Expand Up @@ -813,12 +816,13 @@ impl ParsedDateTime {
pub fn build_parsed_datetime_timestamp(
value: &str,
era: CalendarEra,
order: DateOrder,
) -> Result<ParsedDateTime, String> {
let mut pdt = ParsedDateTime::default();

let mut ts_actual = tokenize_time_str(value)?;

fill_pdt_date(&mut pdt, &mut ts_actual)?;
fill_pdt_date(&mut pdt, &mut ts_actual, order, era)?;

if let CalendarEra::BC = era {
pdt.year = pdt.year.map(|mut y| {
Expand Down Expand Up @@ -1080,6 +1084,8 @@ impl ParsedDateTime {
fn fill_pdt_date(
pdt: &mut ParsedDateTime,
actual: &mut VecDeque<TimeStrToken>,
order: DateOrder,
era: CalendarEra,
) -> Result<(), String> {
use TimeStrToken::*;

Expand Down Expand Up @@ -1124,6 +1130,26 @@ fn fill_pdt_date(
_ => (),
}

// PostgreSQL under the pinned `DateStyle = ISO, MDY` reads a separated
// date whose leading field has one or two digits as month-day-year, and
// only a leading field of three or more digits as year-month-day. The
// formats below fill fields positionally as year-month-day, so record the
// digit counts up front and reorder the fields after a successful fill.
let mdy = match (order, actual.front()) {
(DateOrder::Mdy, Some(&Num(_, digits))) => digits <= 2,
_ => false,
};
// Digit count of the third numeric field, the year in a month-day-year
// date, which decides two-digit-year windowing.
let year_digits = actual
.iter()
.take_while(|t| matches!(t, Num(..) | Dash | Delim))
.filter_map(|t| match t {
Num(_, digits) => Some(*digits),
_ => None,
})
.nth(2);

let valid_formats = [
[
Num(0, 1), // year
Expand Down Expand Up @@ -1153,6 +1179,22 @@ fn fill_pdt_date(
for expected in valid_formats {
match fill_pdt_from_tokens(pdt, actual, &expected, DateTimeField::Year, 1) {
Ok(()) => {
// In a month-day-year date the positional fill above put the
// month in `year`, the day in `month`, and the year in `day`.
if mdy {
if let (Some(month), Some(day), Some(mut year)) = (pdt.year, pdt.month, pdt.day)
{
// Window two-digit years into 1970..=2069, as
// PostgreSQL does. Explicit BC years are never
// windowed.
if era == CalendarEra::AD && year_digits.is_some_and(|d| d <= 2) {
year.unit += if year.unit < 70 { 2000 } else { 1900 };
}
pdt.year = Some(year);
pdt.month = Some(month);
pdt.day = Some(day);
}
}
return Ok(());
}
Err(_) => {
Expand Down Expand Up @@ -1810,12 +1852,25 @@ pub(crate) fn tokenize_time_str(value: &str) -> Result<VecDeque<TimeStrToken>, S
Ok(toks)
}

#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CalendarEra {
BC,
AD,
}

/// Determines how ambiguous all-numeric dates like `01/02/03` are interpreted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateOrder {
/// PostgreSQL semantics under the pinned `DateStyle = ISO, MDY`: a date
/// whose leading field has one or two digits is month-day-year, and a
/// two-digit year is windowed into 1970..=2069.
Mdy,
/// Always year-month-day, with no year windowing. Frozen behavior for
/// storage source casts, which must not change across releases (see the
/// stability contract in `mz_storage_types::sources::casts`).
LegacyYmd,
}

/// Takes a 'date timezone' 'date time timezone' string and splits it into 'date
/// {time}' and 'timezone' components then checks for a 'CalenderEra' defaulting
/// to 'AD'.
Expand Down Expand Up @@ -2839,10 +2894,79 @@ mod tests {
..Default::default()
},
);
// A one- or two-digit leading field starts a month-day-year date, and
// a two-digit year is windowed into 1970..=2069.
run_test_build_parsed_datetime_timestamp(
"01/02/03",
ParsedDateTime {
year: Some(DateTimeFieldValue::new(2003, 0)),
month: Some(DateTimeFieldValue::new(1, 0)),
day: Some(DateTimeFieldValue::new(2, 0)),
..Default::default()
},
);
run_test_build_parsed_datetime_timestamp(
"01-02-70 3:4:5.6",
ParsedDateTime {
year: Some(DateTimeFieldValue::new(1970, 0)),
month: Some(DateTimeFieldValue::new(1, 0)),
day: Some(DateTimeFieldValue::new(2, 0)),
hour: Some(DateTimeFieldValue::new(3, 0)),
minute: Some(DateTimeFieldValue::new(4, 0)),
second: Some(DateTimeFieldValue::new(5, 600_000_000)),
..Default::default()
},
);
// A four-digit year in month-day-year position is not windowed.
run_test_build_parsed_datetime_timestamp(
"1/2/1999",
ParsedDateTime {
year: Some(DateTimeFieldValue::new(1999, 0)),
month: Some(DateTimeFieldValue::new(1, 0)),
day: Some(DateTimeFieldValue::new(2, 0)),
..Default::default()
},
);
// BC years are never windowed.
assert_eq!(
ParsedDateTime::build_parsed_datetime_timestamp(
"01/02/03",
CalendarEra::BC,
DateOrder::Mdy,
)
.unwrap(),
ParsedDateTime {
year: Some(DateTimeFieldValue::new(-3, 0)),
month: Some(DateTimeFieldValue::new(1, 0)),
day: Some(DateTimeFieldValue::new(2, 0)),
..Default::default()
},
);
// The legacy order keeps the frozen year-month-day interpretation
// with no windowing.
assert_eq!(
ParsedDateTime::build_parsed_datetime_timestamp(
"01/02/03",
CalendarEra::AD,
DateOrder::LegacyYmd,
)
.unwrap(),
ParsedDateTime {
year: Some(DateTimeFieldValue::new(1, 0)),
month: Some(DateTimeFieldValue::new(2, 0)),
day: Some(DateTimeFieldValue::new(3, 0)),
..Default::default()
},
);

fn run_test_build_parsed_datetime_timestamp(test: &str, res: ParsedDateTime) {
assert_eq!(
ParsedDateTime::build_parsed_datetime_timestamp(test, CalendarEra::AD).unwrap(),
ParsedDateTime::build_parsed_datetime_timestamp(
test,
CalendarEra::AD,
DateOrder::Mdy
)
.unwrap(),
res
);
}
Expand Down
Loading
Loading