Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
13 changes: 8 additions & 5 deletions src/bun.js/bindings/JSValue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,22 @@ 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) {
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 {
const count = JSC__JSValue__DateNowISOString(globalObject, buf);
if (count < 0) {
if (count < 0 or count > buf.len) {
return "";
}

Expand Down
3 changes: 1 addition & 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,7 @@ 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));
return static_cast<int>(Bun::toISOString(vm, thisDateObj->internalNumber(), buf));
}

extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf)
Expand Down
71 changes: 67 additions & 4 deletions src/sql/postgres/DataCell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,66 @@ 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));

// Reject impossibly-large `dim_size` up front: every element carries at
// least a 4-byte length prefix. Without this a malformed header could
// drive an allocation far larger than the payload can justify.
if (dim_size > (bytes.len - 20) / 4) return error.InvalidBinaryData;

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 = @byteSwap(len_raw);
cursor += 4;

if (len < 0) {
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) } },
.free_value = 1,
};
}
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 +275,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 +534,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 +548,10 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
};
}

if (contains_nulls != 0) {
return try decodeBinaryNumericArrayWithNulls(bytes, tag);
}

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

return SQLDataCell{
Expand Down Expand Up @@ -693,6 +754,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
10 changes: 10 additions & 0 deletions src/sql/postgres/PostgresRequest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub fn writeBind(
const force_text = is_custom_type or (tag.isBinaryFormatSupported() and brk: {
iter.to(@truncate(i));
if (try iter.next()) |value| {
// JS array bound to a PG array param → text-format literal below.
if (value.isArray() and tag.isArray()) break :brk true;
break :brk value.isString();
}
if (iter.anyFailed()) {
Expand Down Expand Up @@ -95,6 +97,14 @@ pub fn writeBind(
debug(" -> {s}", .{tag.tagName() orelse "(unknown)"});
}

// JS array bound to a PG array param → emit a text-format array
// literal. Falling through to `String.fromJS` would produce the
// comma-joined `Array.prototype.toString` result, which PG rejects.
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
23 changes: 23 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,28 @@ 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) {
.pg_database_array2 => true,
_ => false,
inline else => |t| comptime std.mem.endsWith(u8, @tagName(t), "_array"),
};
}

/// Element tag the array serializer needs to special-case; everything
/// else falls through to the `.text` → `String.fromJS` 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
168 changes: 168 additions & 0 deletions src/sql/postgres/types/array_serializer.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! Emits a PG text-format array literal (e.g. `{"a","b"}`) for a JS array
//! bound to an array-typed parameter — used by `sql(object)` expansion.
//! Mirrors `arrayValueSerializer` in `src/js/internal/sql/postgres.ts`.
pub const Error = AnyPostgresError || bun.JSError;

pub fn writeTo(
globalObject: *jsc.JSGlobalObject,
value: JSValue,
element_tag: Tag,
comptime Context: type,
writer: protocol.NewWriter(Context),
) Error!void {
const l = try writer.length();
try writeArrayLiteral(globalObject, value, element_tag, Context, writer);
try l.writeExcludingSelf();
}

fn writeArrayLiteral(
globalObject: *jsc.JSGlobalObject,
value: JSValue,
element_tag: Tag,
comptime Context: type,
writer: protocol.NewWriter(Context),
) Error!void {
// Both entry points gate on `value.isArray()` before dispatching here —
// `writeTo` via `writeBind` in `PostgresRequest.zig`, and the recursive
// call in `writeElement` below. A non-array would be a caller bug.
bun.assert(value.isArray());

Comment thread
robobun marked this conversation as resolved.
var iter = try jsc.JSArrayIterator.init(value, globalObject);
try writer.write("{");
// `box[]` is the sole PG array type whose element delimiter is `;`.
const delimiter: []const u8 = if (element_tag == .box) ";" else ",";
var first = true;
while (try iter.next()) |element| {
if (!first) try writer.write(delimiter);
first = false;
try writeElement(globalObject, element, element_tag, Context, writer);
}
Comment thread
robobun marked this conversation as resolved.
try writer.write("}");
}

fn writeElement(
globalObject: *jsc.JSGlobalObject,
value: JSValue,
element_tag: Tag,
comptime Context: type,
writer: protocol.NewWriter(Context),
) Error!void {
if (value.isEmptyOrUndefinedOrNull()) {
try writer.write("null");
return;
}
Comment thread
claude[bot] marked this conversation as resolved.

// JSON / JSONB elements are JSON values — arrays/objects/primitives all
// get stringified and embedded as a quoted, escaped string. Must precede
// the `isArray` branch so e.g. a `jsonb[]` value `[[1,2],[3,4]]`
// becomes the 1-D literal `{"[1,2]","[3,4]"}` (two jsonb values), not
// a 2-D PG array `{{"1","2"},{"3","4"}}`.
if (element_tag == .json or element_tag == .jsonb) {
var str = bun.String.empty;
defer str.deref();
try value.jsonStringifyFast(globalObject, &str);
const slice = str.toUTF8WithoutRef(bun.default_allocator);
defer slice.deinit();
try writer.write("\"");
try writeEscaped(Context, writer, slice.slice());
try writer.write("\"");
return;
}

if (value.isArray()) {
try writeArrayLiteral(globalObject, value, element_tag, Context, writer);
return;
}

// bytea[] elements are Buffers — encode as `"\x<hex>"`. Typed arrays
// are NOT matched here; they fall through to the array/default path so
// they serialize as their own array of numeric elements. This mirrors
// `arrayValueSerializer` in `src/js/internal/sql/postgres.ts` which
// gates on `Buffer.isBuffer(value)`, not on `ArrayBuffer.isView`.
if (element_tag == .bytea and value.isBuffer(globalObject)) {
if (value.asArrayBuffer(globalObject)) |buf| {
try writer.write("\"\\\\x");
try writeHex(Context, writer, buf.byteSlice());
try writer.write("\"");
return;
}
}

if (element_tag == .bool and value.isBoolean()) {
try writer.write(if (value.toBoolean()) "t" else "f");
return;
}

// Date -> ISO 8601 in UTC, quoted. `toISOString` returns an empty slice
// for non-finite dates (NaN, ±Infinity); emit NULL rather than falling
// through to `String.fromJS`, which would yield `"Invalid Date"` — PG
// rejects that as invalid input syntax for any date/timestamp column.
if (value.isDate()) {
var iso_buf: [29]u8 = undefined;
const iso = value.toISOString(globalObject, &iso_buf);
if (iso.len == 0) {
try writer.write("null");
return;
}
try writer.write("\"");
try writer.write(iso);
try writer.write("\"");
return;
}

var str = try bun.String.fromJS(value, globalObject);
if (str.tag == .Dead) return error.OutOfMemory;
defer str.deref();
const slice = str.toUTF8WithoutRef(bun.default_allocator);
defer slice.deinit();

try writer.write("\"");
try writeEscaped(Context, writer, slice.slice());
try writer.write("\"");
}

/// PG array-literal element escape: backslash and double-quote are prefixed
/// with a backslash. Mirrors `arrayEscape` in
/// `src/js/internal/sql/postgres.ts`.
fn writeEscaped(
comptime Context: type,
writer: protocol.NewWriter(Context),
bytes: []const u8,
) AnyPostgresError!void {
var start: usize = 0;
for (bytes, 0..) |c, i| {
switch (c) {
'\\', '"' => {
if (i > start) try writer.write(bytes[start..i]);
const escape_pair: [2]u8 = .{ '\\', c };
try writer.write(&escape_pair);
start = i + 1;
},
else => {},
}
}
if (start < bytes.len) try writer.write(bytes[start..]);
}

const hex_digits = "0123456789abcdef";

fn writeHex(
comptime Context: type,
writer: protocol.NewWriter(Context),
bytes: []const u8,
) AnyPostgresError!void {
var buf: [2]u8 = undefined;
for (bytes) |b| {
buf[0] = hex_digits[b >> 4];
buf[1] = hex_digits[b & 0xf];
try writer.write(&buf);
}
}

const bun = @import("bun");
const protocol = @import("../PostgresProtocol.zig");
const AnyPostgresError = @import("../AnyPostgresError.zig").AnyPostgresError;
const Tag = @import("./Tag.zig").Tag;

const jsc = bun.jsc;
const JSValue = jsc.JSValue;
Loading
Loading