Found on main @ 68fd61c, verified against a live Postgres
(not inferred from reading).
whereJsonContains is wrong on Postgres for all inputs, not just edge cases. It returns an empty result set for containment that genuinely holds, silently.
Repro
CREATE TABLE jc (id int primary key, tags jsonb);
INSERT INTO jc VALUES (1, '["bun","sql"]'), (2, '["node"]');const rows = await db.selectFrom('jc').selectAll()
.whereJsonContains('tags', ['bun'])
.execute()
// [] <- row 1 should match-- the same containment, written literally
SELECT id FROM jc WHERE tags @> '["bun"]'; -- [1]Emitted SQL is fine; the parameter is not:
SELECT * FROM jc WHERE tags @> $1
params: ["[\"bun\"]"]Cause
whereJsonContains does whereParams.push(JSON.stringify(json)). Bun's SQL driver already JSON-encodes a JS value bound to a jsonb parameter, so the JSON.stringify makes it happen twice. Postgres receives the JSON string "[\"bun\"]" where an array was intended:
await sql.unsafe('SELECT ($1::jsonb)::text v', [JSON.stringify(['bun'])])
// v: "\"[\\\"bun\\\"]\"" <- a jsonb string, not a jsonb arrayA jsonb string never @>-contains anything, so the predicate is vacuously false and the query returns nothing.
Binding the value directly is correct:
await sql.unsafe('SELECT id FROM jc2 WHERE tags @> $1', [JSON.stringify(['bun'])]) // []
await sql.unsafe('SELECT id FROM jc2 WHERE tags @> $1', [['bun']]) // [1]Fix
Drop the JSON.stringify on the Postgres branch and push json as-is. The MySQL branch (JSON_CONTAINS) takes a text argument and likely still wants the stringified form — worth checking separately rather than changing both together. The SQLite branch uses json_each membership and binds scalars, so it is unaffected.
Note
This interacts with a guard that has been discussed but not shipped: rejecting an empty containment document (whereJsonContains(col, [])). x @> '[]' is vacuously true of every row, so that guard widens the query — but only once the encoding is fixed and the predicate starts doing anything at all. Fix the encoding first.