Skip to content

sql: serialize object/array JSON parameters correctly when prepare: false - #30222

Closed
robobun wants to merge 8 commits into
mainfrom
farm/bcf8ce18/sql-prepare-false-json-object
Closed

robobun wants to merge 8 commits into
mainfrom
farm/bcf8ce18/sql-prepare-false-json-object

Conversation

@robobun

@robobun robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30221.

Repro

import { SQL } from 'bun';
const sql = new SQL({ url: process.env.DATABASE_URL!, prepare: false });
await sql`SELECT ${{ hello: 'world' }}::jsonb AS value`;
// PostgresError: invalid input syntax for type json
//   detail: "Token \"object\" is invalid."
//   where: "JSON data, line 1: [object..."

Cause

With prepare: false, the Parse message is sent with parameter OID
0 to let Postgres infer the type from context (e.g. from the
::jsonb cast in the query). That same OID then drives the value-
encoding switch in writeBind:

// src/sql/postgres/PostgresRequest.zig — pre-fix
const parameter_field = parameter_fields[i];
const is_custom_type = std.math.maxInt(short) < parameter_field;
break :brk if (is_custom_type) .text else @enumFromInt(@as(short, @intCast(parameter_field)));

@enumFromInt(0) is not a defined Tag, so the switch falls through
to else, which calls String.fromJS:

else => {
    const str = try String.fromJS(value, globalObject); // → "[object Object]"
    ...
}

String.fromJS on a plain JS object invokes Object.prototype.toString
("[object Object]"); arrays get Array.prototype.toString
("1,2,3"). Postgres rejects both for any json/jsonb cast.

The prepare: true path worked because ParameterDescription fills
statement.parameters with real OIDs (114 / 3802), which route
to the existing .jsonb, .json branch → jsonStringifyFast.

Fix

When parameter_field == 0, re-derive the tag from the JS value with
types.Tag.fromJS (the same helper Signature.generate already uses
to classify the slot). If the value is JSON-shaped (.json /
.jsonb), dispatch to the JSON branch so jsonStringifyFast runs.
Everything else keeps its existing path — Dates, typed arrays,
BigInts, Bools, etc. are unchanged.

Format codes don't need adjusting: for an unspecified slot the first
loop already writes 0 (text), and .json/.jsonb are text-format
types, so serializer and format-code agree.

Verification

New tests in test/js/sql/sql-prepare-false.test.ts cover:

  • object → ::jsonb
  • object → ::json
  • nested object (with inner array) → ::jsonb
  • array → ::jsonb

Manual repro against local Postgres:

  • All four fail on USE_SYSTEM_BUN=1 (22P02 invalid input syntax).
  • All four pass on the debug build.

Regression checks that still produce identical output pre/post-fix on
prepare: false: int/text/float8 scalars, Date without cast,
Uint8Array → ::bytea, BigInt → ::int8, true → ::bool, and
Int32Array → ::int[] (still surfaces the pre-existing
malformed array literal" 1,2,3" — tracked by #29551 / #29552).

@coderabbitai

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

The PR fixes JSON/JSONB parameter serialization in SQL({ prepare: false }) mode. When a parameter type is unspecified (parameter_field == 0), the binding logic now infers the actual type from the JavaScript value and selects the appropriate tag (.json or .jsonb), ensuring objects are encoded as JSON text rather than "[object Object]".

Changes

JSON/JSONB Parameter Serialization for Unprepared Queries

Layer / File(s) Summary
Core Type Inference
src/sql/postgres/PostgresRequest.zig
writeBind now special-cases parameter_field == 0 to infer the actual tag from the JS value using types.Tag.fromJS(...), selecting .json or .jsonb tags appropriately instead of falling back to .text or direct enum mapping.
Wire Protocol Tests
test/js/sql/sql-prepare-false.test.ts (lines 1–213)
New mock-backend test suite validates that objects, nested objects, and arrays bound to ::jsonb and ::json casts are correctly encoded as JSON text in the Bind message payload, not as "[object Object]".
Round-Trip Validation
test/js/sql/sql-prepare-false.test.ts (lines 342–366)
Docker-backed integration tests confirm that prepare: false with JSON/JSONB parameters correctly returns parsed JSON structures when read back from PostgreSQL.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: fixing JSON parameter serialization for the prepare: false code path.
Description check ✅ Passed The description includes repro steps, cause analysis, fix explanation, and verification details covering the changed behavior and test coverage.
Linked Issues check ✅ Passed The PR directly addresses issue #30221 by implementing logic to re-derive the Tag from JS values when parameter_field==0 and route json/jsonb values through jsonStringifyFast for correct serialization.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing JSON parameter serialization in prepare: false mode, including core logic in PostgresRequest.zig and comprehensive test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 2/5 reviews remaining, refill in 29 minutes and 19 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:07 PM PT - May 4th, 2026

❌ @robobun, your commit 65062bc has 2 failures in Build #51419 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30222

That installs a local version of the PR into your bun-30222 executable, so you can run:

bun-30222 --bun

@github-actions github-actions Bot added the claude label May 4, 2026
@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix: #28819 Fixes double encoding

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bug(SQL): bun-sql double encodes all values inserted into json and jsonb columns with Postgres 18, their resulting type is always string #28819 - Fixes double-encoding of JSON/JSONB values when prepare: false by routing objects/arrays through jsonStringifyFast instead of String.fromJS()

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28819

Generated with Claude Code (https://claude.ai/code)

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bug(SQL): bun-sql double encodes all values inserted into json and jsonb columns with Postgres 18, their resulting type is always string #28819 - Fixes double-encoding of JSON/JSONB values when prepare: false by routing objects/arrays through jsonStringifyFast instead of String.fromJS()

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28819

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — narrow, well-tested fix that routes object/array params through JSON.stringify when the OID is unspecified.

Extended reasoning...

Overview

This PR fixes #30221 by changing 4 lines of logic (plus a detailed comment) in src/sql/postgres/PostgresRequest.zig:writeBind, and adds 4 regression tests in test/js/sql/sql-prepare-false.test.ts. When prepare: false sends Parse with OID 0, the Bind serializer previously hit @enumFromInt(0) → undefined tag → else branch → String.fromJS → "[object Object]". The fix re-derives the tag via types.Tag.fromJS for the parameter_field == 0 case and routes .json/.jsonb results through the existing jsonStringifyFast branch.

Verification

I confirmed:

  • Tag.fromJS (Tag.zig:321) returns .json for plain objects and array-likes, so the new branch correctly captures both repro shapes.
  • .json/.jsonb are not in isBinaryFormatSupported (Tag.zig:170), so they serialize as text — consistent with the format code 0 already written for OID-0 slots in the first loop. No format/payload mismatch.
  • Signature.generate (Signature.zig:78) appends 0 for .json/.jsonb/.text/.timestamptz/etc., and the fix only diverts the .json/.jsonb subset; strings, Dates, and nulls retain their existing (working) paths. Null values hit Tag.fromJS → .numeric → fall through → handled by the existing isEmptyOrUndefinedOrNull check immediately after.
  • Tag.fromJS can throw on NumberObject/BooleanObject/non-indexable objects, but those would already have thrown in Signature.generate before writeBind is reached, so no new error surface.

Security risks

None. Parameters remain bound via the wire protocol (no string interpolation into SQL); this only swaps Object.prototype.toString for JSON.stringify when serializing the bound value's text representation.

Level of scrutiny

Low–medium. The change is surgical, gated behind parameter_field == 0, and only affects the previously-broken object/array case. Behavior for all other value types is byte-identical to before. Four new tests exercise object, nested object, array, and both ::json/::jsonb casts.

Other factors

No CODEOWNERS for src/sql/. No prior reviewer comments to address. Bug-hunting agents found no issues. The PR description includes manual before/after verification plus regression checks for scalars, Date, bytea, BigInt, and bool.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/sql/sql-prepare-false.test.ts`:
- Around line 98-148: The data handler assumes each `chunk` contains whole
protocol frames causing flakiness; buffer incoming bytes per-socket and parse
from that buffer instead of treating `chunk` as a complete packet. Concretely,
introduce a per-socket accumulator (e.g., `let buf = Buffer.alloc(0)` in the
server callback), append each `chunk` to `buf`, then run the existing parsing
loop against `buf` but only advance/consume when enough bytes are available for
the next field (startup length or message code+length); if there aren’t enough
bytes for a full frame, break the loop and keep the remainder in `buf` for the
next `data` event. Keep and update the existing flags (`gotStartup`) and logic
that uses `extractFirstParam`, `captured`, `parseComplete`, `bindComplete`,
etc., but operate on `buf` (slicing/consuming) rather than on `chunk`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4bdbfa07-3ff4-4913-8a26-72671c707064

📥 Commits

Reviewing files that changed from the base of the PR and between bab007c and f0d3ae4.

📒 Files selected for processing (2)
  • src/sql/postgres/PostgresRequest.zig
  • test/js/sql/sql-prepare-false.test.ts

Comment thread test/js/sql/sql-prepare-false.test.ts
Comment thread test/js/sql/sql-prepare-false.test.ts Outdated
@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

The remaining CI failure is test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts timing out on Windows — a pre-existing flake that also fails on builds #50767, #50762, #50753, #50750, #50745, #50730, #50719, #50715 against unrelated PRs, and is failing again on todays main PRs. This PR only touches src/sql/postgres/PostgresRequest.zig (SQL bind encoding) and test/js/sql/sql-prepare-false.test.ts (new mock-backend wire-protocol tests).

The SQL test passes cleanly — most recent debian-13 x64 ASAN shard: 7 pass, 1 skip, 0 fail for test/js/sql/sql-prepare-false.test.ts (the single skip is the docker round-trip block, gated on a postgres_plain container name conflict unrelated to the fix).

robobun and others added 5 commits May 4, 2026 10:22
With `prepare: false`, Parse is sent with parameter OID 0 to let
Postgres infer the type from context. `writeBind`'s value loop keyed
off the same OID, so objects and arrays fell into the generic `else`
branch and got `Object.prototype.toString`'d — producing
`[object Object]` or a comma-joined list, both of which fail any
`::jsonb` or `::json` cast.

When parameter_field is 0, re-derive the tag from the JS value
(matching what `Signature.generate` already did with `Tag.fromJS`)
and route `.json` / `.jsonb` values through the JSON branch so
`jsonStringifyFast` serializes them. Other types keep their existing
text behavior.

Fixes #30221
The docker-gated round-trip tests skip when the gate environment has
no docker daemon. Add a mock Postgres backend alongside them that
speaks just enough of the extended-query protocol (Parse / Describe /
Bind / Execute / Sync) to capture the raw bytes the client places in
the Bind message for parameter #0.

Four cases assert the exact text that regressed in #30221 — plain
object, nested object, plain array, object under ::json — plus three
regression guards for string / int / null that the re-derivation path
must not touch.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/bcf8ce18/sql-prepare-false-json-object branch from 69b907a to a2a0612 Compare May 4, 2026 10:22
Comment thread test/js/sql/sql-prepare-false.test.ts Outdated
robobun added 2 commits May 4, 2026 19:27
The mock returns OID 0, but the comment wrongly implied this mirrored
real Postgres behavior for `::jsonb` casts. A real server resolves
`$1::jsonb` to 3802 during Parse analysis. The reason this mock's
OID doesn't matter is that `prepare: false` batches Parse + Describe
+ Bind + Execute + Sync in one write, so the first Bind is built from
`statement.signature.fields` before any ParameterDescription arrives.
- Accumulate chunks per-socket instead of assuming each 'data' event
  holds whole protocol frames. TCP doesn't preserve boundaries, and on
  slower / loaded runners the Startup or Parse+Describe+Bind batch can
  be split.
- Tighten the integer regression guard: Signature.generate writes
  OID 23 (int4, not 0), so the new parameter_field==0 branch is not
  taken for integers. Assert the exact 4-byte big-endian binary payload
  with format=1 — pre-fix else behavior would have been ASCII "42" in
  format=0, so this now actually proves the JSON re-derivation didn't
  steal this path.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both earlier nits were addressed in 65062bc, and the re-derivation is correctly scoped to only redirect json-shaped values when the OID is 0.

Extended reasoning...

Overview

This PR fixes #30221: with prepare: false, plain JS objects/arrays bound as parameters were stringified via Object.prototype.toString (→ "[object Object]") instead of JSON, because writeBind saw parameter_field == 0 and fell through to the generic String.fromJS branch. The fix is ~5 lines of logic in src/sql_jsc/postgres/PostgresRequest.zig: when the OID is 0, re-classify the value with the existing types.Tag.fromJS helper (the same one Signature.generate already ran on this value) and route .json/.jsonb results to the existing jsonStringifyFast arm. All other values fall through to the unchanged @enumFromInt(parameter_field) path. The rest of the diff is test coverage: a mock-Postgres wire-protocol harness that captures the raw Bind bytes, plus Docker-backed round-trip tests.

Security risks

None. Parameter values in the extended-query Bind message are length-prefixed binary fields, not interpolated into SQL text, so there is no injection surface. The change only affects how a JS object is serialized into that field (JSON text vs [object Object]). No auth, crypto, or permission code is touched.

Level of scrutiny

Moderate — Postgres wire-protocol encoding is user-facing, but the change is surgical and strictly additive. I verified against Signature.zig:78-87 that parameter_field == 0 is written for every tag outside {bool, int4, int8, float8, int2, numeric, float4, bytea}, and against Tag.zig:170-185 that OID 0 yields formatCode() == 0 (text) in the first loop, so the .json branch's text output stays consistent with the format code already written. Tag.fromJS is pure and was already invoked on the same value during Signature.generate, so re-invoking it cannot introduce a new throw. Non-JSON OID-0 slots (strings, Dates, Int32Array) take the identical pre-fix path, which the PR description and the regression-guard tests both confirm.

Other factors

I reviewed this PR twice previously; both nits (misleading int4 test title/assertion, inaccurate ParameterDescription comment) were addressed in 65062bc, as was CodeRabbit's TCP-frame-buffering concern. All inline threads are resolved. No CODEOWNERS cover src/sql_jsc/ or test/js/sql/. The bug-hunting system found no issues on the latest revision, CI is green except for an unrelated Windows HTTP flake, and the new test file passes 7/7 on the ASAN shard.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this PR's implementation lives entirely in Zig source files that have since been removed from the tree as part of the Rust migration. The change can no longer merge cleanly and the files it edits no longer exist on main.

If the underlying issue is still present, it will need a fresh fix against the Rust implementation.

@robobun robobun closed this Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.SQL with prepare:false serializes object JSONB parameters as "[object Object]"

1 participant