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
4 changes: 3 additions & 1 deletion docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1266,7 +1266,7 @@ MySQL types are automatically converted to JavaScript types:
| DECIMAL, NUMERIC | string | To preserve precision |
| FLOAT, DOUBLE | number | |
| DATE | Date | JavaScript Date object |
| DATETIME, TIMESTAMP | Date | With timezone handling |
| DATETIME, TIMESTAMP | Date | Decoded as UTC (see note below); `0000-00-00` becomes an Invalid Date |
| TIME | number | Total of microseconds |
| YEAR | number | |
| CHAR, VARCHAR, VARSTRING, STRING | string | |
Expand All @@ -1276,6 +1276,8 @@ MySQL types are automatically converted to JavaScript types:
| BIT(1) | boolean | BIT(1) in MySQL |
| GEOMETRY | string | Geometry data |

`DATETIME` and `TIMESTAMP` values have no timezone on the wire, so Bun reads them back as **UTC** — the `Date` you get has the same UTC wall-clock that was stored, regardless of the machine's timezone. This matches how values are written (a bound `Date` stores its UTC components). The same applies to PostgreSQL's `timestamp` (without time zone); `timestamptz` carries an explicit offset and is unaffected.

#### Differences from PostgreSQL

While the API is unified, there are some behavioral differences:
Expand Down
96 changes: 95 additions & 1 deletion src/sql_jsc/mysql/MySQLValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,82 @@ impl DateTime {
}
}

/// Parse a MySQL text-protocol DATE/DATETIME/TIMESTAMP string
/// (`YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS`, or with `.ffffff` fractional
/// seconds) into components so the text path can treat them as UTC, the
/// same way the binary path does. Returns `None` for MySQL zero-date
/// sentinels (`0000-00-00`), impossible calendar values (`2024-02-31`), and
/// malformed input, so the caller surfaces `Invalid Date` — matching what
/// the previous `Date.parse` path produced for those.
pub fn from_text(text: &[u8]) -> Option<DateTime> {
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;
}
n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?;
}
Some(n)
}

if text.len() < 10 || text[4] != b'-' || text[7] != b'-' {
return None;
}
let year = u16::try_from(parse_u(&text[0..4])?).ok()?;
let month = u8::try_from(parse_u(&text[5..7])?).ok()?;
let day = u8::try_from(parse_u(&text[8..10])?).ok()?;
if month < 1 || month > 12 || day < 1 || day > days_in_month(year, month) {
return None;
}

let mut result = DateTime {
year,
month,
day,
..Default::default()
};
if text.len() == 10 {
return Some(result);
}

// Either "YYYY-MM-DD HH:MM:SS" or the ISO "YYYY-MM-DDTHH:MM:SS".
if text.len() < 19
|| (text[10] != b' ' && text[10] != b'T')
|| text[13] != b':'
|| text[16] != b':'
{
return None;
}
result.hour = u8::try_from(parse_u(&text[11..13])?).ok()?;
result.minute = u8::try_from(parse_u(&text[14..16])?).ok()?;
result.second = u8::try_from(parse_u(&text[17..19])?).ok()?;
if result.hour > 23 || result.minute > 59 || result.second > 59 {
return None;
}

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

pub fn to_binary(&self, field_type: FieldType, buffer: &mut [u8]) -> u8 {
match field_type {
FieldType::MYSQL_TYPE_YEAR => {
Expand Down Expand Up @@ -597,7 +673,25 @@ impl DateTime {
}

pub fn to_js_timestamp(&self, global_object: &JSGlobalObject) -> JsResult<f64> {
global_object.gregorian_date_time_to_ms(
// MySQL in permissive sql_mode can store zero / partial-zero dates like
// "0000-00-00" or "2024-00-15" and send them over the binary protocol.
// WTF::GregorianDateTime would silently wrap month=0 to December of the
// prior year, so validate here and surface NaN instead — matching the
// Invalid Date the text path produces via from_text().
if self.month < 1
|| self.month > 12
|| self.day < 1
|| self.day > days_in_month(self.year, self.month)
|| self.hour > 23
|| self.minute > 59
|| self.second > 59
{
return Ok(f64::NAN);
}
// from_unix_timestamp() breaks a Date's UTC epoch into Y/M/D h:m:s with
// pure-UTC arithmetic, so decode must also treat the stored wall-clock
// as UTC — otherwise a Date round-trips shifted by the local UTC offset.
global_object.gregorian_date_time_to_ms_utc(
Comment thread
robobun marked this conversation as resolved.
i32::from(self.year),
i32::from(self.month),
i32::from(self.day),
Expand Down
5 changes: 4 additions & 1 deletion src/sql_jsc/mysql/MySQLValue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,10 @@ pub const Value = union(enum) {
}

pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject) bun.JSError!f64 {
return globalObject.gregorianDateTimeToMS(
// fromUnixTimestamp() breaks a Date's UTC epoch into Y/M/D h:m:s with
// pure-UTC arithmetic, so decode must also treat the stored wall-clock
// as UTC — otherwise a Date round-trips shifted by the local UTC offset.
return globalObject.gregorianDateTimeToMSUTC(
this.year,
this.month,
this.day,
Expand Down
5 changes: 4 additions & 1 deletion src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,12 @@ pub fn decode_binary_value<Context: ReaderContext>(
FieldType::MYSQL_TYPE_DATE
| FieldType::MYSQL_TYPE_TIMESTAMP
| FieldType::MYSQL_TYPE_DATETIME => match reader.byte()? {
// A zero-length binary DATETIME is MySQL's "0000-00-00 00:00:00"
// sentinel — surface it as Invalid Date (NaN), not the Unix epoch,
// so it agrees with the text path's from_text().
0 => Ok(SQLDataCell {
tag: CellTag::Date,
value: CellValue { date: 0.0 },
value: CellValue { date: f64::NAN },
..Default::default()
}),
l @ (11 | 7 | 4) => {
Expand Down
20 changes: 10 additions & 10 deletions src/sql_jsc/mysql/protocol/ResultSet.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use core::ptr;

use crate::jsc::{ExternColumnIdentifier, JSGlobalObject, JSValue};
use crate::mysql::my_sql_value::DateTime;
use bun_core::String as BunString;
use bun_core::parse_int;

Expand Down Expand Up @@ -232,16 +233,15 @@ impl<'a> Row<'a> {
};
}
MYSQL_TYPE_DATE | MYSQL_TYPE_DATETIME | MYSQL_TYPE_TIMESTAMP => {
let mut str = BunString::init(value.slice());
// `str` derefs on Drop.
let date = 'brk: {
match crate::jsc::bun_string_jsc::parse_date(&mut str, self.global_object) {
Ok(d) => break 'brk d,
Err(err) => {
let _ = self.global_object.take_exception(err);
break 'brk f64::NAN;
}
}
// MySQL's DATE/DATETIME/TIMESTAMP text has no timezone, so parse
// the components directly and convert them as UTC — matching the
// binary path. Routing through JS Date.parse here would instead
// read "2024-06-15 12:00:00" as local time and make the text and
// binary protocols disagree on non-UTC hosts. Zero/invalid dates
// fall through to NaN (Invalid Date).
let date = match DateTime::from_text(value.slice()) {
Some(dt) => dt.to_js_timestamp(self.global_object).unwrap_or(f64::NAN),
None => f64::NAN,
};
*cell = SQLDataCell {
tag: Tag::Date,
Expand Down
19 changes: 17 additions & 2 deletions src/sql_jsc/postgres/DataCell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1069,10 +1069,25 @@ pub(crate) fn from_bytes(
..Default::default()
});
}
let mut str = BunString::init(bytes);
// `timestamp` (without time zone) text carries no offset, so
// decode its components as UTC to match the binary path. `date`
// (UTC midnight) and `timestamptz` (explicit offset) already
// parse correctly via Date.parse, so only redirect `timestamp`.
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 {
tag: Tag::Date,
value: Value { date: crate::jsc::bun_string_jsc::parse_date(&mut str, global_object).map_err(crate::jsc::js_error_to_postgres)? },
value: Value { date },
..Default::default()
})
}
Expand Down
63 changes: 63 additions & 0 deletions src/sql_jsc/postgres/types/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,69 @@ pub fn from_binary(bytes: &[u8]) -> f64 {
(double_microseconds / US_PER_MS as f64) + POSTGRES_EPOCH_DATE as f64
}

/// Decode a Postgres `timestamp` (WITHOUT TIME ZONE) text value as UTC, so the
/// text/simple-query path agrees with the binary path (which is already UTC).
/// Postgres emits these as `YYYY-MM-DD HH:MM:SS[.ffffff]` with no offset;
/// 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.
pub fn timestamp_text_to_ms_utc(global_object: &JSGlobalObject, bytes: &[u8]) -> Option<f64> {
fn parse_u(bytes: &[u8]) -> Option<i32> {
if bytes.is_empty() {
return None;
}
let mut n: i32 = 0;
for &c in bytes {
if !c.is_ascii_digit() {
return None;
}
n = n.checked_mul(10)?.checked_add(i32::from(c - b'0'))?;
}
Some(n)
}

if bytes.len() < 19
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b' '
|| bytes[13] != b':'
|| bytes[16] != b':'
{
return None;
}
let year = parse_u(&bytes[0..4])?;
let month = parse_u(&bytes[5..7])?;
let day = parse_u(&bytes[8..10])?;
let hour = parse_u(&bytes[11..13])?;
let minute = parse_u(&bytes[14..16])?;
let second = parse_u(&bytes[17..19])?;

let millisecond = if bytes.len() > 19 {
if bytes[19] != b'.' {
return None;
}
let frac = &bytes[20..];
if frac.is_empty() || frac.len() > 6 || !frac.iter().all(u8::is_ascii_digit) {
return None;
}
// Fractional seconds → milliseconds (JS Date is ms-precision, like the
// binary path's f64 truncation).
let mut micro = parse_u(frac)?;
for _ in 0..(6 - frac.len()) {
micro *= 10;
}
micro / 1000
} else {
0
};

global_object
.gregorian_date_time_to_ms_utc(year, month, day, hour, minute, second, millisecond)
.ok()
}

pub fn from_js(global_object: &JSGlobalObject, value: JSValue) -> JsResult<i64> {
let double_value = if value.is_date() {
value.get_unix_timestamp()
Expand Down
86 changes: 86 additions & 0 deletions test/js/sql/sql-mysql-datetime-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, describeWithContainer, isDockerEnabled } from "harness";
import path from "path";

// A JS Date bound to a MySQL DATETIME/TIMESTAMP and read back must be the same
// instant regardless of the process timezone. Encode breaks the Date's epoch-ms
// into Y/M/D h:m:s via pure-UTC arithmetic, so decode has to treat those
// components as UTC too — if it interprets them as local time, the round-trip
// silently shifts by the machine's UTC offset.
//
// The fixture runs against a real MySQL server (docker-compose in CI, or a
// MYSQL_URL/local instance otherwise) and prints "OK TZ=<tz> offsetMin=<n>"
// only when every Date round-trips to the same instant.

const TIMEZONES = ["Etc/UTC", "America/New_York", "Asia/Tokyo"];
const fixture = path.join(import.meta.dir, "sql-mysql-datetime-tz-fixture.ts");

function runFixture(url: string, TZ: string, caPath = "") {
return Bun.spawnSync([bunExe(), fixture], {
env: { ...bunEnv, MYSQL_URL: url, CA_PATH: caPath, TZ },
stdout: "pipe",
stderr: "pipe",
});
}

function assertRoundTrip(stdout: string, stderr: string, TZ: string) {
// On a round-trip mismatch the fixture writes `FAIL TZ=… offsetMin=…` plus the
// per-row `diffMin` breakdown to stderr, then exits 1. Assert it's empty so a
// CI failure surfaces *which* dates drifted and by how much, not just a bare
// "CONNECTED" vs "OK" mismatch. (ASAN emits a harmless interposition warning.)
const diagnostics = stderr
.split(/\r?\n/)
.filter(l => l && !l.startsWith("WARNING: ASAN interferes"))
.join("\n");
expect(diagnostics).toBe("");
// "OK TZ=<tz>" only prints when all three Dates round-trip to the same instant.
expect(stdout).toContain(`OK TZ=${TZ}`);
// And the child runtime must actually have adopted the injected timezone —
// a non-zero offset for the non-UTC zones — otherwise a silently-unapplied TZ
// would degenerate all three runs into the UTC case and stop exercising the
// local-time decode bug.
expect(stdout).toMatch(TZ === "Etc/UTC" ? /offsetMin=0\b/ : /offsetMin=-?[1-9]/);
}

if (isDockerEnabled()) {
// CI: run against the docker-compose MySQL service.
describeWithContainer("mysql", { image: "mysql_plain" }, container => {
describe.each(TIMEZONES)("TZ=%s", TZ => {
test("DATETIME Date round-trip is the identity", async () => {
await container.ready;
const url = `mysql://root@${container.host}:${container.port}/bun_sql_test`;
const { stdout, stderr, exitCode } = runFixture(url, TZ);
const out = String(stdout);
expect(out).toContain("CONNECTED");
assertRoundTrip(out, String(stderr), TZ);
expect(exitCode).toBe(0);
});
});
});
} else {
// No docker daemon (e.g. local/sandboxed environments). If a MySQL server is
// reachable at MYSQL_URL or the conventional local address, exercise the
// fixture there so the round-trip is still covered without a mock.
const url = process.env.MYSQL_URL || "mysql://bun@127.0.0.1:3306/bun_sql_test";

describe.each(TIMEZONES)("mysql (local) TZ=%s", TZ => {
test("DATETIME Date round-trip is the identity", () => {
const { stdout, stderr, exitCode } = runFixture(url, TZ);
const out = String(stdout);
// The fixture prints "CONNECTED" once it reaches the server. If it never
// got that far, there's no MySQL to talk to here; the docker-gated branch
// above provides the CI coverage.
if (!out.includes("CONNECTED")) {
if (process.env.MYSQL_URL) {
throw new Error(
`sql-mysql-datetime-roundtrip: MYSQL_URL was provided but fixture never reached CONNECTED\nstdout:\n${out}\nstderr:\n${String(stderr)}`,
);
}
console.warn("sql-mysql-datetime-roundtrip: no MySQL reachable at " + url + "; skipping assertions");
return;
}
assertRoundTrip(out, String(stderr), TZ);
expect(exitCode).toBe(0);
});
});
}
Loading
Loading