From 0d1b0e1abbddd414cbd783cfa75265b8e6dcf433 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:20:33 +0000 Subject: [PATCH 1/4] sql(postgres): bind a Date to a text-format parameter as ISO-8601 write_bind sends a parameter in text format when the server-reported type has no binary encoder (date, text, domains, OID 0 with prepare: false). A JS Date on that path went through BunString::from_js, that is Date.prototype.toString(), which Postgres rejects for a date parameter with 22007 and stores verbatim for a text one. Send toISOString() output instead, as postgres.js and pg do. timestamp and timestamptz parameters are bound in binary and are unchanged. --- src/sql_jsc/postgres/PostgresRequest.rs | 27 ++++-- test/js/sql/postgres-date-param-text.test.ts | 93 ++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 test/js/sql/postgres-date-param-text.test.ts diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index dfed47bee818..f327c306fe00 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -216,13 +216,28 @@ pub(crate) fn write_bind( } _ => { - let str = BunString::from_js(value, global).map_err(js_error_to_postgres)?; - if str.tag() == bun_core::Tag::Dead { - return Err(AnyPostgresError::OutOfMemory); - } - let slice = str.to_utf8(); + // Text format. A `Date` goes out as `toISOString()` (what + // postgres.js and pg send): valid input for date, timestamp, + // timestamptz and text parameters, unlike its `toString()` + // form. An invalid Date has no ISO form and is sent as + // "Invalid Date" for the server to reject. + let mut iso_buf = [0u8; 64]; + let str; + let utf8; + let slice: &[u8] = if value.is_date() + && let Some(iso) = value.to_iso_string(global, &mut iso_buf) + { + iso + } else { + str = BunString::from_js(value, global).map_err(js_error_to_postgres)?; + if str.tag() == bun_core::Tag::Dead { + return Err(AnyPostgresError::OutOfMemory); + } + utf8 = str.to_utf8(); + utf8.slice() + }; let l = writer.length()?; - writer.write(slice.slice())?; + writer.write(slice)?; l.write_excluding_self()?; } } diff --git a/test/js/sql/postgres-date-param-text.test.ts b/test/js/sql/postgres-date-param-text.test.ts new file mode 100644 index 000000000000..c71dd24787d5 --- /dev/null +++ b/test/js/sql/postgres-date-param-text.test.ts @@ -0,0 +1,93 @@ +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import { describeWithContainer } from "harness"; + +// A JS Date bound to a parameter that Bun sends in Postgres' text format must +// go out as ISO-8601 (`toISOString()`), not as `Date.prototype.toString()` +// output ("Mon May 06 2024 07:08:09 GMT+0000 (Coordinated Universal Time)"). +// With the default `prepare: true` the server reports the parameter type before +// Bind is written: `timestamp`/`timestamptz` are then bound in binary and were +// already correct, but `date` (and `text`, domains, ...) are bound as text and +// the server rejected the toString() form with 22007. + +describeWithContainer("postgres", { image: "postgres_plain" }, container => { + const options = (extra: Partial = {}): Bun.SQL.PostgresOrMySQLOptions => ({ + db: "bun_sql_test", + username: "bun_sql_test", + host: container.host, + port: container.port, + max: 1, + ...extra, + }); + + const date = new Date("2024-05-06T07:08:09.123Z"); + + test("Date bound to a date parameter", async () => { + await container.ready; + await using sql = new SQL(options()); + // First run prepares the statement (Parse/Describe, then Bind with the + // described types); the second run binds against the cached statement. + for (let i = 0; i < 2; i++) { + const [row] = await sql`SELECT ${date}::date AS d, (${date}::date)::text AS t`; + expect(row).toEqual({ d: new Date("2024-05-06T00:00:00.000Z"), t: "2024-05-06" }); + } + }); + + test("Date inserted into a date column through every parameter path", async () => { + await container.ready; + await using sql = new SQL(options()); + await sql`CREATE TEMP TABLE date_param (id int, d date)`; + await sql`INSERT INTO date_param (id, d) VALUES (1, ${date})`; + await sql`INSERT INTO date_param ${sql({ id: 2, d: date })}`; + await sql.unsafe(`INSERT INTO date_param (id, d) VALUES (3, $1)`, [date]); + expect(await sql`SELECT id, d::text AS d FROM date_param ORDER BY id`).toEqual([ + { id: 1, d: "2024-05-06" }, + { id: 2, d: "2024-05-06" }, + { id: 3, d: "2024-05-06" }, + ]); + }); + + test("the calendar date is taken in UTC, whatever the session time zone", async () => { + await container.ready; + await using sql = new SQL(options()); + await sql`SET TIME ZONE 'Pacific/Kiritimati'`; // UTC+14 + const late = new Date("2024-05-06T23:30:00.000Z"); + const [row] = await sql`SELECT (${late}::date)::text AS d`; + expect(row.d).toBe("2024-05-06"); + // and a decoded date column value round-trips to the same day + const [{ decoded }] = await sql`SELECT '2024-02-29'::date AS decoded`; + const [{ again }] = await sql`SELECT (${decoded}::date)::text AS again`; + expect(again).toBe("2024-02-29"); + }); + + test("Date bound to a text parameter is its ISO string", async () => { + await container.ready; + await using sql = new SQL(options()); + const [row] = await sql`SELECT ${date}::text AS t`; + expect(row.t).toBe("2024-05-06T07:08:09.123Z"); + }); + + test("an invalid Date is left for the server to reject", async () => { + await container.ready; + await using sql = new SQL(options()); + const err = await sql`SELECT ${new Date(NaN)}::date AS d`.catch(e => e); + expect(err).toBeInstanceOf(SQL.PostgresError); + expect(err.errno).toBe("22007"); + expect(err.message).toBe('invalid input syntax for type date: "Invalid Date"'); + }); + + test("timestamptz and timestamp parameters are unaffected", async () => { + await container.ready; + await using sql = new SQL(options()); + const [row] = + await sql`SELECT ${date}::timestamptz AS tz, ${date}::timestamp AS ts, ${"2024-05-06 07:08:09.123+00"}::timestamptz AS s`; + expect(row).toEqual({ tz: date, ts: date, s: date }); + }); + + test("prepare: false binds a Date the same way", async () => { + await container.ready; + await using sql = new SQL(options({ prepare: false })); + const [row] = await sql`SELECT (${date}::date)::text AS d, ${date}::timestamptz AS tz, ${date}::text AS t`; + expect(row).toEqual({ d: "2024-05-06", tz: date, t: "2024-05-06T07:08:09.123Z" }); + }); +}); From 84dbb349ce60258968aff5cf8887beb6e23e881e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:10:04 +0000 Subject: [PATCH 2/4] ci: retrigger From 3b965e59469e3c920fc1c19befeb4ba90d51df99 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:13:03 +0000 Subject: [PATCH 3/4] shorten the text-arm comment --- src/sql_jsc/postgres/PostgresRequest.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index f327c306fe00..cfc13fcdf90a 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -216,11 +216,8 @@ pub(crate) fn write_bind( } _ => { - // Text format. A `Date` goes out as `toISOString()` (what - // postgres.js and pg send): valid input for date, timestamp, - // timestamptz and text parameters, unlike its `toString()` - // form. An invalid Date has no ISO form and is sent as - // "Invalid Date" for the server to reject. + // Text format: the server parses the string as the parameter's + // type. A valid `Date` goes out as `toISOString()`, not `toString()`. let mut iso_buf = [0u8; 64]; let str; let utf8; From 9899625de21d6bde9dbf9a348e5056016e7caffe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:16:17 +0000 Subject: [PATCH 4/4] one-line comment in the text arm --- src/sql_jsc/postgres/PostgresRequest.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index cfc13fcdf90a..8a055227cda3 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -216,8 +216,7 @@ pub(crate) fn write_bind( } _ => { - // Text format: the server parses the string as the parameter's - // type. A valid `Date` goes out as `toISOString()`, not `toString()`. + // Text format: a valid `Date` as `toISOString()`, anything else as `toString()`. let mut iso_buf = [0u8; 64]; let str; let utf8;