Skip to content
Closed
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
5 changes: 4 additions & 1 deletion src/jsc/bindings/SQLClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,10 @@ extern "C" void JSC__putDirectOffset(JSC::VM* vm, JSC::EncodedJSValue object, ui
}
extern "C" uint32_t JSC__JSObject__maxInlineCapacity = JSC::JSFinalObject::maxInlineCapacity;

// PostgreSQL time formatting helpers - following WebKit's pattern
// PostgreSQL time formatting helpers - following WebKit's pattern.
// formatTime is also shared by the MySQL binary TIME decoder, which emits the
// same shape (zero-padded components, fractional seconds trimmed of trailing
// zeros); MySQL's sign is prepended on the Rust side.
extern "C" size_t Postgres__formatTime(int64_t microseconds, char* buffer, size_t bufferSize)
{
// Convert microseconds since midnight to time components
Expand Down
65 changes: 32 additions & 33 deletions src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::jsc::JSGlobalObject;
use crate::mysql::my_sql_value::{DateTime, Time};
use crate::postgres::data_cell::Postgres__formatTime;
use crate::shared::sql_data_cell::SQLDataCell;
use crate::shared::sql_data_cell::{Tag as CellTag, Value as CellValue};
use bun_sql::mysql::mysql_types as types;
Expand Down Expand Up @@ -246,39 +247,37 @@ pub fn decode_binary_value<Context: ReaderContext>(
let data = reader.read(l as usize)?;
let time = Time::from_data(&data)?;

let total_hours: u32 = time.hours as u32 + time.days * 24;
// -838:59:59 to 838:59:59 is valid (it only store seconds)
// it should be represented as HH:MM:SS or HHH:MM:SS if total_hours > 99
let mut buffer = [0u8; 32];
let sign: &str = if time.negative { "-" } else { "" };
let slice: &[u8] = 'brk: {
use std::io::Write;
let mut w = &mut buffer[..];
if total_hours > 99 {
if write!(
w,
"{}{:03}:{:02}:{:02}",
sign, total_hours, time.minutes, time.seconds
)
.is_err()
{
return Err(bun_core::err!("InvalidBinaryValue"));
}
} else {
if write!(
w,
"{}{:02}:{:02}:{:02}",
sign, total_hours, time.minutes, time.seconds
)
.is_err()
{
return Err(bun_core::err!("InvalidBinaryValue"));
}
}
let remaining = w.len();
break 'brk &buffer[..32 - remaining];
};
// PORT NOTE: reshaped for borrowck — compute remaining before re-borrowing buffer
// -838:59:59.999999 to 838:59:59.999999 is valid, rendered as
// [-][H]HH:MM:SS with the fractional part zero-padded to 6
// digits and trimmed of trailing zeros ("02:03:04.5"), matching
// the mysql2 driver. That is exactly the shape the Postgres
// TIME decoder emits, so reuse its formatter; only the sign is
// MySQL-specific (the wire form carries absolute components
// plus an is_negative byte). Totals are computed in u64 and
// clamped to MySQL's hard cap so a malformed day count cannot
// overflow the formatter's i64.
const MAX_TIME_MICROS: u64 =
((838 * 3600 + 59 * 60 + 59) * 1_000_000) + 999_999;
let total_seconds = (time.days as u64 * 24 + time.hours as u64) * 3600
+ time.minutes as u64 * 60
+ time.seconds as u64;
let total_micros = total_seconds
.saturating_mul(1_000_000)
.saturating_add(time.microseconds as u64)
.min(MAX_TIME_MICROS) as i64;

let mut buffer = [0u8; 33];
let mut len = 0usize;
if time.negative {
buffer[0] = b'-';
len = 1;
}
let body: &mut [u8; 32] = (&mut buffer[len..len + 32])
.try_into()
.expect("infallible: slice length is 32");
len += Postgres__formatTime(total_micros, body, 32);
let slice = &buffer[..len];

Ok(SQLDataCell {
tag: CellTag::String,
value: CellValue {
Expand Down
10 changes: 7 additions & 3 deletions src/sql_jsc/postgres/DataCell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1597,9 +1597,13 @@ unsafe extern "C" {
// `&mut [u8; N]` is ABI-identical to `*mut u8` (thin pointer to a `Sized`
// array == pointer to its first element); the reference type discharges the
// "valid for `buffer_size` bytes" precondition, so → `safe fn`. The C++
// side never writes past `buffer_size`, and the only two callers pass
// exactly these fixed-size stack arrays.
safe fn Postgres__formatTime(
// side never writes past `buffer_size`, and every caller passes exactly
// these fixed-size stack arrays.
//
// `pub(crate)`: the MySQL binary TIME decoder shares this formatter — the
// output shape (zero-padded components, fractional seconds trimmed of
// trailing zeros) is identical for both drivers.
pub(crate) safe fn Postgres__formatTime(
microseconds: i64,
buffer: &mut [u8; 32],
buffer_size: usize,
Expand Down
231 changes: 231 additions & 0 deletions test/js/sql/sql-mysql-time-fraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// The binary-protocol TIME decoder parsed the 12-byte wire form's
// microseconds field but never wrote it, so TIME(6) values lost their
// fractional part ("02:03:04.5" decoded as "02:03:04"). The fractional part is
// emitted zero-padded to 6 digits with trailing zeros stripped, matching the
// mysql2 driver; the docker-backed "time with fractional seconds" test in
// sql-mysql.test.ts asserts the same values against a real server.
//
// Uses a minimal mock MySQL server so the test runs without Docker.

import { SQL } from "bun";
import { expect, test } from "bun:test";
import { once } from "events";
import net from "net";

// --- MySQL wire format helpers ---------------------------------------------

function u16le(n: number): Buffer {
return Buffer.from([n & 0xff, (n >> 8) & 0xff]);
}
function u24le(n: number): Buffer {
return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]);
}
function u32le(n: number): Buffer {
return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]);
}
function packet(seq: number, payload: Buffer): Buffer {
return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]);
}
function lenencStr(s: string): Buffer {
const buf = Buffer.from(s, "utf-8");
if (buf.length >= 0xfb) throw new Error("too long for 1-byte lenenc");
return Buffer.concat([Buffer.from([buf.length]), buf]);
}

// --- Capability flags ------------------------------------------------------

const CLIENT_PROTOCOL_41 = 1 << 9;
const CLIENT_SECURE_CONNECTION = 1 << 15;
const CLIENT_PLUGIN_AUTH = 1 << 19;
const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21;
const CLIENT_DEPRECATE_EOF = 1 << 24;
const SERVER_CAPS =
CLIENT_PROTOCOL_41 |
CLIENT_SECURE_CONNECTION |
CLIENT_PLUGIN_AUTH |
CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA |
CLIENT_DEPRECATE_EOF;

const MYSQL_TYPE_TIME = 0x0b;
const BINARY_FLAG = 1 << 7; // ColumnFlags::BINARY
const BINARY_CHARSET = 63; // the "binary" pseudo-charset

// --- Packet builders -------------------------------------------------------

function handshakeV10(): Buffer {
const authData1 = Buffer.alloc(8, 0x61);
const authData2 = Buffer.alloc(13, 0x62);
authData2[12] = 0;
return packet(
0,
Buffer.concat([
Buffer.from([10]),
Buffer.from("mock-5.7.0\0"),
u32le(1),
authData1,
Buffer.from([0]),
u16le(SERVER_CAPS & 0xffff),
Buffer.from([0x2d]),
u16le(0x0002),
u16le((SERVER_CAPS >>> 16) & 0xffff),
Buffer.from([21]),
Buffer.alloc(10, 0),
authData2,
Buffer.from("mysql_native_password\0"),
]),
);
}

function okPacket(seq: number, header = 0x00): Buffer {
return packet(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]));
}

// TIME(6) column: binary charset + BINARY flag + 6 decimals, the metadata a
// real server attaches.
function timeColumn(): Buffer {
return Buffer.concat([
lenencStr("def"),
lenencStr(""),
lenencStr("t"),
lenencStr("t"),
lenencStr("a"),
lenencStr("a"),
Buffer.from([0x0c]),
u16le(BINARY_CHARSET),
u32le(17),
Buffer.from([MYSQL_TYPE_TIME]),
u16le(BINARY_FLAG),
Buffer.from([6]), // decimals
Buffer.from([0, 0]),
]);
}

// Binary TIME wire value: length byte (8 or 12), then is_negative(1),
// days(4 LE), hours(1), minutes(1), seconds(1), and for the 12-byte form
// microseconds(4 LE).
function binaryTime(t: { negative?: boolean; days?: number; h: number; m: number; s: number; us?: number }): Buffer {
const head = Buffer.concat([
Buffer.from([t.us === undefined ? 8 : 12, t.negative ? 1 : 0]),
u32le(t.days ?? 0),
Buffer.from([t.h, t.m, t.s]),
]);
return t.us === undefined ? head : Buffer.concat([head, u32le(t.us)]);
}

const binaryRows = [
binaryTime({ h: 2, m: 3, s: 4 }), // 8-byte form, no microseconds field
binaryTime({ h: 2, m: 3, s: 4, us: 500000 }),
binaryTime({ h: 2, m: 3, s: 4, us: 123456 }),
binaryTime({ negative: true, h: 2, m: 3, s: 4, us: 123456 }),
binaryTime({ days: 34, h: 22, m: 59, s: 58, us: 999999 }), // 838:59:58.999999
binaryTime({ h: 2, m: 3, s: 4, us: 500 }), // sub-millisecond
binaryTime({ h: 2, m: 3, s: 4, us: 0 }), // 12-byte form, zero microseconds
];

// Text-protocol rows: the server sends the column at its declared precision,
// passed through as-is.
const textRows = [
"02:03:04.000000",
"02:03:04.500000",
"02:03:04.123456",
"-02:03:04.123456",
"838:59:58.999999",
"02:03:04.000500",
"02:03:04.000000",
];

function stmtPrepareOK(startSeq: number, stmtId: number): Buffer {
let seq = startSeq;
return Buffer.concat([
packet(
seq++,
Buffer.concat([Buffer.from([0x00]), u32le(stmtId), u16le(1), u16le(0), Buffer.from([0x00]), u16le(0)]),
),
packet(seq++, timeColumn()),
]);
}

function binaryResultSet(startSeq: number): Buffer {
let seq = startSeq;
const packets = [packet(seq++, Buffer.from([1])), packet(seq++, timeColumn())];
for (const value of binaryRows) {
// 0x00 row header, 1-byte NULL bitmap (nothing null), then the value.
packets.push(packet(seq++, Buffer.concat([Buffer.from([0x00, 0x00]), value])));
}
packets.push(okPacket(seq++, 0xfe));
return Buffer.concat(packets);
}

function textResultSet(startSeq: number): Buffer {
let seq = startSeq;
const packets = [packet(seq++, Buffer.from([1])), packet(seq++, timeColumn())];
for (const value of textRows) {
packets.push(packet(seq++, lenencStr(value)));
}
packets.push(okPacket(seq++, 0xfe));
return Buffer.concat(packets);
}

function startMockServer(): net.Server {
const server = net.createServer(socket => {
let buffered = Buffer.alloc(0);
let authed = false;
let stmtId = 0;
socket.write(handshakeV10());
socket.on("data", chunk => {
buffered = Buffer.concat([buffered, chunk]);
while (buffered.length >= 4) {
const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16);
if (buffered.length < 4 + len) break;
const seq = buffered[3];
const payload = buffered.subarray(4, 4 + len);
buffered = buffered.subarray(4 + len);
if (!authed) {
authed = true;
socket.write(okPacket(seq + 1));
continue;
}
const cmd = payload[0];
if (cmd === 0x16 /* COM_STMT_PREPARE */) {
socket.write(stmtPrepareOK(seq + 1, ++stmtId));
} else if (cmd === 0x17 /* COM_STMT_EXECUTE */) {
socket.write(binaryResultSet(seq + 1));
} else if (cmd === 0x03 /* COM_QUERY */) {
socket.write(textResultSet(seq + 1));
} else if (cmd === 0x19 /* COM_STMT_CLOSE */) {
// no response expected
} else {
socket.end();
}
}
});
});
server.listen(0, "127.0.0.1");
return server;
}

test("binary TIME keeps fractional seconds", async () => {
const server = startMockServer();
await once(server, "listening");
const { port } = server.address() as net.AddressInfo;
try {
await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 });

// Binary protocol (prepared statement): fractional part zero-padded to 6
// digits with trailing zeros stripped, omitted entirely when zero.
expect(await sql`SELECT a FROM times`).toEqual([
{ a: "02:03:04" },
{ a: "02:03:04.5" },
{ a: "02:03:04.123456" },
{ a: "-02:03:04.123456" },
{ a: "838:59:58.999999" },
{ a: "02:03:04.0005" },
{ a: "02:03:04" },
]);

// Text protocol (`.simple()`) passes the server's string through verbatim.
expect(await sql`SELECT a FROM times`.simple()).toEqual(textRows.map(a => ({ a })));
} finally {
await new Promise<void>(r => server.close(() => r()));
}
});
39 changes: 39 additions & 0 deletions test/js/sql/sql-mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,45 @@ if (isDockerEnabled()) {
expect(result2).toEqual(times);
});

test("time with fractional seconds", async () => {
await using sql = new SQL({ ...getOptions(), max: 1 });
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a TIME(6))`;
const times = [
{ a: "02:03:04" },
{ a: "02:03:04.5" },
{ a: "02:03:04.123456" },
{ a: "-02:03:04.123456" },
{ a: "838:59:58.999999" },
{ a: null },
];
await sql`INSERT INTO ${sql(random_name)} ${sql(times)}`;

// Binary protocol: matches the mysql2 driver — fractional part with trailing
// zeros stripped, omitted entirely when zero.
const result = await sql`SELECT * FROM ${sql(random_name)}`;
expect(result).toEqual([
{ a: "02:03:04" },
{ a: "02:03:04.5" },
{ a: "02:03:04.123456" },
{ a: "-02:03:04.123456" },
{ a: "838:59:58.999999" },
{ a: null },
]);

// Text protocol: server sends the column at its declared precision, passed
// through as-is.
const result2 = await sql`SELECT * FROM ${sql(random_name)}`.simple();
expect(result2).toEqual([
{ a: "02:03:04.000000" },
{ a: "02:03:04.500000" },
{ a: "02:03:04.123456" },
{ a: "-02:03:04.123456" },
{ a: "838:59:58.999999" },
{ a: null },
]);
});

test("date", async () => {
await using sql = new SQL({ ...getOptions(), max: 1 });
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
Expand Down
Loading