Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,21 @@
*/
bigint?: boolean | undefined;

/**
* Interpret MySQL `DATE`/`DATETIME`/`TIMESTAMP` values as UTC when
* decoding them into JavaScript `Date` objects.
*
* MySQL's wire protocol carries no timezone information for these
* types. By default (`false`) Bun matches its historical behaviour and
* interprets the components in the client's local timezone. Set this to
* `true` to interpret them as UTC so a value round-trips to the same
* `Date` regardless of `process.env.TZ`.
*
* Currently only affects the MySQL / MariaDB adapter.
* @default false
*/
utcDate?: boolean | undefined;

Check warning on line 389 in packages/bun-types/sql.d.ts

View check run for this annotation

Claude / Claude Code Review

utcDate option not documented in docs/runtime/sql.mdx

The new `utcDate` connection option is added to the public type surface but isn't mentioned in `docs/runtime/sql.mdx`. Since the directly analogous `bigint` decode option has its own section there ('BigInt Instead of Strings', ~line 1169) and there's a 'MySQL Options' section (~line 599), a brief mention of `utcDate` would help users hitting #29208's symptom discover the fix without reading the .d.ts.
Comment thread
robobun marked this conversation as resolved.

/**
* Automatic creation of prepared statements
* @default true
Expand Down
24 changes: 10 additions & 14 deletions src/js/bun/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ const SQL: typeof Bun.SQL = function SQL(
): Bun.SQL {
const connectionInfo = parseOptions(stringOrUrlOrOptions, definitelyOptionsButMaybeEmpty);
const pool = adapterFromOptions(connectionInfo);
// Per-connection decode flags (bigint, utcDate) are copied onto every query
// so the native per-query entry point doesn't need to reach back into the
// connection object.
const decodeFlags =
(connectionInfo.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none) |
(connectionInfo.utcDate ? SQLQueryFlags.utcDate : SQLQueryFlags.none);

function onQueryDisconnected(this: Query<any, any>, err: Error) {
// connection closed mid query this will not be called if the query finishes first
Expand Down Expand Up @@ -113,13 +119,7 @@ const SQL: typeof Bun.SQL = function SQL(
values: any[],
) {
try {
return new Query(
strings,
values,
connectionInfo.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none,
queryFromPoolHandler,
pool,
);
return new Query(strings, values, decodeFlags, queryFromPoolHandler, pool);
} catch (err) {
return Promise.$reject(err);
}
Expand All @@ -130,7 +130,7 @@ const SQL: typeof Bun.SQL = function SQL(
values: any[],
) {
try {
let flags = connectionInfo.bigint ? SQLQueryFlags.bigint | SQLQueryFlags.unsafe : SQLQueryFlags.unsafe;
let flags = decodeFlags | SQLQueryFlags.unsafe;
if ((values?.length ?? 0) === 0) {
flags |= SQLQueryFlags.simple;
}
Expand Down Expand Up @@ -182,9 +182,7 @@ const SQL: typeof Bun.SQL = function SQL(
const query = new Query(
strings,
values,
connectionInfo.bigint
? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.bigint
: SQLQueryFlags.allowUnsafeTransaction,
decodeFlags | SQLQueryFlags.allowUnsafeTransaction,
queryFromTransactionHandler.bind(pooledConnection, transactionQueries),
pool,
);
Expand All @@ -203,9 +201,7 @@ const SQL: typeof Bun.SQL = function SQL(
transactionQueries: Set<Query<any, any>>,
) {
try {
let flags = connectionInfo.bigint
? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe | SQLQueryFlags.bigint
: SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe;
let flags = decodeFlags | SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe;

if ((values?.length ?? 0) === 0) {
flags |= SQLQueryFlags.simple;
Expand Down
2 changes: 2 additions & 0 deletions src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export interface MySQLDotZig {
columns: string[] | undefined,
bigint: boolean,
simple: boolean,
utcDate: boolean,
) => $ZigGeneratedClasses.MySQLQuery;
}

Expand Down Expand Up @@ -588,6 +589,7 @@ class MySQLAdapter
undefined,
!!(flags & SQLQueryFlags.bigint),
!!(flags & SQLQueryFlags.simple),
!!(flags & SQLQueryFlags.utcDate),
);
}

Expand Down
1 change: 1 addition & 0 deletions src/js/internal/sql/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ const enum SQLQueryFlags {
bigint = 1 << 2,
simple = 1 << 3,
notTagged = 1 << 4,
utcDate = 1 << 5,
}

const enum SQLQueryStatus {
Expand Down
3 changes: 3 additions & 0 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ function parseOptions(
let onclose: ((error?: Error | undefined) => void) | undefined;
let max: number | null | undefined;
let bigint: boolean | undefined;
let utcDate: boolean | undefined;
let path: string;
let prepare: boolean = true;

Expand Down Expand Up @@ -771,6 +772,7 @@ function parseOptions(
maxLifetime ??= options.maxLifetime;
maxLifetime ??= options.max_lifetime;
bigint ??= options.bigint;
utcDate ??= options.utcDate;

// we need to explicitly set prepare to false if it is false
if (options.prepare === false) {
Expand Down Expand Up @@ -866,6 +868,7 @@ function parseOptions(
tls,
prepare,
bigint,
utcDate,
sslMode,
query,
max: max || 10,
Expand Down
9 changes: 7 additions & 2 deletions src/sql/mysql/MySQLQuery.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const MySQLQuery = @This();
simple: bool = false,
pipelined: bool = false,
result_mode: SQLQueryResultMode = .objects,
_padding: u3 = 0,
utc_date: bool = false,
_padding: u2 = 0,
},

fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *JSGlobalObject, binding_value: JSValue, columns_value: JSValue) AnyMySQLError.Error!void {
Expand Down Expand Up @@ -182,14 +183,15 @@ fn runPreparedQuery(
}
}

pub fn init(query: bun.String, bigint: bool, simple: bool) @This() {
pub fn init(query: bun.String, bigint: bool, simple: bool, utc_date: bool) @This() {
query.ref();
return .{
.#query = query,
.#status = .pending,
.#flags = .{
.bigint = bigint,
.simple = simple,
.utc_date = utc_date,
},
};
}
Expand Down Expand Up @@ -261,6 +263,9 @@ pub inline fn isSimple(this: *const @This()) bool {
pub inline fn isBigintSupported(this: *const @This()) bool {
return this.#flags.bigint;
}
pub inline fn isUtcDate(this: *const @This()) bool {
return this.#flags.utc_date;
}
pub inline fn getResultMode(this: *const @This()) SQLQueryResultMode {
return this.#flags.result_mode;
}
Expand Down
104 changes: 100 additions & 4 deletions src/sql/mysql/MySQLTypes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,69 @@ pub const Value = union(enum) {
return fromBinary(data.slice());
}

/// Parse MySQL's text-protocol DATE/DATETIME/TIMESTAMP representation:
/// "YYYY-MM-DD"
/// "YYYY-MM-DD HH:MM:SS"
/// "YYYY-MM-DD HH:MM:SS.ffffff" (1-6 fractional digits)
///
/// MySQL TIMESTAMP values are returned in the server's session TZ; DATETIME
/// is a naive wall-clock. Both are returned without a TZ designator. When
/// the connection was opened with `utcDate: true` Bun interprets them as
/// UTC components so the resulting JS `Date` round-trips the bytes the
/// server sent, regardless of the client TZ.
pub fn fromText(text: []const u8) !DateTime {
if (text.len < 10) return error.InvalidDateTimeText;

const year = std.fmt.parseInt(u16, text[0..4], 10) catch return error.InvalidDateTimeText;
if (text[4] != '-') return error.InvalidDateTimeText;
const month = std.fmt.parseInt(u8, text[5..7], 10) catch return error.InvalidDateTimeText;
if (text[7] != '-') return error.InvalidDateTimeText;
const day = std.fmt.parseInt(u8, text[8..10], 10) catch return error.InvalidDateTimeText;

// Reject MySQL zero-date sentinels like "0000-00-00" and impossible
// calendar values (e.g. 2024-02-31) so the caller produces NaN,
// matching the behaviour of the pre-existing JS-parser path.
// Otherwise JSC's GregorianDateTime would silently normalize them
// into bogus timestamps instead of an Invalid Date.
if (month < 1 or month > 12) return error.InvalidDateTimeText;
if (day < 1 or day > daysInMonth(year, month)) return error.InvalidDateTimeText;

var result: DateTime = .{ .year = year, .month = month, .day = day };
if (text.len == 10) return result;

// Either "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" (ISO-style).
if (text.len < 19 or (text[10] != ' ' and text[10] != 'T')) return error.InvalidDateTimeText;

result.hour = std.fmt.parseInt(u8, text[11..13], 10) catch return error.InvalidDateTimeText;
if (text[13] != ':') return error.InvalidDateTimeText;
result.minute = std.fmt.parseInt(u8, text[14..16], 10) catch return error.InvalidDateTimeText;
if (text[16] != ':') return error.InvalidDateTimeText;
result.second = std.fmt.parseInt(u8, text[17..19], 10) catch return error.InvalidDateTimeText;
// Same rationale as the date checks above. MySQL's strict modes
// reject these values, but permissive modes will happily store
// them.
if (result.hour > 23 or result.minute > 59 or result.second > 59) {
return error.InvalidDateTimeText;
}

if (text.len == 19) return result;
if (text[19] != '.') return error.InvalidDateTimeText;
Comment thread
robobun marked this conversation as resolved.

// Fractional seconds: up to 6 digits, right-padded to microseconds.
const frac = text[20..];
if (frac.len == 0 or frac.len > 6) return error.InvalidDateTimeText;
var micro: u32 = 0;
for (frac) |c| {
if (c < '0' or c > '9') return error.InvalidDateTimeText;
micro = micro * 10 + (c - '0');
}
var pad: usize = 6 - frac.len;
while (pad > 0) : (pad -= 1) micro *= 10;
result.microsecond = micro;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return result;
}

pub fn fromBinary(val: []const u8) DateTime {
switch (val.len) {
4 => {
Expand Down Expand Up @@ -600,8 +663,41 @@ pub const Value = union(enum) {
}
}

pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject) bun.JSError!f64 {
return globalObject.gregorianDateTimeToMS(
/// Convert the parsed components to a JS timestamp (milliseconds since
/// the Unix epoch).
///
/// MySQL's binary DATETIME/TIMESTAMP protocol encodes raw
/// year/month/day/hour/minute/second/microsecond components with no
/// timezone information. When `utc` is true (the `utcDate: true`
/// connection option) the components are treated as UTC so the
/// resulting JS `Date` has the correct UTC epoch regardless of the
/// process TZ. When `utc` is false the components are treated as
/// local time, matching Bun's historical behaviour.
pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject, utc: bool) bun.JSError!f64 {
if (!utc) {
return globalObject.gregorianDateTimeToMS(
this.year,
this.month,
this.day,
this.hour,
this.minute,
this.second,
if (this.microsecond > 0) @intCast(@divFloor(this.microsecond, 1000)) else 0,
);
}
// MySQL in permissive sql_mode can store partial zero-dates like
// "2024-00-15" or "2024-01-00" and send them via the binary
// protocol as non-zero-length payloads. WTF::GregorianDateTime
// would silently wrap month=0 to December of the prior year, so
// validate here and surface NaN instead, matching the text-path
// behaviour in `fromText`.
if (this.month < 1 or this.month > 12 or
this.day < 1 or this.day > daysInMonth(this.year, this.month) or
this.hour > 23 or this.minute > 59 or this.second > 59)
{
return std.math.nan(f64);
}
return globalObject.gregorianDateTimeToMSUTC(
this.year,
this.month,
this.day,
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
Expand Down Expand Up @@ -635,8 +731,8 @@ pub const Value = union(enum) {
};
}

pub fn toJS(this: DateTime, globalObject: *JSC.JSGlobalObject) JSValue {
return JSValue.fromDateNumber(globalObject, this.toJSTimestamp());
pub fn toJS(this: DateTime, globalObject: *JSC.JSGlobalObject, utc: bool) bun.JSError!JSValue {
return JSValue.fromDateNumber(globalObject, try this.toJSTimestamp(globalObject, utc));
}

pub fn fromJS(value: JSValue, globalObject: *JSC.JSGlobalObject) !DateTime {
Expand Down
1 change: 1 addition & 0 deletions src/sql/mysql/js/JSMySQLConnection.zig
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ pub fn onResultRow(this: *@This(), request: *JSMySQLQuery, statement: *MySQLStat
.binary = !request.isSimple(),
.raw = result_mode == .raw,
.bigint = request.isBigintSupported(),
.utc_date = request.isUtcDate(),
};
var structure: JSValue = .js_undefined;
var cached_structure: ?CachedStructure = null;
Expand Down
6 changes: 6 additions & 0 deletions src/sql/mysql/js/JSMySQLQuery.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ pub fn createInstance(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame
const columns: JSValue = args.nextEat() orelse .js_undefined;
const js_bigint: JSValue = args.nextEat() orelse .false;
const js_simple: JSValue = args.nextEat() orelse .false;
const js_utc_date: JSValue = args.nextEat() orelse .false;

const bigint = js_bigint.isBoolean() and js_bigint.asBoolean();
const simple = js_simple.isBoolean() and js_simple.asBoolean();
const utc_date = js_utc_date.isBoolean() and js_utc_date.asBoolean();
if (simple) {
if (try values.getLength(globalThis) > 0) {
return globalThis.throwInvalidArguments("simple query cannot have parameters", .{});
Expand All @@ -76,6 +78,7 @@ pub fn createInstance(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame
try query.toBunString(globalThis),
bigint,
simple,
utc_date,
),
.#globalObject = globalThis,
.#vm = globalThis.bunVM(),
Expand Down Expand Up @@ -309,6 +312,9 @@ pub inline fn isSimple(this: *@This()) bool {
pub inline fn isBigintSupported(this: *@This()) bool {
return this.#query.isBigintSupported();
}
pub inline fn isUtcDate(this: *@This()) bool {
return this.#query.isUtcDate();
}
pub inline fn getResultMode(this: *@This()) SQLQueryResultMode {
return this.#query.getResultMode();
}
Expand Down
12 changes: 9 additions & 3 deletions src/sql/mysql/protocol/DecodeBinaryValue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// with binary collations (e.g., utf8mb4_bin) which have different character_set values.
pub const binary_charset: u16 = 63;

pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.FieldType, column_length: u32, raw: bool, bigint: bool, unsigned: bool, binary: bool, character_set: u16, comptime Context: type, reader: NewReader(Context)) !SQLDataCell {
pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.FieldType, column_length: u32, raw: bool, bigint: bool, utc_date: bool, unsigned: bool, binary: bool, character_set: u16, comptime Context: type, reader: NewReader(Context)) !SQLDataCell {
debug("decodeBinaryValue: {s}", .{@tagName(field_type)});
return switch (field_type) {
.MYSQL_TYPE_TINY => {
Expand Down Expand Up @@ -125,13 +125,19 @@ pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.Fi
},
.MYSQL_TYPE_DATE, .MYSQL_TYPE_TIMESTAMP, .MYSQL_TYPE_DATETIME => switch (try reader.byte()) {
0 => {
return SQLDataCell{ .tag = .date, .value = .{ .date = 0 } };
// MySQL's binary protocol sends a zero-length payload for
// zero-date sentinels like '0000-00-00'. In `utcDate: true`
// mode return NaN so the JS side sees an Invalid Date,
// matching the text-protocol path. Default (local-time)
// mode keeps Bun's historical behaviour of returning the
// Unix epoch.
return SQLDataCell{ .tag = .date, .value = .{ .date = if (utc_date) std.math.nan(f64) else 0 } };
},
11, 7, 4 => |l| {
var data = try reader.read(l);
Comment thread
robobun marked this conversation as resolved.
defer data.deinit();
const time = try DateTime.fromData(&data);
return SQLDataCell{ .tag = .date, .value = .{ .date = try time.toJSTimestamp(globalObject) } };
return SQLDataCell{ .tag = .date, .value = .{ .date = try time.toJSTimestamp(globalObject, utc_date) } };
},
else => error.InvalidBinaryValue,
},
Expand Down
Loading
Loading