Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d12010a
sql: serialize JS arrays in sql(object) as PostgreSQL array literals
robobun Apr 21, 2026
05a6b6a
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 21, 2026
3c3e09e
address review feedback
robobun Apr 21, 2026
096f29f
sql: add uuid_array (OID 2951) and emit SQL NULL for non-finite Date
robobun Apr 21, 2026
07582a2
sql: serialize jsonb[] elements as JSON, not nested PG array dimensions
robobun Apr 21, 2026
15f2da7
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 21, 2026
70670c2
sql: decode int[] / float4[] binary results that contain NULL elements
robobun Apr 21, 2026
519fa97
sql: collapse arrayElementTag and trim array_serializer/test boilerplate
robobun Apr 21, 2026
09185a3
test(29551): fall back to local postgres when docker is unavailable
robobun Apr 21, 2026
05d42cb
sql: mark the binary-null-array cell as owning its backing store
robobun Apr 21, 2026
5f84a4f
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 21, 2026
e14be3b
empty: retrigger CI after stale aggregate buildkite/bun status
robobun Apr 21, 2026
cdf0424
sql: mark parseArray's ArrayList as owned by the cell
robobun Apr 21, 2026
8d3b35c
empty: retrigger past debian-asan bun-install / cron / loader flakes
robobun Apr 21, 2026
bfb2b0b
Revert "sql: mark parseArray's ArrayList as owned by the cell"
robobun Apr 21, 2026
3cc42b1
sql.test: update UUID-array assertion to match new parsed output + gu…
robobun Apr 21, 2026
9970e18
empty: retrigger past expired darwin test shards + bun-install asan f…
robobun Apr 21, 2026
383b5fb
empty: retrigger past debian-asan bun-install shard flake
robobun Apr 21, 2026
f70fbf5
Merge remote-tracking branch 'origin/main' into farm/83c18c6c/fix-sql…
robobun Apr 22, 2026
0dc6a43
test(resolve): give load-file-loader-a-lot 30s under ASAN/debug
robobun Apr 22, 2026
52f86e5
sql: collapse Tag.isArray via comptime, trim narrative comments, drop…
robobun Apr 22, 2026
4d4339b
DataCell: drop stray comment; NodeFSStatBinding: scope Windows S_IF* …
robobun Apr 22, 2026
5f22222
test(29551): restore docker-less Postgres fallback; scale loader test…
robobun Apr 22, 2026
9317fde
Merge remote-tracking branch 'origin/main' into farm/83c18c6c/fix-sql…
robobun Apr 22, 2026
5c5dfa2
Merge main; drop redundant NodeFSStatBinding S_IF* scoping
robobun Apr 22, 2026
0b0fe9d
empty: retrigger past test-worker-nested-uncaught + win-aarch64 socke…
robobun Apr 22, 2026
c399e90
test(29551): add service-free wire-protocol assertions via mock PG ba…
robobun Apr 22, 2026
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
17 changes: 12 additions & 5 deletions src/bun.js/bindings/JSValue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,26 @@ pub const JSValue = enum(i64) {
return bun.jsc.fromJSHostCallGeneric(globalObject, @src(), JSC__JSValue__push, .{ value, globalObject, out });
}

extern fn JSC__JSValue__toISOString(*jsc.JSGlobalObject, jsc.JSValue, *[28]u8) c_int;
pub fn toISOString(this: JSValue, globalObject: *jsc.JSGlobalObject, buf: *[28]u8) []const u8 {
extern fn JSC__JSValue__toISOString(*jsc.JSGlobalObject, jsc.JSValue, *[29]u8) c_int;
/// Writes the ISO 8601 UTC representation of a JS Date into `buf`
/// (e.g. "2024-01-01T00:00:00.000Z", max 28 chars + NUL). Returns an empty
/// slice for non-Date or non-finite values.
pub fn toISOString(this: JSValue, globalObject: *jsc.JSGlobalObject, buf: *[29]u8) []const u8 {
const count = JSC__JSValue__toISOString(globalObject, this, buf);
if (count < 0) {
// Defensive: the C++ side asserts `count < 29` but asserts are
// compiled out in release. Clamp to the buffer so we never slice
// out of bounds even if the binding is ever changed to write more.
if (count < 0 or count > buf.len) {
return "";
}

return buf[0..@as(usize, @intCast(count))];
}
extern fn JSC__JSValue__DateNowISOString(*JSGlobalObject, f64) JSValue;
extern fn JSC__JSValue__DateNowISOString(*JSGlobalObject, *[28]u8) c_int;
pub fn getDateNowISOString(globalObject: *jsc.JSGlobalObject, buf: *[28]u8) []const u8 {
// `JSC__JSValue__DateNowISOString` writes at most 28 bytes (no NUL).
const count = JSC__JSValue__DateNowISOString(globalObject, buf);
if (count < 0) {
if (count < 0 or count > buf.len) {
return "";
}

Expand Down
6 changes: 4 additions & 2 deletions src/bun.js/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5815,7 +5815,6 @@ extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNullTerminatedString(JSC
// this is largely copied from dateProtoFuncToISOString
extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, EncodedJSValue dateValue, char* buf)
{
char buffer[64];
JSC::DateInstance* thisDateObj = JSC::jsDynamicCast<JSC::DateInstance*>(JSC::JSValue::decode(dateValue));
if (!thisDateObj)
return -1;
Expand All @@ -5825,7 +5824,10 @@ extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, Enco

auto& vm = JSC::getVM(globalObject);

return static_cast<int>(Bun::toISOString(vm, thisDateObj->internalNumber(), buffer));
// Bun::toISOString writes up to 28 bytes plus a NUL terminator into
// `buf`. Callers must provide a 29-byte buffer (fits ISO 8601 in UTC with
// millisecond precision, e.g. "2024-01-01T00:00:00.000Z" = 24 chars).
return static_cast<int>(Bun::toISOString(vm, thisDateObj->internalNumber(), buf));
}

extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf)
Expand Down
66 changes: 62 additions & 4 deletions src/sql/postgres/DataCell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,58 @@ fn trySlice(slice: []const u8, count: usize) []const u8 {
if (slice.len <= count) return "";
return slice[count..];
}

/// Binary 1-D numeric array with one or more NULL elements. Typed arrays
/// can't represent holes, so build a regular JS array of per-element cells.
/// Element stream layout (after the 20-byte header):
/// { i32 length, [length]u8 data }* // length = -1 → NULL element
fn decodeBinaryNumericArrayWithNulls(bytes: []const u8, comptime tag: types.Tag) !SQLDataCell {
// Caller has already validated bytes.len >= 12 and dimensions == 1.
if (bytes.len < 20) return error.InvalidBinaryData;
const dim_size_raw: int4 = @bitCast(bytes[12..16].*);
const dim_size: u32 = @bitCast(@byteSwap(dim_size_raw));

const element_bytes = @sizeOf(try tag.byteArrayType());

var array: std.ArrayListUnmanaged(SQLDataCell) = .empty;
Comment thread
robobun marked this conversation as resolved.
errdefer if (array.capacity > 0) array.deinit(bun.default_allocator);
try array.ensureTotalCapacityPrecise(bun.default_allocator, dim_size);

var cursor: usize = 20;
var i: u32 = 0;
while (i < dim_size) : (i += 1) {
if (cursor + 4 > bytes.len) return error.InvalidBinaryData;
const len_raw: i32 = @bitCast(bytes[cursor..][0..4].*);
const len: i32 = @bitCast(@byteSwap(@as(u32, @bitCast(len_raw))));
cursor += 4;

if (len < 0) {
// NULL element (length = -1).
array.appendAssumeCapacity(SQLDataCell{ .tag = .null, .value = .{ .null = 0 } });
continue;
}

Comment thread
robobun marked this conversation as resolved.
if (len != element_bytes) return error.InvalidBinaryData;
if (cursor + element_bytes > bytes.len) return error.InvalidBinaryData;
const value_bytes = bytes[cursor..][0..element_bytes];
cursor += element_bytes;

switch (tag) {
.int4_array => {
const raw: i32 = @bitCast(value_bytes[0..4].*);
array.appendAssumeCapacity(SQLDataCell{ .tag = .int4, .value = .{ .int4 = @byteSwap(raw) } });
},
.float4_array => {
const raw: u32 = @bitCast(value_bytes[0..4].*);
const f: f32 = @bitCast(@byteSwap(raw));
array.appendAssumeCapacity(SQLDataCell{ .tag = .float8, .value = .{ .float8 = @floatCast(f) } });
},
else => comptime unreachable,
}
}

return SQLDataCell{ .tag = .array, .value = .{ .array = .{ .ptr = array.items.ptr, .len = @truncate(array.items.len), .cap = @truncate(array.capacity) } } };
}
fn parseArray(bytes: []const u8, bigint: bool, comptime arrayType: types.Tag, globalObject: *jsc.JSGlobalObject, offset: ?*usize, comptime is_json_sub_array: bool) !SQLDataCell {
Comment thread
robobun marked this conversation as resolved.
const closing_brace = if (is_json_sub_array) ']' else '}';
const opening_brace = if (is_json_sub_array) '[' else '{';
Expand Down Expand Up @@ -215,6 +267,7 @@ fn parseArray(bytes: []const u8, bigint: bool, comptime arrayType: types.Tag, gl
.aclitem_array,
.pg_database_array,
.pg_database_array2,
.uuid_array,
=> {
// this is also a string until we reach "," or "}" but a single word string like Bun
var current_idx: usize = 0;
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -473,10 +526,6 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
return error.MultidimensionalArrayNotSupportedYet;
}

if (contains_nulls != 0) {
return error.NullsInArrayNotSupportedYet;
}

if (dimensions == 0) {
return SQLDataCell{
.tag = .typed_array,
Expand All @@ -491,6 +540,13 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
};
}

if (contains_nulls != 0) {
// Binary 1-D array with NULL elements: can't use the
// typed-array fast path (holes have no in-place slot).
// Decode element-by-element into a regular JS array.
return try decodeBinaryNumericArrayWithNulls(bytes, tag);
}

const elements = (try tag.pgArrayType()).init(bytes).slice();

return SQLDataCell{
Expand Down Expand Up @@ -693,6 +749,8 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
.timestamp_array,
.timestamptz_array,
.interval_array,

.uuid_array,
=> |tag| {
return try parseArray(bytes, bigint, tag, globalObject, null, false);
},
Comment thread
robobun marked this conversation as resolved.
Expand Down
14 changes: 14 additions & 0 deletions src/sql/postgres/PostgresRequest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ pub fn writeBind(
const force_text = is_custom_type or (tag.isBinaryFormatSupported() and brk: {
iter.to(@truncate(i));
if (try iter.next()) |value| {
// If the user passed a JS array for an array-typed parameter,
// we'll emit a PostgreSQL array literal in text format below,
// so request format 0 here too.
if (value.isArray() and tag.isArray()) break :brk true;
break :brk value.isString();
}
if (iter.anyFailed()) {
Expand Down Expand Up @@ -95,6 +99,16 @@ pub fn writeBind(
debug(" -> {s}", .{tag.tagName() orelse "(unknown)"});
}

// When the server-inferred parameter type is a PG array and the user
// passed a plain JS array (e.g. from `sql({ col: [1, 2] })`), emit a
// text-format PG array literal instead of falling through to the
// generic `String.fromJS` path — which would produce the comma-joined
// `Array.prototype.toString` result and get rejected as malformed.
if (value.isArray() and tag.isArray()) {
try types.array_serializer.writeTo(globalObject, value, tag.arrayElementTag(), Context, writer);
continue;
}

switch (
// If they pass a value as a string, let's avoid attempting to
// convert it to the binary representation. This minimizes the room
Expand Down
1 change: 1 addition & 0 deletions src/sql/postgres/PostgresTypes.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub const @"bool" = @import("./types/bool.zig");

pub const array_serializer = @import("./types/array_serializer.zig");
pub const bytea = @import("./types/bytea.zig");
pub const date = @import("./types/date.zig");
pub const json = @import("./types/json.zig");
Expand Down
70 changes: 70 additions & 0 deletions src/sql/postgres/types/Tag.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub const Tag = enum(short) {
varbit = 1562,
numeric = 1700,
uuid = 2950,
uuid_array = 2951,

bool_array = 1000,
bytea_array = 1001,
Expand Down Expand Up @@ -184,6 +185,75 @@ pub const Tag = enum(short) {
return 0;
}

/// Returns true for `*_array` type OIDs (e.g. `text_array`, `int4_array`).
pub fn isArray(this: Tag) bool {
return switch (this) {
.bool_array,
.bytea_array,
.char_array,
.name_array,
.int8_array,
.int2_array,
.int2vector_array,
.int4_array,
.text_array,
.oid_array,
.tid_array,
.xid_array,
.cid_array,
.json_array,
.xml_array,
.point_array,
.lseg_array,
.path_array,
.box_array,
.polygon_array,
.line_array,
.cidr_array,
.float4_array,
.float8_array,
.circle_array,
.macaddr8_array,
Comment thread
robobun marked this conversation as resolved.
Outdated
.money_array,
.macaddr_array,
.inet_array,
.aclitem_array,
.bpchar_array,
.varchar_array,
.date_array,
.time_array,
.timestamp_array,
.timestamptz_array,
.interval_array,
.pg_database_array,
.timetz_array,
.bit_array,
.varbit_array,
.numeric_array,
.jsonb_array,
.jsonpath_array,
.pg_database_array2,
.uuid_array,
=> true,
else => false,
};
}

/// For array type OIDs, returns the element tag that the array serializer
/// treats specially (bool/bytea/json/jsonb for element formatting, box for
/// the `;` delimiter). Every other array — and every non-array tag —
/// maps to `.text`, which is the `String.fromJS` fallback path.
pub fn arrayElementTag(this: Tag) Tag {
return switch (this) {
.bool_array => .bool,
.bytea_array => .bytea,
.json_array => .json,
.jsonb_array => .jsonb,
.box_array => .box,
else => .text,
};
}

fn PostgresBinarySingleDimensionArray(comptime T: type) type {
return extern struct {
// struct array_int4 {
Expand Down
Loading
Loading