Skip to content
Open
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
61 changes: 36 additions & 25 deletions src/sql_jsc/postgres/DataCell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@ type Result<T, E = AnyPostgresError> = core::result::Result<T, E>;
bun_core::declare_scope!(Postgres, visible);
bun_core::declare_scope!(PostgresDataCell, visible);

/// Text-format `date` / `timestamp` / `timestamptz` (scalar or array element) to epoch ms.
fn parse_date_time_text(
tag: types::Tag,
bytes: &[u8],
global_object: &JSGlobalObject,
) -> Result<f64> {
use crate::postgres::types::date;
// The StartupMessage pins DateStyle=ISO, so the server only ever sends these shapes.
let ms = match tag {
types::Tag::timestamp | types::Tag::timestamp_array => {
date::timestamp_text_to_ms_utc(global_object, bytes)
}
types::Tag::timestamptz | types::Tag::timestamptz_array => {
date::timestamptz_text_to_ms_utc(global_object, bytes)
}
_ => None,
};
if let Some(ms) = ms {
return Ok(ms);
}
// `date` (date-only ISO form), BC dates and 5+ digit years fall back to `Date.parse`.
let mut str = BunString::init(bytes);
crate::jsc::bun_string_jsc::parse_date(&mut str, global_object)
.map_err(crate::jsc::js_error_to_postgres)
}

fn parse_bytea(hex: &[u8]) -> Result<SQLDataCell> {
let len = hex.len() / 2;
let mut buf: Vec<u8> = Vec::new();
Expand Down Expand Up @@ -214,11 +240,11 @@ fn parse_array(
| types::Tag::timestamp_array
| types::Tag::date_array => {
let date_str = &slice[1..current_idx];
let mut str = BunString::init(date_str);
array.push(SQLDataCell::date(
crate::jsc::bun_string_jsc::parse_date(&mut str, global_object)
.map_err(crate::jsc::js_error_to_postgres)?,
));
array.push(SQLDataCell::date(parse_date_time_text(
array_type,
date_str,
global_object,
)?));

slice = try_slice(slice, current_idx + 1);
continue;
Expand Down Expand Up @@ -841,26 +867,11 @@ fn from_bytes(
if let Some(inf) = crate::postgres::types::date::parse_infinity(bytes) {
return Ok(SQLDataCell::date(inf));
}
// DateStyle is pinned to ISO in the startup packet, so the
// server always emits `YYYY-MM-DD[...]` here regardless of
// postgresql.conf / ALTER DATABASE / ALTER ROLE defaults.
// `timestamp` (no offset) is decoded as UTC components to
// agree with the binary path; `date` (UTC midnight) and
// `timestamptz` (explicit offset) go through Date.parse,
// which handles the ISO form unambiguously.
let date = match tag {
T::timestamp => crate::postgres::types::date::timestamp_text_to_ms_utc(global_object, bytes),
_ => None,
};
let date = match date {
Some(d) => d,
None => {
let mut str = BunString::init(bytes);
crate::jsc::bun_string_jsc::parse_date(&mut str, global_object)
.map_err(crate::jsc::js_error_to_postgres)?
}
};
Ok(SQLDataCell::date(date))
Ok(SQLDataCell::date(parse_date_time_text(
tag,
bytes,
global_object,
)?))
}
}
tag @ (T::time | T::timetz) => {
Expand Down
20 changes: 18 additions & 2 deletions src/sql_jsc/postgres/types/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,29 @@ pub(crate) fn parse_infinity(bytes: &[u8]) -> Option<f64> {
/// without this they'd go through JS `Date.parse` and be read as local time on
/// non-UTC hosts. Returns `None` for anything that isn't this exact shape
/// (e.g. `infinity`, BC dates, 5+ digit years), so the caller falls back to
/// `Date.parse`. `timestamptz` and `date` already decode correctly via
/// `Date.parse` and must NOT be routed here.
/// `Date.parse`.
pub(crate) fn timestamp_text_to_ms_utc(
global_object: &JSGlobalObject,
bytes: &[u8],
) -> Option<f64> {
let parsed = crate::shared::datetime_text::parse_postgres_timestamp(bytes)?;
components_to_ms_utc(global_object, &parsed)
}

/// Same for `timestamptz` text, whose trailing `±HH[:MM[:SS]]` offset is applied here.
pub(crate) fn timestamptz_text_to_ms_utc(
global_object: &JSGlobalObject,
bytes: &[u8],
) -> Option<f64> {
let (parsed, offset_seconds) = crate::shared::datetime_text::parse_postgres_timestamptz(bytes)?;
let wall_clock_as_utc = components_to_ms_utc(global_object, &parsed)?;
Some(wall_clock_as_utc - f64::from(offset_seconds) * 1000.0)
}

fn components_to_ms_utc(
global_object: &JSGlobalObject,
parsed: &crate::shared::datetime_text::DateTimeText,
) -> Option<f64> {
global_object
.gregorian_date_time_to_ms_utc(
i32::from(parsed.year),
Expand Down
72 changes: 48 additions & 24 deletions src/sql_jsc/shared/datetime_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,59 @@ enum Separator {
/// MySQL DATE/DATETIME/TIMESTAMP text. Accepts the 10-byte date-only form
/// (`YYYY-MM-DD`) and either `' '` or `'T'` as the date/time separator.
pub(crate) fn parse_mysql(text: &[u8]) -> Option<DateTimeText> {
parse(text, TimePart::Optional, Separator::SpaceOrT)
let (dt, consumed) = parse(text, TimePart::Optional, Separator::SpaceOrT)?;
(consumed == text.len()).then_some(dt)
}

/// Postgres `timestamp` (WITHOUT TIME ZONE) text. Requires the full
/// `YYYY-MM-DD HH:MM:SS[.ffffff]` shape — anything else (date-only, `'T'`
/// separator, `infinity`, BC dates, 5+ digit years) returns `None` so the
/// caller can fall back to `Date.parse`.
pub(crate) fn parse_postgres_timestamp(text: &[u8]) -> Option<DateTimeText> {
parse(text, TimePart::Required, Separator::Space)
let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?;
(consumed == text.len()).then_some(dt)
}

fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<DateTimeText> {
fn parse_u(bytes: &[u8]) -> Option<u32> {
if bytes.is_empty() {
return None;
/// Postgres `timestamptz` text; the `±HH[:MM[:SS]]` offset is returned in seconds east of UTC.
pub(crate) fn parse_postgres_timestamptz(text: &[u8]) -> Option<(DateTimeText, i32)> {
let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?;
let (&sign, offset) = text.get(consumed..)?.split_first()?;
let sign: i32 = match sign {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let hours = parse_u(offset.get(0..2)?)?;
let (minutes, seconds) = match offset.len() {
2 => (0, 0),
5 if offset[2] == b':' => (parse_u(&offset[3..5])?, 0),
8 if offset[2] == b':' && offset[5] == b':' => {
(parse_u(&offset[3..5])?, parse_u(&offset[6..8])?)
}
let mut n: u32 = 0;
for &c in bytes {
if !c.is_ascii_digit() {
return None;
}
n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?;
_ => return None,
};
if minutes > 59 || seconds > 59 {
return None;
}
let offset_seconds = i32::try_from(hours * 3600 + minutes * 60 + seconds).ok()?;
Some((dt, sign * offset_seconds))
Comment thread
robobun marked this conversation as resolved.
}

fn parse_u(bytes: &[u8]) -> Option<u32> {
if bytes.is_empty() {
return None;
}
let mut n: u32 = 0;
for &c in bytes {
if !c.is_ascii_digit() {
return None;
}
Some(n)
n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?;
}
Some(n)
}

fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<(DateTimeText, usize)> {
if text.len() < 10 || text[4] != b'-' || text[7] != b'-' {
return None;
}
Expand All @@ -79,7 +106,7 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<DateT
};
if text.len() == 10 {
return match time_part {
TimePart::Optional => Some(result),
TimePart::Optional => Some((result, 10)),
TimePart::Required => None,
};
}
Expand All @@ -95,21 +122,18 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<DateT
result.minute = u8::try_from(parse_u(&text[14..16])?).ok()?;
result.second = u8::try_from(parse_u(&text[17..19])?).ok()?;

if text.len() == 19 {
return Some(result);
}
if text[19] != b'.' {
return None;
if text.len() == 19 || text[19] != b'.' {
return Some((result, 19));
}
// Fractional seconds: up to 6 digits, right-padded to microseconds.
let frac = &text[20..];
if frac.is_empty() || frac.len() > 6 {
let frac_len = text[20..].iter().take_while(|c| c.is_ascii_digit()).count();
if frac_len == 0 || frac_len > 6 {
return None;
}
let mut micro = parse_u(frac)?;
for _ in 0..(6 - frac.len()) {
let mut micro = parse_u(&text[20..20 + frac_len])?;
for _ in 0..(6 - frac_len) {
micro *= 10;
}
result.microsecond = micro;
Some(result)
Some((result, 20 + frac_len))
}
Loading
Loading