Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
7 changes: 5 additions & 2 deletions src/bun.js/bindings/JSValue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,11 @@ 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) {
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 + NUL into `buf`. The caller
// is expected to provide a 28-byte buffer (it fits ISO 8601 in UTC with
// millisecond precision, e.g. "2024-01-01T00:00:00.000Z" = 24 chars).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return static_cast<int>(Bun::toISOString(vm, thisDateObj->internalNumber(), buf));
Comment thread
robobun marked this conversation as resolved.
Outdated
}

extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf)
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
107 changes: 107 additions & 0 deletions src/sql/postgres/types/Tag.zig
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,113 @@
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,
=> true,
else => false,
};
}

/// For array type OIDs, returns the element type tag. For element types
/// that the binding layer treats as plain text (e.g. `tid`, `jsonpath`),
/// returns `.text`. Returns the input tag unchanged for non-array types.
pub fn arrayElementTag(this: Tag) Tag {
return switch (this) {
.bool_array => .bool,
.bytea_array => .bytea,
.char_array => .char,
.name_array => .name,
.int8_array => .int8,
.int2_array => .int2,
.int2vector_array => .int2vector,
.int4_array => .int4,
.text_array => .text,
.oid_array => .oid,
.xid_array => .xid,
.cid_array => .cid,
.json_array => .json,
.xml_array => .xml,
.point_array => .point,
.lseg_array => .lseg,
.path_array => .path,
.box_array => .box,
.polygon_array => .polygon,
.line_array => .line,
.cidr_array => .cidr,
.float4_array => .float4,
.float8_array => .float8,
.circle_array => .circle,
.macaddr8_array => .macaddr8,
.money_array => .money,
.macaddr_array => .macaddr,
.inet_array => .inet,
.aclitem_array => .aclitem,
.bpchar_array => .bpchar,
.varchar_array => .varchar,
.date_array => .date,
.time_array => .time,
.timestamp_array => .timestamp,
.timestamptz_array => .timestamptz,
.interval_array => .interval,
.pg_database_array, .pg_database_array2 => .pg_database,
.timetz_array => .timetz,
.bit_array => .bit,
.varbit_array => .varbit,
.numeric_array => .numeric,
.jsonb_array => .jsonb,
// `tid`, `jsonpath` have no Tag enum member; they serialize as
// plain text strings just like the `else` fallback in `writeBind`.
.tid_array, .jsonpath_array => .text,

Check warning on line 289 in src/sql/postgres/types/Tag.zig

View check run for this annotation

Claude / Claude Code Review

arrayElementTag() comment incorrectly claims jsonpath has no Tag enum member

The comment at Tag.zig:287-288 incorrectly states that both `tid` and `jsonpath` have no Tag enum member, but `jsonpath = 4072` is a named member of the enum (line ~160); only `tid` is missing (commented out). The functional behavior is correct — returning `.text` for `jsonpath_array` elements is fine since `writeElement` has no special case for `.jsonpath` — but the inaccurate comment could mislead a maintainer into thinking they can safely treat `.jsonpath` as if no enum member exists.
else => this,
};
}

fn PostgresBinarySingleDimensionArray(comptime T: type) type {
return extern struct {
// struct array_int4 {
Expand Down
185 changes: 185 additions & 0 deletions src/sql/postgres/types/array_serializer.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! Serializes a JS array to a PostgreSQL text-format array literal so it can
//! be bound to a parameter whose server-inferred type is a PG array (e.g.
//! `text[]`, `int4[]`, `jsonb[]`).
//!
//! Used when `sql(object)` is expanded for INSERT/UPDATE and a property value
//! is a plain JS array — the server infers the parameter type from the column,
//! and we need to emit a valid literal in that representation.
//!
//! Element formatting mirrors `arrayValueSerializer` in
//! `src/js/internal/sql/postgres.ts`, with extra bytea/json handling for
//! cases only reachable at bind time (when we know the server-inferred type).
//!
//! PG accepts quoted element values for most array types (`{"1"}` for
//! `int4[]`, `{"true"}` for `bool[]`, `{"2024-01-01"}` for `date[]`), so
//! quoting universally keeps the serializer simple. We still special-case
//! element types that need specific encoding:
//! - `bytea[]` -> `"\x<hex>"`
//! - `json[]`/`jsonb[]` -> JSON.stringify, quoted
//! - `bool[]` -> bare `t`/`f` (compact + canonical)
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 {
if (!value.isArray()) {
try writer.write("{}");
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Comment thread
robobun marked this conversation as resolved.
var iter = try jsc.JSArrayIterator.init(value, globalObject);
if (iter.len == 0) {
try writer.write("{}");
return;
}

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.

// Nested array -> recurse (multi-dimensional arrays share one element type).
if (value.isArray()) {
try writeArrayLiteral(globalObject, value, element_tag, Context, writer);
return;
}

Check failure on line 80 in src/sql/postgres/types/array_serializer.zig

View check run for this annotation

Claude / Claude Code Review

json/jsonb[] array elements treated as nested PG array dimensions instead of JSON-serialized

When inserting into a jsonb[] (or json[]) column, JS array elements are incorrectly serialized as nested PostgreSQL array dimensions instead of being JSON-stringified. In writeElement() (array_serializer.zig:77-80), the value.isArray() check fires before the json/jsonb handler at lines 100-110, so an element like [1,2] in a jsonb[] column produces {{"1","2"}} (a 2D PG array) instead of the correct {"[1,2]"} (a 1D jsonb array). Fix by moving the json/jsonb element_tag check before the isArray() c
Comment thread
robobun marked this conversation as resolved.
Outdated

// bytea[] elements are Buffers / typed arrays — encode as `"\x<hex>"`.
if (element_tag == .bytea) {
if (value.asArrayBuffer(globalObject)) |buf| {
try writer.write("\"\\\\x");
try writeHex(Context, writer, buf.byteSlice());
try writer.write("\"");
return;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// bool[] -> canonical `t`/`f` for booleans.
if (element_tag == .bool and value.isBoolean()) {
try writer.write(if (value.toBoolean()) "t" else "f");
return;
}

// JSON / JSONB: each element is stringified with the SIMD-optimized path
// and embedded as a quoted, escaped string.
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;
}

// Date -> ISO 8601 in UTC, quoted.
if (value.isDate()) {
var iso_buf: [29]u8 = undefined;
const iso = value.toISOString(globalObject, &iso_buf);
if (iso.len > 0) {
try writer.write("\"");
try writer.write(iso);
try writer.write("\"");
return;
}
// Non-finite Date (NaN, ±Infinity) falls through to the default
// String.fromJS path below.
}
Comment thread
robobun marked this conversation as resolved.
Outdated

// Default: stringify via `String.fromJS` (uses `Array.prototype.toString`
// for nested JS arrays, but we handled those above). Quote and
// backslash-escape — universally safe for text[], int[], numeric[],
// date[], etc.
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