Skip to content

sql: add support for column name transforms (snake_case to camelCase) - #36451

Closed
pjb3 wants to merge 1 commit into
oven-sh:mainfrom
pjb3:feat/sql-column-transforms
Closed

pjb3 wants to merge 1 commit into
oven-sh:mainfrom
pjb3:feat/sql-column-transforms

Conversation

@pjb3

@pjb3 pjb3 commented Jul 30, 2026

Copy link
Copy Markdown

What does this PR do?

Implements Unicode-aware column name transforms for Bun SQL (new SQL() / postgres()), resolving the Roadmap item in docs/runtime/sql.mdx.

Details:

  • Adds Unicode-aware casing helpers: toCamel, toPascal, toKebab, toSnake supporting multi-language characters (accents, Cyrillic, German Eszett, etc.).
  • Exposes transform presets postgres.camel, postgres.pascal, postgres.kebab, postgres.snake on SQL, postgres, and bun module exports.
  • Applies bidirectional column transforms:
    • Results: transforms DB column names (e.g. user_id -> userId) when reading query results.
    • Helpers: transforms JS object keys back to DB column names when constructing sql({ firstName: "Alice" }) INSERT / UPDATE queries.
  • Includes $O(1)$ per-column memoization cache to optimize multi-row result sets.
  • Adds comprehensive unit tests in test/js/sql/sql-transform.test.ts.

Related Docs:

  • Updates docs/runtime/sql.mdx documentation and removes column transforms from the Roadmap list.

@pjb3
pjb3 requested a review from alii as a code owner July 30, 2026 11:47

@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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Column transformation support is added to SQL clients, including public types, casing helpers, adapter configuration, transformed query results, helper-generated SQL values and identifiers, tests, and documentation.

Changes

SQL transformations

Layer / File(s) Summary
Transformation API and exports
packages/bun-types/sql.d.ts, src/js/bun/sql.ts
Adds public transform types, adapter options, casing utilities, and runtime exports.
Transform parsing and result processing
src/js/internal/sql/shared.ts
Implements Unicode-aware casing helpers, transform normalization, and column, value, and row result processing.
Adapter and query integration
src/js/internal/sql/shared.ts, src/js/internal/sql/query.ts
Parses adapter transforms, applies them during query resolution, and maps transformed identifiers and values for helper queries.
Behavior validation and documentation
test/js/sql/sql-transform.test.ts, docs/runtime/sql.mdx
Adds coverage for casing, Unicode, custom hooks, result processing, transformed helper queries, invalid options, and documents the new option.

Suggested reviewers: robobun, jarred-sumner, alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: SQL column-name transforms.
Description check ✅ Passed The description is mostly complete and matches the template's intent, though it omits an explicit verification section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/query.ts (1)

183-200: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

User-supplied transform functions can throw uncaught inside resolve().

applyResultTransform invokes arbitrary user callbacks (column/value/row transforms). Unlike #run()/#runAsync() in this same file, which wrap handler calls in try/catch and reject the query on error, this call is unguarded — a throwing transform function will escape resolve() instead of rejecting the query gracefully.

🔧 Proposed fix
     const transform = (this[_adapter] as any)?.connectionInfo?.transform;
     if (transform) {
-      const { applyResultTransform } = require("./shared");
-      applyResultTransform(x, transform);
+      try {
+        const { applyResultTransform } = require("./shared");
+        applyResultTransform(x, transform);
+      } catch (err) {
+        this[_queryStatus] |= SQLQueryStatus.error;
+        return this.reject(err as Error);
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/query.ts` around lines 183 - 200, Wrap the transform
application in resolve() with the same error-handling behavior used by `#run`()
and `#runAsync`(): catch exceptions from applyResultTransform, reject the query
through the established rejection path, and return without invoking the success
resolver. Preserve normal transformation and resolution when no error occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/shared.ts`:
- Line 1: Implement the documented bidirectional value transform by applying
ParsedTransform.value.from during INSERT and UPDATE parameter binding in
normalizeQuery, while preserving value.to in applyResultTransform for reads.
Ensure parseTransform and the public TransformOptions.value type remain aligned
with the now-supported from behavior.
- Around line 2242-2353: Wire the parsed transform.value.from handler into
outbound value binding for INSERT and UPDATE operations, using the binding paths
in normalizeQuery and buildDefinedColumnsAndQuery. Apply it to each value before
pushing binding_values, while preserving transform.value.to for result reads and
existing behavior when no from handler is configured.

In `@test/js/sql/sql-transform.test.ts`:
- Around line 42-197: Update each SQL transform test to declare its SQL instance
with await using, including the tests covering column, row, value, INSERT, and
UPDATE transforms. Remove the manual await sql.close() calls so disposal occurs
automatically even when assertions fail.

---

Outside diff comments:
In `@src/js/internal/sql/query.ts`:
- Around line 183-200: Wrap the transform application in resolve() with the same
error-handling behavior used by `#run`() and `#runAsync`(): catch exceptions from
applyResultTransform, reject the query through the established rejection path,
and return without invoking the success resolver. Preserve normal transformation
and resolution when no error occurs.
🪄 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 Plus

Run ID: cba47d58-9db4-44be-b5da-090484a50b60

📥 Commits

Reviewing files that changed from the base of the PR and between c08f665 and 60b837b.

📒 Files selected for processing (6)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/bun/sql.ts
  • src/js/internal/sql/query.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-transform.test.ts

Comment thread src/js/internal/sql/shared.ts
Comment thread src/js/internal/sql/shared.ts
Comment thread test/js/sql/sql-transform.test.ts
@pjb3
pjb3 force-pushed the feat/sql-column-transforms branch from 60b837b to ade4bdc Compare July 30, 2026 12:18

@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: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/shared.ts (1)

477-500: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated value.from application.

The same four-line guard is duplicated at four binding sites, and it has already drifted — the single-row INSERT path (Lines 497-499) adds a typeof boundVal !== "undefined" check the other three lack. A local helper keeps the null/undefined policy in one place.

♻️ Proposed refactor
           const valFromTransform = transform?.value?.from;
+          const bindValue = (v: unknown) =>
+            valFromTransform && v !== null && typeof v !== "undefined" ? valFromTransform(v) : v;

Then each site becomes binding_values.push(bindValue(columnValue)).

Also applies to: 532-548, 582-586

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 477 - 500, Extract the repeated
transform-and-bind logic into a local helper near the binding code, such as
bindValue, that applies valFromTransform only for non-null and non-undefined
values while preserving null/undefined unchanged. Replace all four duplicated
binding sites, including the single-row INSERT path and the sites around the
other binding loops, with binding_values.push(bindValue(columnValue)), removing
the inconsistent guards.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/runtime/sql.mdx`:
- Around line 1209-1217: Extend the custom transform documentation near the
existing transform.column example to also cover transform.value and
transform.row. Add a concise example describing value’s bidirectional { to, from
} behavior for reads and bound helper values on writes, and document row as
another supported transform option consistent with the types in sql.d.ts.

In `@packages/bun-types/sql.d.ts`:
- Around line 162-173: Update the module-export declarations in bun.d.ts to
expose the SQL transform symbols camel, pascal, kebab, snake, toCamel, toPascal,
toKebab, toSnake, fromCamel, fromPascal, fromKebab, and fromSnake as named "bun"
exports, matching the runtime imports. Reuse the existing ColumnTransform type
and helper signatures from the SQL declarations rather than changing their
behavior or limiting them to SQL/postgres access.

In `@src/js/internal/sql/shared.ts`:
- Around line 442-444: Update the QueryNormalizationAdapter interface to declare
optional readonly connectionInfo with an optional transform of type
ParsedTransform, then access connectionInfo directly in the transform extraction
within the affected function and remove the any cast.
- Around line 2340-2362: Update the result-column metadata alongside
transformRowObj so result.columns and result.statement.columns expose names
transformed by colTo. Preserve the original metadata when no column transform is
configured, and ensure transformed metadata matches the keys emitted in each
transformed row.
- Around line 2318-2375: Update applyResultTransform to use the module’s
intrinsic helpers instead of mutable globals: replace each Array.isArray call
with $isArray and Object.keys(row) with $Object.keys(row). Preserve the existing
transformation and branching behavior.
- Around line 1-18: In src/js/internal/sql/shared.ts lines 1-18, remove the
globalThis.$isArray patch and getQueryMod fallback; restore the static
string-literal require and destructuring of the internal/sql/query exports so
load failures propagate. In src/js/internal/sql/query.ts lines 193-203, replace
the per-resolution require("./shared") with a module-scope memoized
lazyApplyResultTransform accessor using
require("internal/sql/shared").applyResultTransform.
- Around line 1-18: Remove the globalThis.$isArray initialization and replace
getQueryMod with static string-literal require("internal/sql/query")
destructuring, preserving the Query, SQLQueryFlags, _strings, and _values
bindings from that module. Do not retain a catch-all fallback or allow
module-loading errors to be swallowed.
- Around line 2191-2223: Update getWords and the casing transforms to preserve
identifier separators, including leading/trailing underscores and dots, while
treating alphanumeric runs such as user2024 as a single identifier component.
Ensure toCamel, toPascal, toKebab, and toSnake round-trip legal SQL identifiers
without dropping existing separators or introducing new ones.

In `@test/js/sql/sql-transform.test.ts`:
- Around line 6-38: Extend the casing-helper tests for toCamel, toPascal,
toKebab, and toSnake to explicitly cover degenerate inputs where getWords
returns an empty result or splitting is lossy: "", "_", "___", "_private_id",
"user2024", and "col$x". Assert each helper preserves the original input
unchanged for these cases.
- Around line 171-200: Update the “Bidirectional value transform function” test
so it verifies both transformed output and the actual stored lowercase value.
Use a non-transforming SQL client connected to the same database, query through
that client, assert the raw value is lowercase, and manage it with await using;
retain the transforming read assertion so removing either value.from or value.to
fails.

---

Outside diff comments:
In `@src/js/internal/sql/shared.ts`:
- Around line 477-500: Extract the repeated transform-and-bind logic into a
local helper near the binding code, such as bindValue, that applies
valFromTransform only for non-null and non-undefined values while preserving
null/undefined unchanged. Replace all four duplicated binding sites, including
the single-row INSERT path and the sites around the other binding loops, with
binding_values.push(bindValue(columnValue)), removing the inconsistent guards.
🪄 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 Plus

Run ID: 3462407b-8436-4910-91a1-579e8bf0348b

📥 Commits

Reviewing files that changed from the base of the PR and between 60b837b and ade4bdc.

📒 Files selected for processing (6)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/bun/sql.ts
  • src/js/internal/sql/query.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-transform.test.ts

Comment thread docs/runtime/sql.mdx
Comment on lines +1209 to +1217
Or pass a custom transform function:

```ts
const sql = new SQL({
transform: {
column: (name) => name.toUpperCase(),
},
});
```

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document transform.value and transform.row.

The section only covers transform.column, but value ({ to, from }, applied on read and to bound helper values on write) and row are both supported and typed in sql.d.ts. Worth a short example so the bidirectional value semantics aren't discovered by reading the source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/runtime/sql.mdx` around lines 1209 - 1217, Extend the custom transform
documentation near the existing transform.column example to also cover
transform.value and transform.row. Add a concise example describing value’s
bidirectional { to, from } behavior for reads and bound helper values on writes,
and document row as another supported transform option consistent with the types
in sql.d.ts.

Comment thread packages/bun-types/sql.d.ts
Comment on lines +1 to +18
if (typeof (globalThis as any).$isArray === "undefined") {
(globalThis as any).$isArray = Array.isArray;
}

const PublicArray = globalThis.Array;
const {
Query,
SQLQueryFlags,
symbols: { _strings, _values },
} = require("internal/sql/query");
function getQueryMod() {
try {
return require("internal/sql/query");
} catch {
return require("./query");
}
}
const queryMod = getQueryMod();
const queryModExp = queryMod?.default || queryMod || {};
const Query = queryModExp.Query;
const SQLQueryFlags = queryModExp.SQLQueryFlags;
const _strings = queryModExp.symbols?._strings;
const _values = queryModExp.symbols?._values;

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Ad-hoc dynamic requires on both sides of the shared ↔ query cycle. These two modules require each other, and rather than establishing one memoized lazy accessor, each side improvised its own dynamic require — one with an error-swallowing fallback plus a globalThis patch, the other repeated per query resolution with a relative specifier. Pick a single convention: static require("internal/sql/query") where the cycle allows it, and one memoized lazy getter where it doesn't.

  • src/js/internal/sql/shared.ts#L1-L18: drop getQueryMod()'s catch/./query fallback and the globalThis.$isArray assignment; restore the static string-literal destructuring require so a broken internal/sql/query fails at load instead of leaving Query undefined.
  • src/js/internal/sql/query.ts#L193-L203: replace the per-resolution require("./shared") with a module-scope memoized lazyApplyResultTransform ??= require("internal/sql/shared").applyResultTransform.
📍 Affects 2 files
  • src/js/internal/sql/shared.ts#L1-L18 (this comment)
  • src/js/internal/sql/query.ts#L193-L203
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 1 - 18, In
src/js/internal/sql/shared.ts lines 1-18, remove the globalThis.$isArray patch
and getQueryMod fallback; restore the static string-literal require and
destructuring of the internal/sql/query exports so load failures propagate. In
src/js/internal/sql/query.ts lines 193-203, replace the per-resolution
require("./shared") with a module-scope memoized lazyApplyResultTransform
accessor using require("internal/sql/shared").applyResultTransform.

Source: Coding guidelines


🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't ship a globalThis patch and a swallow-all module fallback in a builtin.

Two problems here:

  1. globalThis.$isArray = Array.isArray mutates the global object from a builtin. $isArray is a build-time intrinsic in this pipeline, so the assignment is only needed when the file is loaded outside it — that scaffolding shouldn't be in the shipped module.
  2. getQueryMod()'s bare catch swallows any error thrown while evaluating internal/sql/query (not just resolution failure). If both requires fail, queryModExp becomes {} and Query, SQLQueryFlags, _strings, _values are all undefined — value instanceof Query then throws TypeError: Right-hand side of 'instanceof' is not callable deep inside normalizeQuery instead of failing loudly at load.

Restore the static, string-literal require("internal/sql/query") destructuring.

As per coding guidelines: "Modules in Bun's builtin/compatibility code are not ES modules: use require("...") with string literals only".

♻️ Proposed fix
-if (typeof (globalThis as any).$isArray === "undefined") {
-  (globalThis as any).$isArray = Array.isArray;
-}
-
 const PublicArray = globalThis.Array;
-function getQueryMod() {
-  try {
-    return require("internal/sql/query");
-  } catch {
-    return require("./query");
-  }
-}
-const queryMod = getQueryMod();
-const queryModExp = queryMod?.default || queryMod || {};
-const Query = queryModExp.Query;
-const SQLQueryFlags = queryModExp.SQLQueryFlags;
-const _strings = queryModExp.symbols?._strings;
-const _values = queryModExp.symbols?._values;
+const {
+  Query,
+  SQLQueryFlags,
+  symbols: { _strings, _values },
+} = require("internal/sql/query");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (typeof (globalThis as any).$isArray === "undefined") {
(globalThis as any).$isArray = Array.isArray;
}
const PublicArray = globalThis.Array;
const {
Query,
SQLQueryFlags,
symbols: { _strings, _values },
} = require("internal/sql/query");
function getQueryMod() {
try {
return require("internal/sql/query");
} catch {
return require("./query");
}
}
const queryMod = getQueryMod();
const queryModExp = queryMod?.default || queryMod || {};
const Query = queryModExp.Query;
const SQLQueryFlags = queryModExp.SQLQueryFlags;
const _strings = queryModExp.symbols?._strings;
const _values = queryModExp.symbols?._values;
const PublicArray = globalThis.Array;
const {
Query,
SQLQueryFlags,
symbols: { _strings, _values },
} = require("internal/sql/query");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 1 - 18, Remove the
globalThis.$isArray initialization and replace getQueryMod with static
string-literal require("internal/sql/query") destructuring, preserving the
Query, SQLQueryFlags, _strings, and _values bindings from that module. Do not
retain a catch-all fallback or allow module-loading errors to be swallowed.

Source: Coding guidelines

Comment on lines +2191 to +2223
const WORD_REGEX = /(\p{Lu}\p{Ll}+|\p{Lu}+(?!\p{Ll})|\p{Ll}+|\p{N}+)/gu;

function getWords(str: string): string[] {
if (typeof str !== "string" || !str) return [];
const matches = str.match(WORD_REGEX);
return matches || [];
}

function toCamel(str: string): string {
const words = getWords(str);
if (words.length === 0) return str;
const first = words[0].toLowerCase();
const rest = words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
return first + rest.join("");
}

function toPascal(str: string): string {
const words = getWords(str);
if (words.length === 0) return str;
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
}

function toKebab(str: string): string {
const words = getWords(str);
if (words.length === 0) return str;
return words.map(w => w.toLowerCase()).join("-");
}

function toSnake(str: string): string {
const words = getWords(str);
if (words.length === 0) return str;
return words.map(w => w.toLowerCase()).join("_");
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

postgres.js toCamel toSnake column transform leading underscore digits behavior

💡 Result:

In postgres.js, the built-in toCamel and fromCamel (and their counterparts for pascal and kebab cases) transformations are designed to convert between snake_case and their respective naming conventions [1][2][3]. These transformations rely on standard string manipulation logic. Regarding leading underscores and digits, the default behavior follows the logic implemented in the library's internal transformation functions: 1. Leading Underscores: The transformation functions typically preserve leading underscores, as they are often treated as part of the identifier name rather than separators. 2. Digits: Transformation functions generally treat digits as part of the alphanumeric sequence. Because they are not separators (like underscores), they do not trigger a case change in the resulting string (e.g., test1_column would typically transform to test1Column). If you have specific requirements that differ from the library's default behavior—such as handling leading underscores or digits in a way that deviates from standard camelCase conversion—postgres.js allows you to define custom transformation functions [4][5]. You can configure this via the transform option in the postgres configuration object: javascript const sql = postgres({ transform: { column: { to: (columnName) => { /* Custom logic for outgoing column names */ }, from: (columnName) => { /* Custom logic for incoming column names */ } } } }) By providing your own functions, you gain full control over how specific patterns—including those with leading underscores or embedded digits—are processed [4]. If you do not provide your own, the library falls back to its default internal implementation [4]. To see the exact current implementation used for toCamel or fromCamel, you can reference the library's source code [4], specifically within the types.js file [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant casing helpers and their column mapping usage.
if [ -f src/js/internal/sql/shared.ts ]; then
  echo '--- relevant helper code ---'
  sed -n '2180,2230p' src/js/internal/sql/shared.ts

  echo '--- references to toSnake/toPascal/toCamel/toKebab/getWords ---'
  rg -n "toSnake|toPascal|toCamel|toKebab|getWords|column\\.(to|from)" src/js/internal/sql/src? src/js/internal/sql -g '*.ts' || true

  echo '--- package/version hints ---'
  rg -n '"postgres"|postgres\\:' package.json bun.lock bun.lockb . 2>/dev/null | head -80 || true
else
  echo 'src/js/internal/sql/shared.ts not found'
  fd -a shared.ts .
fi

echo '--- deterministic regex behavior probe ---'
node - <<'JS'
const WORD_REGEX = /(\p{Lu}\p{Ll}+|\p{Lu}+(?!\p{Ll})|\p{Ll}+|\p{N}+)/gu;

function getWords(str) {
  if (typeof str !== "string" || !str) return [];
  const matches = str.match(WORD_REGEX);
  return matches || [];
}

function toCamel(str) {
  const words = getWords(str);
  if (words.length === 0) return str;
  const first = words[0].toLowerCase();
  const rest = words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
  return first + rest.join("");
}

function toPascal(str) {
  const words = getWords(str);
  if (words.length === 0) return str;
  return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
}

function toKebab(str) {
  const words = getWords(str);
  if (words.length === 0) return str;
  return words.map(w => w.toLowerCase()).join("-");
}

function toSnake(str) {
  const words = getWords(str);
  if (words.length === 0) return str;
  return words.map(w => w.toLowerCase()).join("_");
}

const samples = [
  "_private_id",
  "user2024",
  "col$x",
  "_id",
  "$col",
  "getURL",
  "v2Model",
  "__id",
  "col_"
];
for (const s of samples) {
  const c = toCamel(s);
  const s2 = toSnake(c);
  const p = toPascal(s);
  const k = toKebab(s);
  console.log(JSON.stringify({
    input: s,
    words: getWords(s),
    toCamel: c,
    toSnakeAfterToCamel: s2,
    roundTripBackToSnakeIdentical: s2 === s,
    toPascal: p,
    toKebab: k
  }));
}
JS

Repository: oven-sh/bun

Length of output: 13156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- shared.ts transform setup ---'
sed -n '2225,2415p' src/js/internal/sql/shared.ts

echo '--- inspect installed postgres package implementation files ---'
for path in node_modules/postgres/cjs/types.js node_modules/postgres/types/types.js node_modules/postgres/lib/types.js; do
  if [ -f "$path" ]; then
    echo "### $path"
    rg -n "toCamel|fromCamel|snake|camel|kebab|pascal|to\\:" "$path" -C 4 || true
  fi
done

echo '--- inspect benchmark postgres package implementation files if available ---'
for path in \
  /test/pkg/node_modules/postgres/types/types.js \
  /bench/install/node_modules/postgres/types/types.js \
  /bench/postgres/node_modules/postgres/types/types.js \
  node_modules/postgres/cjs/types.js \
  node_modules/postgres/types/types.js \
  node_modules/postgres/lib/types.js; do
  if [ -f "$path" ]; then
    echo "### $path"
    sed -n '1,180p' "$path" | rg -n "toCamel|fromCamel|snake|camel|to\\:" -C 6 || true
  fi
done

Repository: oven-sh/bun

Length of output: 5510


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- transform usage in shared.ts ---'
rg -n "snake|toCamel|pascal|kebab|applyResultTransform|Bun\\.SQL|transform" src/js/internal/sql/shared.ts -A 4 -B 4

echo '--- inspect sql.ts exports for postgres/bun helpers ---'
for f in src/js/bun/sql.ts src/js/internal/sql/shared.ts; do
  if [ -f "$f" ]; then
    echo "### $f"
    rg -n "postgres|camel|toCamel|snake|toSnake|transform|const SQL|export.*SQL" "$f" -A 3 -B 3 || true
  fi
done

echo '--- deterministic compare against postgres 3.3.5 behavior ---'
node - <<'JS'
const module = { cache: {} };
const name = require.resolve("postgres", module);
console.log("resolved", name);
const pg = require(name);
const samples = [
  "_private_id",
  "user2024",
  "col$x",
  "_id",
  "$col",
  "getURL",
  "v2Model",
  "__id",
  "col_",
  "private_id",
  "user_2024",
  "col_x"
];
for (const s of samples) {
  const c = pg.toCamel(s);
  const s2 = pg.fromCamel(c);
  console.log(JSON.stringify({
    input: s,
    pg_toCamel: c,
    pg_fromCamel_after_toCamel: s2,
    roundTripBackToInputIdentical: s2 === s
  }));
}
JS

Repository: oven-sh/bun

Length of output: 29231


Preserve identifier separators in the SQL casing transforms.

WORD_REGEX discards non-letter/non-digit characters and splits digit runs, so snake.to / camel.from can map legal column names to different SQL identifiers. For example, _private_id → camel → privateId → snake → private_id, causing an INSERT/UPDATE helper to reference the wrong column. Handle leading/trailing separators and alphanumeric runs like user2024 without inserting new separators.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 2191 - 2223, Update getWords and
the casing transforms to preserve identifier separators, including
leading/trailing underscores and dots, while treating alphanumeric runs such as
user2024 as a single identifier component. Ensure toCamel, toPascal, toKebab,
and toSnake round-trip legal SQL identifiers without dropping existing
separators or introducing new ones.

Comment on lines +2340 to +2362
const transformRowObj = (row: any) => {
if (!row || typeof row !== "object" || Array.isArray(row) || row instanceof Date || Buffer.isBuffer(row)) {
return row;
}
let out: any = row;
if (colTo || valTo) {
out = {};
const keys = Object.keys(row);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const newKey = colTo ? colTo(key) : key;
let val = row[key];
if (valTo) {
val = valTo(val);
}
out[newKey] = val;
}
}
if (rowFn) {
out = rowFn(out);
}
return out;
};

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

result.columns still reports untransformed names.

Rows are rebuilt with transformed keys, but the result's column metadata (result.columns / result.statement.columns) is left holding the original database names. Consumers that iterate result.columns to index into a row will get undefined for every column once a transform is configured.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 2340 - 2362, Update the
result-column metadata alongside transformRowObj so result.columns and
result.statement.columns expose names transformed by colTo. Preserve the
original metadata when no column transform is configured, and ensure transformed
metadata matches the keys emitted in each transformed row.

Comment on lines +6 to +38
test("toCamel", () => {
expect(toCamel("user_id")).toBe("userId");
expect(toCamel("USER_ID")).toBe("userId");
expect(toCamel("first_name")).toBe("firstName");
expect(toCamel("user_id_2024")).toBe("userId2024");
expect(toCamel("créée_à")).toBe("crééeÀ");
expect(toCamel("über_größe")).toBe("überGröße");
expect(toCamel("имя_пользователя")).toBe("имяПользователя");
expect(toCamel("FooBar")).toBe("fooBar");
expect(toCamel("foo-bar")).toBe("fooBar");
});

test("toPascal", () => {
expect(toPascal("user_id")).toBe("UserId");
expect(toPascal("USER_ID")).toBe("UserId");
expect(toPascal("first_name")).toBe("FirstName");
expect(toPascal("créée_à")).toBe("CrééeÀ");
expect(toPascal("foo-bar")).toBe("FooBar");
});

test("toKebab", () => {
expect(toKebab("user_id")).toBe("user-id");
expect(toKebab("userId")).toBe("user-id");
expect(toKebab("First_Name")).toBe("first-name");
});

test("toSnake", () => {
expect(toSnake("userId")).toBe("user_id");
expect(toSnake("UserId")).toBe("user_id");
expect(toSnake("first-name")).toBe("first_name");
expect(toSnake("FOO_BAR")).toBe("foo_bar");
expect(toSnake("crééeÀ")).toBe("créée_à");
});

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enumerate the degenerate inputs for the casing helpers.

The matrix covers realistic identifiers but skips exactly the inputs where getWords returns [] and the functions fall back to returning the input unchanged: "", "_", "___", and names where splitting is lossy ("_private_id", "user2024", "col$x"). Those fallbacks are load-bearing for the write path — please pin them.

As per coding guidelines: "Deliberately enumerate input spaces, including empty, zero, unset, delimiter-only, CRLF, IPv6, integer limits, and every accepted option spelling".

💚 Suggested additions
     test("toSnake", () => {
       expect(toSnake("userId")).toBe("user_id");
+      expect(toSnake("")).toBe("");
+      expect(toSnake("___")).toBe("___");
+      expect(toSnake("user2024")).toBe("user_2024");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("toCamel", () => {
expect(toCamel("user_id")).toBe("userId");
expect(toCamel("USER_ID")).toBe("userId");
expect(toCamel("first_name")).toBe("firstName");
expect(toCamel("user_id_2024")).toBe("userId2024");
expect(toCamel("créée_à")).toBe("crééeÀ");
expect(toCamel("über_größe")).toBe("überGröße");
expect(toCamel("имя_пользователя")).toBe("имяПользователя");
expect(toCamel("FooBar")).toBe("fooBar");
expect(toCamel("foo-bar")).toBe("fooBar");
});
test("toPascal", () => {
expect(toPascal("user_id")).toBe("UserId");
expect(toPascal("USER_ID")).toBe("UserId");
expect(toPascal("first_name")).toBe("FirstName");
expect(toPascal("créée_à")).toBe("CrééeÀ");
expect(toPascal("foo-bar")).toBe("FooBar");
});
test("toKebab", () => {
expect(toKebab("user_id")).toBe("user-id");
expect(toKebab("userId")).toBe("user-id");
expect(toKebab("First_Name")).toBe("first-name");
});
test("toSnake", () => {
expect(toSnake("userId")).toBe("user_id");
expect(toSnake("UserId")).toBe("user_id");
expect(toSnake("first-name")).toBe("first_name");
expect(toSnake("FOO_BAR")).toBe("foo_bar");
expect(toSnake("crééeÀ")).toBe("créée_à");
});
test("toSnake", () => {
expect(toSnake("userId")).toBe("user_id");
expect(toSnake("")).toBe("");
expect(toSnake("___")).toBe("___");
expect(toSnake("user2024")).toBe("user_2024");
expect(toSnake("UserId")).toBe("user_id");
expect(toSnake("first-name")).toBe("first_name");
expect(toSnake("FOO_BAR")).toBe("foo_bar");
expect(toSnake("crééeÀ")).toBe("créée_à");
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/sql/sql-transform.test.ts` around lines 6 - 38, Extend the
casing-helper tests for toCamel, toPascal, toKebab, and toSnake to explicitly
cover degenerate inputs where getWords returns an empty result or splitting is
lossy: "", "_", "___", "_private_id", "user2024", and "col$x". Assert each
helper preserves the original input unchanged for these cases.

Source: Coding guidelines

Comment thread test/js/sql/sql-transform.test.ts
@pjb3
pjb3 force-pushed the feat/sql-column-transforms branch from ade4bdc to 4d08ae9 Compare July 30, 2026 12:50

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/shared.ts`:
- Around line 2318-2375: Move the column-name memoization cache out of
applyResultTransform and into the connection-scoped transform setup performed by
parseTransform, so the same cache is reused across query results. Update the
parsed transform’s column.to wrapper to capture that persistent Map, while
preserving the existing key transformation behavior and row processing in
applyResultTransform.
- Around line 2264-2316: Update parseTransform to validate the top-level
transform input and throw the established $ERR_INVALID_ARG_TYPE error when it is
neither a function nor an object, instead of silently returning undefined.
Preserve the existing parsing behavior for valid function and object transforms,
including the final undefined result when a valid object contains no recognized
transform handlers.
🪄 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 Plus

Run ID: 3dff7d18-6856-4796-9bb5-79e9bc9255a4

📥 Commits

Reviewing files that changed from the base of the PR and between ade4bdc and 4d08ae9.

📒 Files selected for processing (6)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/bun/sql.ts
  • src/js/internal/sql/query.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-transform.test.ts

Comment thread src/js/internal/sql/shared.ts
@pjb3
pjb3 force-pushed the feat/sql-column-transforms branch from 4d08ae9 to 3fbf163 Compare July 30, 2026 13:17

@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: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/internal/sql/shared.ts (1)

477-500: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

valFromTransform guards on !== null but not undefined in the multi-value paths.

Lines 477-481 and 532-548 normalize undefined → null first so they're safe, and 496-500 checks both; the UPDATE path (582-586) can't see undefined because of the continue above. Consistent, but the three near-identical let boundVal / if (valFromTransform …) / push blocks would read better as one local helper.

Also applies to: 532-548, 580-586

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 477 - 500, Extract the repeated
bound-value normalization logic into a local helper near the affected
SQL-building code, covering undefined-to-null conversion, conditional
valFromTransform application, and binding_values insertion. Replace the
equivalent blocks in the multi-value paths and UPDATE path around the visible
let boundVal/valFromTransform/push sequences with calls to that helper,
preserving their existing null and undefined behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-types/sql.d.ts`:
- Around line 229-233: Remove the redundant `(column: string) => string` union
member from the `transform` property in both option interfaces, leaving
`TransformOptions | ColumnTransform | undefined`; use `ColumnTransform` as the
sole function-compatible type.
- Around line 188-198: Update the TransformOptions interface to include the
top-level { to, from } column-transform shape accepted by parseTransform when no
column key is present, adding it to the existing column union while preserving
current function and nested-object forms.

In `@src/js/bun/sql.ts`:
- Around line 10-29: Remove the unused toCamelTransform, toPascalTransform,
toKebabTransform, and toSnakeTransform bindings from the destructuring in
sql.ts, and remove their corresponding exports from the shared.ts export
surface. Preserve the existing toCamel, toPascal, toKebab, toSnake, and
from*Transform APIs.

In `@src/js/internal/sql/shared.ts`:
- Around line 2334-2378: Update applyResultTransform so column metadata stays
consistent with transformed row keys: apply colTo to result.columns and
result.statement.columns when present, preserving the existing value and row
transformations. Replace Array.isArray, Object.keys, and Buffer.isBuffer in
transformRowObj and result traversal with this directory’s approved $-prefixed
intrinsic/private equivalents, avoiding global resolution.
- Around line 442-444: Move value.from handling to the shared bindParam boundary
so both ordinary interpolated parameters and INSERT/IN/UPDATE helper values use
the same transformation path. Update the relevant bindParam flow and remove the
per-helper valFromTransform handling to prevent syntax-dependent behavior.
Expose connectionInfo on QueryNormalizationAdapter and access it without the
(adapter as any) cast.
- Around line 2276-2308: Update parseTransform’s handling of transform.column so
postgres.js semantics are preserved: assign column.to to the direction used for
generated SQL identifiers (colFrom) and column.from to the direction used for
result-row transformation (colTo), including function metadata and object forms.
Compensate the built-in helper assignments as needed, while leaving value and
row transformation behavior unchanged.
- Around line 2314-2332: Update the memoization wrapper around colTo in the
returned transform setup to distinguish a missing cache entry from a cached
undefined result by checking cache presence with cache.has(key) before invoking
rawColTo. Preserve undefined values in the cache and prevent repeated user
transform calls or unintended out[undefined] keys.

In `@test/js/sql/sql-transform.test.ts`:
- Around line 196-203: Strengthen the assertion in “Throws error for invalid
transform options type” by matching the thrown validation error’s message,
ensuring it identifies the transform option and the rejected type. Keep the
invalid transform setup unchanged while replacing the bare toThrow assertion
with a message-specific expectation.
- Around line 42-152: Expand the transform integration tests around the existing
preset tests to parameterize every exported preset in both namespaces: postgres
and SQL, covering camel, pascal, kebab, and snake aliases. Exercise
result-column conversion in both directions for each preset, including Unicode
where applicable, and add matching SQLHelper INSERT/UPDATE assertions for
non-camel presets. Ensure the matrix verifies each exported entry point and its
inverse mapping.

---

Outside diff comments:
In `@src/js/internal/sql/shared.ts`:
- Around line 477-500: Extract the repeated bound-value normalization logic into
a local helper near the affected SQL-building code, covering undefined-to-null
conversion, conditional valFromTransform application, and binding_values
insertion. Replace the equivalent blocks in the multi-value paths and UPDATE
path around the visible let boundVal/valFromTransform/push sequences with calls
to that helper, preserving their existing null and undefined behavior.
🪄 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 Plus

Run ID: aeba1b05-2130-4362-975d-53710bf4da01

📥 Commits

Reviewing files that changed from the base of the PR and between 4d08ae9 and 3fbf163.

📒 Files selected for processing (6)
  • docs/runtime/sql.mdx
  • packages/bun-types/sql.d.ts
  • src/js/bun/sql.ts
  • src/js/internal/sql/query.ts
  • src/js/internal/sql/shared.ts
  • test/js/sql/sql-transform.test.ts

Comment on lines +188 to +198
interface TransformOptions {
column?: ColumnTransform | {
to?: (column: string) => string;
from?: (column: string) => string;
} | undefined;
value?: ((value: any) => any) | {
to?: (value: any) => any;
from?: (value: any) => any;
} | undefined;
row?: ((row: any) => any) | undefined;
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

TransformOptions doesn't express the top-level { to, from } shape the runtime accepts.

parseTransform in src/js/internal/sql/shared.ts (Lines 2304-2307) treats an object with bare to/from and no column key as a column transform, but that form is a type error against this declaration. Either add it to the union or drop the runtime branch so the public contract and the parser agree.

♻️ Proposed type addition
     interface TransformOptions {
       column?: ColumnTransform | {
         to?: (column: string) => string;
         from?: (column: string) => string;
       } | undefined;
+      /** Shorthand for `column`, applied when `column` is not set. */
+      to?: ((column: string) => string) | undefined;
+      from?: ((column: string) => string) | undefined;
       value?: ((value: any) => any) | {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface TransformOptions {
column?: ColumnTransform | {
to?: (column: string) => string;
from?: (column: string) => string;
} | undefined;
value?: ((value: any) => any) | {
to?: (value: any) => any;
from?: (value: any) => any;
} | undefined;
row?: ((row: any) => any) | undefined;
}
interface TransformOptions {
column?: ColumnTransform | {
to?: (column: string) => string;
from?: (column: string) => string;
} | undefined;
/** Shorthand for `column`, applied when `column` is not set. */
to?: ((column: string) => string) | undefined;
from?: ((column: string) => string) | undefined;
value?: ((value: any) => any) | {
to?: (value: any) => any;
from?: (value: any) => any;
} | undefined;
row?: ((row: any) => any) | undefined;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-types/sql.d.ts` around lines 188 - 198, Update the
TransformOptions interface to include the top-level { to, from }
column-transform shape accepted by parseTransform when no column key is present,
adding it to the existing column union while preserving current function and
nested-object forms.

Comment on lines +229 to +233

/**
* Column name and value transforms (e.g. `postgres.camel` for snake_case -> camelCase).
*/
transform?: TransformOptions | ColumnTransform | ((column: string) => string) | undefined;

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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

(column: string) => string is already subsumed by ColumnTransform.

ColumnTransform has a bare call signature with optional to/from, so a plain function satisfies it; the third union member is redundant noise in both option interfaces.

Also applies to: 428-432

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-types/sql.d.ts` around lines 229 - 233, Remove the redundant
`(column: string) => string` union member from the `transform` property in both
option interfaces, leaving `TransformOptions | ColumnTransform | undefined`; use
`ColumnTransform` as the sole function-compatible type.

Comment thread src/js/bun/sql.ts
Comment on lines +10 to +29
const {
SQLHelper,
parseOptions,
camel,
pascal,
kebab,
snake,
toCamel,
toPascal,
toKebab,
toSnake,
toCamelTransform,
fromCamelTransform,
toPascalTransform,
fromPascalTransform,
toKebabTransform,
fromKebabTransform,
toSnakeTransform,
fromSnakeTransform,
} = require("internal/sql/shared");

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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

toCamelTransform/toPascalTransform/toKebabTransform/toSnakeTransform are destructured but never used.

Only the from*Transform bindings are referenced (Lines 1067-1070, 1131-1134); the to*Transform aliases duplicate toCamel/toPascal/toKebab/toSnake and can be dropped from both this destructuring and the shared.ts export surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/bun/sql.ts` around lines 10 - 29, Remove the unused toCamelTransform,
toPascalTransform, toKebabTransform, and toSnakeTransform bindings from the
destructuring in sql.ts, and remove their corresponding exports from the
shared.ts export surface. Preserve the existing toCamel, toPascal, toKebab,
toSnake, and from*Transform APIs.

Comment on lines +442 to +444
const transform = (adapter as any)?.connectionInfo?.transform;
const fromTransform = transform?.column?.from;
const valFromTransform = transform?.value?.from;

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

value.from only reaches helper-bound values, never plain interpolated parameters.

valFromTransform is consulted in the INSERT/IN/UPDATE helper branches, but ordinary interpolation still goes through adapter.bindParam(value, ...) (Line 597) untouched. So sql\insert into t ${sql(row)}`transforms values whilesql`update t set a = ${v}`` does not — the same logical write is transformed or not depending on syntax, which can persist inconsistent data.

Route the transform through the single bindParam boundary instead of per-helper branches.

The (adapter as any) cast here also hides connectionInfo from QueryNormalizationAdapter (previously noted).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 442 - 444, Move value.from
handling to the shared bindParam boundary so both ordinary interpolated
parameters and INSERT/IN/UPDATE helper values use the same transformation path.
Update the relevant bindParam flow and remove the per-helper valFromTransform
handling to prevent syntax-dependent behavior. Expose connectionInfo on
QueryNormalizationAdapter and access it without the (adapter as any) cast.

Comment on lines +2276 to +2308
if (typeof transform === "function") {
colTo = transform;
if (transform.from) colFrom = transform.from;
if (transform.to) colTo = transform.to;
} else if (typeof transform === "object") {
if (transform.column) {
if (typeof transform.column === "function") {
colTo = transform.column;
if (transform.column.from) colFrom = transform.column.from;
if (transform.column.to) colTo = transform.column.to;
} else if (typeof transform.column === "object") {
colTo = transform.column.to;
colFrom = transform.column.from;
}
}
if (transform.value) {
if (typeof transform.value === "function") {
valTo = transform.value;
if (transform.value.from) valFrom = transform.value.from;
if (transform.value.to) valTo = transform.value.to;
} else if (typeof transform.value === "object") {
valTo = transform.value.to;
valFrom = transform.value.from;
}
}
if (typeof transform.row === "function") {
rowFn = transform.row;
}
if (!transform.column && (transform.to || transform.from)) {
colTo = transform.to;
colFrom = transform.from;
}
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

postgres.js transform column to from direction outgoing incoming column names

💡 Result:

In postgres.js, column name transformations are handled via the transform option in the connection configuration [1][2]. You can control how column names are mapped between your application code and the database by defining custom functions for the to and from directions within transform.column [1][3][4]. The transformation works as follows: - to: Defines the function to transform outgoing column names (e.g., from your application's naming convention, like camelCase, to the database's convention, like snake_case, in interpolated identifiers) [1][4]. - from: Defines the function to transform incoming column names (e.g., from the database's snake_case to your application's camelCase) [1][4]. Example Configuration: const sql = postgres({ transform: { column: { to: (column) => { /* logic to transform outgoing column name / }, from: (column) => { / logic to transform incoming column name */ } } } }) Important Notes: 1. Static SQL: Postgres.js does not rewrite static parts of template strings [1][4]. If you need transformations for identifiers, you must use the sql helper (e.g., ${sql('columnName')}) [1][2][4]. 2. Built-in Transformers: The library provides built-in helpers like postgres.camel, postgres.pascal, and postgres.kebab which perform two-way transformations by default [1][3]. If you only want to perform half of the transformation (e.g., only incoming from snake_case to camelCase), you can use specific functions like postgres.fromCamel [1][4]. 3. JSONB caveat: Be aware that column transformations applied via this feature may also affect keys within JSONB columns, as the library may apply the logic to result rows globally [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file and relevant symbols"
if [ -f src/js/internal/sql/shared.ts ]; then
  wc -l src/js/internal/sql/shared.ts
  ast-grep outline src/js/internal/sql/shared.ts --match parseTransform --view expanded || true
  ast-grep outline src/js/internal/sql/shared.ts --match applyResultTransform --view expanded || true
  ast-grep outline src/js/internal/sql/shared.ts --match normalizeQuery --view expanded || true
else
  echo "src/js/internal/sql/shared.ts not found"
  fd -i 'shared.ts' src/js/internal/sql 2>/dev/null || true
fi

echo
echo "Relevant parseTransform/applyResultTransform/normalizeQuery snippets"
rg -n "function parseTransform|parseTransform\\(|function applyResultTransform|applyResultTransform\\(|function normalizeQuery|normalizeQuery\\(" src/js/internal/sql/shared.ts || true

echo
echo "Read focused sections around parseTransform and call sites"
sed -n '2200,2345p' src/js/internal/sql/shared.ts
sed -n '2345,2465p' src/js/internal/sql/shared.ts
sed -n '2465,2620p' src/js/internal/sql/shared.ts

echo
echo "Search transform docs/options in repository"
rg -n "transform:|transform.column|postgres.camel|postgres.fromCamel|fromCamel|column names|column to|column from" src/js/internal sql README.md packages test -g '*{ts,js,mdjs,mdd,mdx,ts}' 2>/dev/null | head -n 200 || true

Repository: oven-sh/bun

Length of output: 28646


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect normalizeQuery relevant section"
sed -n '340,445p' src/js/internal/sql/shared.ts

echo
echo "Inspect shared.transform module/type docs and transform tests"
sed -n '200,260p' packages/bun-types/sql.d.ts
sed -n '35,215p' test/js/sql/sql-transform.test.ts
sed -n '2730,2815p' test/js/sql/sql.test.ts

echo
echo "Find parseTransform / colTo / colFrom usage around buildDefinedColumnsAndQuery"
sed -n '1440,1525p' src/js/internal/sql/shared.ts
sed -n '2090,2180p' src/js/internal/sql/shared.ts

echo
echo "Behavioral model: postgres.js semantics require outgoing to=id(input), from=id(result_key) -> Bun's parseTransform currently puts transform.column.to into result key transformation, and transform.column.from into normalizeQuery identifier generation"
python3 - <<'PY'
# Model the actual parser assignments and transformers used by the built-ins.
def parseTransform(transform):
    col_to = None
    col_from = None
    if isinstance(transform, dict) and transform.get("column"):
        col = transform["column"]
        if callable(col):
            col_to = col
            col_from = getattr(col, "from", None)
            col_to = getattr(col, "to", col_to)
        elif isinstance(col, dict):
            col_to = col.get("to")
            col_from = col.get("from")
    if not col_to and not col_from:
        return None
    # normalizeQuery uses the original stored col_to; applyResultTransform uses the returned result.column.to.
    return {"result": "column": {"to": col_to, "from": col_from}, "normalize": {"colTo": col_to}}

def to_snake(s):
    out = ""
    for c in s:
        if c.isupper():
            out += "_" + c.lower()
        else:
            out += c
    out = out.strip("_").lower()
    return out
def to_camel(s):
    return s[0].lower() + s[1:] if s else s

camel = lambda s: to_camel(s, *()) if True else None
# Use the explicit built-in functions from source lines 2228–2233:
camel_to = to_camel          # js -> db? no, buildIdentifier calls this directly via colTo in Bun
camel_from = to_snake

# Simulate postgres.js expected direction, as documented in search results:
# to = outgoing JS -> database; from = incoming database -> JS.
postgres_column = {
    "to": lambda c: f"<out:{c}>",
    "from": lambda c: f"<in:{c}>"
}

user = {"column": postgres_column}
p = parseTransform(user)
print("parseTransform column.from:", getattr(p["result"]["column"].get("from"), "__name__", p["result"]["column"]["from"] if p["result"]["column"]["from"] else None))
print("parseTransform column.to:", getattr(p["result"]["column"].get("to"), "__name__", p["result"]["column"]["to"] if p["result"]["column"]["to"] else None))
PY

Repository: oven-sh/bun

Length of output: 19348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "postgres.transform helper definitions from repository"
rg -n "toCamelTransform|fromCamelTransform|postgres.fromCamel|postgres.toCamel|postgres.camel|toPascalTransform|fromPascalTransform|toKebabTransform|fromKebabTransform" src/js/internal/sql packages/bun-types test/js/sql | head -n 120

echo
echo "Relevant sqlite adapter normalize helper usage"
sed -n '425,525p' src/js/internal/sql/shared.ts
sed -n '160,240p' test/js/sql/sql-transform.test.ts

echo
echo "Inspect SQL helper buildDefinedColumnsAndQuery implementation"
sed -n '180,230p' src/js/internal/sql/shared.ts
rg -n "buildDefinedColumnsAndQuery|sql\\(", src/js/internal/sql/shared.ts | head -n 80

Repository: oven-sh/bun

Length of output: 11577


Align column.to / column.from semantics with postgres.js

In postgres.js, transform.column.to maps outgoing JS names to database identifiers and transform.column.from maps incoming database column names to JS objects. Here colTo is used by result row transformation while colFrom is used for generated SQL identifiers, so a user’s postgres-compatible transform: { column: { to, from } } config can be silently swapped. Keep the built-in helpers working by compensating their assignments, but make parseTransform apply column.to and column.from in the documented directions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 2276 - 2308, Update
parseTransform’s handling of transform.column so postgres.js semantics are
preserved: assign column.to to the direction used for generated SQL identifiers
(colFrom) and column.from to the direction used for result-row transformation
(colTo), including function metadata and object forms. Compensate the built-in
helper assignments as needed, while leaving value and row transformation
behavior unchanged.

Comment on lines +2314 to +2332
if (colTo) {
const rawColTo = colTo;
const cache = new Map<string, string>();
colTo = (key: string) => {
let cached = cache.get(key);
if (cached === undefined) {
cached = rawColTo(key);
cache.set(key, cached);
}
return cached;
};
}

return {
column: colTo || colFrom ? { to: colTo, from: colFrom } : undefined,
value: valTo || valFrom ? { to: valTo, from: valFrom } : undefined,
row: rowFn,
};
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Memoization miss when the user transform legitimately returns undefined.

cached === undefined is used as the miss sentinel, so a user column.to returning undefined is re-invoked on every row and out[undefined] keys silently appear. Use cache.has(key) (or $Map intrinsic accessors) for the presence check.

♻️ Proposed fix
     colTo = (key: string) => {
-      let cached = cache.get(key);
-      if (cached === undefined) {
-        cached = rawColTo(key);
-        cache.set(key, cached);
-      }
-      return cached;
+      if (cache.$has(key)) return cache.$get(key)!;
+      const computed = rawColTo(key);
+      cache.$set(key, computed);
+      return computed;
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (colTo) {
const rawColTo = colTo;
const cache = new Map<string, string>();
colTo = (key: string) => {
let cached = cache.get(key);
if (cached === undefined) {
cached = rawColTo(key);
cache.set(key, cached);
}
return cached;
};
}
return {
column: colTo || colFrom ? { to: colTo, from: colFrom } : undefined,
value: valTo || valFrom ? { to: valTo, from: valFrom } : undefined,
row: rowFn,
};
}
if (colTo) {
const rawColTo = colTo;
const cache = new Map<string, string>();
colTo = (key: string) => {
if (cache.$has(key)) return cache.$get(key)!;
const computed = rawColTo(key);
cache.$set(key, computed);
return computed;
};
}
return {
column: colTo || colFrom ? { to: colTo, from: colFrom } : undefined,
value: valTo || valFrom ? { to: valTo, from: valFrom } : undefined,
row: rowFn,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 2314 - 2332, Update the
memoization wrapper around colTo in the returned transform setup to distinguish
a missing cache entry from a cached undefined result by checking cache presence
with cache.has(key) before invoking rawColTo. Preserve undefined values in the
cache and prevent repeated user transform calls or unintended out[undefined]
keys.

Comment on lines +2334 to +2378
function applyResultTransform(result: any, transform: ParsedTransform | undefined): any {
if (!result || !transform) return result;

const colTo = transform.column?.to;
const valTo = transform.value?.to;
const rowFn = transform.row;

if (!colTo && !valTo && !rowFn) return result;

const transformRowObj = (row: any) => {
if (!row || typeof row !== "object" || Array.isArray(row) || row instanceof Date || Buffer.isBuffer(row)) {
return row;
}
let out: any = row;
if (colTo || valTo) {
out = {};
const keys = Object.keys(row);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const newKey = colTo ? colTo(key) : key;
let val = row[key];
if (valTo) {
val = valTo(val);
}
out[newKey] = val;
}
}
if (rowFn) {
out = rowFn(out);
}
return out;
};

if (Array.isArray(result)) {
for (let i = 0; i < result.length; i++) {
const item = result[i];
if (Array.isArray(item) && (item as any).command !== undefined) {
applyResultTransform(item, transform);
} else {
result[i] = transformRowObj(item);
}
}
}
return result;
}

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

applyResultTransform mutates rows in place but leaves result.columns untransformed and uses non-primordial globals.

Two points on this function:

  1. The column-metadata gap flagged previously is unresolved — rows get transformed keys while result.columns / result.statement.columns keep database names.
  2. Array.isArray, Object.keys, Buffer.isBuffer should be $isArray, $Object.keys, and the intrinsic-safe equivalents per this directory's conventions; Buffer is also resolved from the global here.

As per coding guidelines: "use $-prefixed intrinsics/private APIs such as $Array, $newArrayWithSize, $debug, and $assert".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/shared.ts` around lines 2334 - 2378, Update
applyResultTransform so column metadata stays consistent with transformed row
keys: apply colTo to result.columns and result.statement.columns when present,
preserving the existing value and row transformations. Replace Array.isArray,
Object.keys, and Buffer.isBuffer in transformRowObj and result traversal with
this directory’s approved $-prefixed intrinsic/private equivalents, avoiding
global resolution.

Source: Coding guidelines

Comment on lines +42 to +152
test("snake_case to camelCase with postgres.camel", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: postgres.camel,
});

const [row] = await sql`SELECT 1 as user_id, 'Alice' as first_name, '2024-01-01' as created_at`;
expect(row).toEqual({
userId: 1,
firstName: "Alice",
createdAt: "2024-01-01",
});
});

test("snake_case to camelCase with Unicode column names", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: { column: postgres.camel },
});

const [row] = await sql`SELECT 'oui' as créée_à, 'groß' as über_größe`;
expect(row).toEqual({
crééeÀ: "oui",
überGröße: "groß",
});
});

test("snake_case to PascalCase with postgres.pascal", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: postgres.pascal,
});

const [row] = await sql`SELECT 1 as user_id, 'Bob' as first_name`;
expect(row).toEqual({
UserId: 1,
FirstName: "Bob",
});
});

test("camelCase to snake_case with postgres.snake", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: postgres.snake,
});

const [row] = await sql`SELECT 1 as userId, 'Charlie' as firstName`;
expect(row).toEqual({
user_id: 1,
first_name: "Charlie",
});
});

test("Custom column transform function", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: {
column: (col: string) => col.toUpperCase(),
},
});

const [row] = await sql`SELECT 1 as user_id, 'Dave' as first_name`;
expect(row).toEqual({
USER_ID: 1,
FIRST_NAME: "Dave",
});
});

test("SQLHelper INSERT with column transform (from camelCase to snake_case)", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: postgres.camel,
});

await sql`CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT)`;

const newUser = { firstName: "Eve", lastName: "Adams" };
await sql`INSERT INTO users ${sql(newUser)}`;

const [retrieved] = await sql`SELECT first_name, last_name FROM users WHERE id = 1`;
expect(retrieved).toEqual({
firstName: "Eve",
lastName: "Adams",
});
});

test("SQLHelper UPDATE with column transform", async () => {
await using sql = new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: postgres.camel,
});

await sql`CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, last_name TEXT)`;
await sql`INSERT INTO users (id, first_name, last_name) VALUES (1, 'Frank', 'Smith')`;

const updateData = { firstName: "Franklin", lastName: "Smith-Doe" };
await sql`UPDATE users SET ${sql(updateData)} WHERE id = 1`;

const [retrieved] = await sql`SELECT first_name, last_name FROM users WHERE id = 1`;
expect(retrieved).toEqual({
firstName: "Franklin",
lastName: "Smith-Doe",
});
});

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exercise every exported preset in both directions.

Integration tests omit postgres.kebab, all documented SQL.camel/pascal/kebab/snake aliases, and helper-key conversion for the non-camel presets. A broken export or inverse mapping can therefore ship without coverage. Add a parameterized result-and-helper matrix for both namespaces. As per coding guidelines: “Tests must cover the complete variant matrix, including … sibling entry points … [and] alternate modes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/sql/sql-transform.test.ts` around lines 42 - 152, Expand the
transform integration tests around the existing preset tests to parameterize
every exported preset in both namespaces: postgres and SQL, covering camel,
pascal, kebab, and snake aliases. Exercise result-column conversion in both
directions for each preset, including Unicode where applicable, and add matching
SQLHelper INSERT/UPDATE assertions for non-camel presets. Ensure the matrix
verifies each exported entry point and its inverse mapping.

Source: Coding guidelines

Comment on lines +196 to +203
test("Throws error for invalid transform options type", () => {
expect(() => {
new SQL({
adapter: "sqlite",
filename: ":memory:",
transform: "invalid" as any,
});
}).toThrow();

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the expected validation error.

Bare toThrow() also passes when construction fails for an unrelated reason. Assert that the error identifies transform and the rejected type. As per coding guidelines: “Every test assertion must be able to fail and assert the strongest invariant; avoid … bare toThrow().”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/sql/sql-transform.test.ts` around lines 196 - 203, Strengthen the
assertion in “Throws error for invalid transform options type” by matching the
thrown validation error’s message, ensuring it identifies the transform option
and the rejected type. Keep the invalid transform setup unchanged while
replacing the bare toThrow assertion with a message-specific expectation.

Source: Coding guidelines

Jarred-Sumner added a commit that referenced this pull request Oct 17, 2026
…quests (#45304)

### What does this PR do?

It folds 197 open pull requests on `Bun.sql` (PostgreSQL, MySQL, MariaDB, SQLite), `bun:sqlite` and `node:sqlite` into one change: 149 are replaced by it, 18 are closed without porting, 30 stay open. Most PRs were taken as a diagnosis; the files many of them patched were rewritten once. 257 files, +41,670 / -21,600: `src` +12,536 / -16,054, tests +26,903 / -5,100. CI ran the suite on Linux x64 / aarch64, musl, ASAN, macOS and Windows at this head (78e0830cdd): every job that ran passed (178; one macOS aarch64 job could not download the build, two macOS x64 jobs got no machine). Numbers marked (release) compare CI's Linux x64 release binaries of main (f841e3670) and of earlier commits of this PR, in user-space instructions unless a unit is given; the others are from a local debug + ASAN build.

### How did you verify your code works?

102 SQL and SQLite test files on a Linux x64 debug + ASAN build against PostgreSQL, MySQL and MariaDB containers: 5,918 tests pass; 8 time out on a machine at load 210 to 250, each of which passes alone or is known to depend on load. Nearly every new test was run on main first and fails there; the exceptions are guards that pin behaviour we keep. PostgreSQL parameters against main, cell by cell (value, type, mode): of 942, 726 are equal, 137 work that main rejects, 17 reject with another code, 44 are new errors, 18 stored another value (six cases: two are main's again since, four are listed). The option parser against main: 120,000 generated cases, every difference named; TLS is weaker than on main only on a unix socket (3,191), where `url` names another driver than a variable's name (14), or where the adapter's own URL rules and main read another adapter's (21); no file main opens read-only is writable. Option keys that an options object inherits are read where main reads them. On the wire against main, the startup packet and the number of round trips are the same (no parameter, one, jsonb + text + timestamptz, begin / commit; prepared and `prepare: false`); only a redundant Flush is gone. Client CPU (release, 43ad527e44): point select -48 %, 100-row select -30 %, insert -21 %. The pool, `cancel()`, MySQL pipelining and the `bun:sqlite` hooks were also run under randomized stress or differential scripts.

### Behaviour changes to look at first

`main -> now`. The rule: a visible change stays only where main is broken (crash, hang, data lost or stored wrong, security hole, against its own docs); elsewhere this PR behaves like main, and the pull request that wanted the change stays open. Every bullet was run on main, on this branch and, where there is one, on the reference clients: postgres.js, pg, mysql2, mariadb, Node 26.3's `node:sqlite`, psql 15 (not better-sqlite3). `ERR_*_X` stands for the `POSTGRES`, `MYSQL`, `SQLITE` codes.

**Waiting for the maintainer's word**

- **Integers from 2^51 to 2^53 - 1** were `float8` (MySQL: DOUBLE) -> `int8` (BIGINT). PostgreSQL stored 2^52 + 1 as 4503599627370500 in `numeric` and `4.503599627370497e+15` in `text`; now exact. The price: `select ${2 ** 52} as x` returns a string and `/ 3` divides as integers, as from 2^31 to 2^51 already. On MySQL only VARCHAR was wrong; there `* 4096` is a new errno 1690, and mysql2 and mariadb behave like main (#43293).
- **An array with an enumerable `raw` is refused.** `Object.assign([text], { raw: [text] })`, also what Babel's and swc's loose mode emit for a tagged template, ran `text` as SQL, as in postgres.js -> `TypeError`; tslib and the strict helpers (non-enumerable `raw`) still run. It closes `ids.raw = []` on a data array pasting `ids[0]` into the SQL, which only the application itself can set. The own-property half of the check closes more: on main a polluted `Array.prototype.raw = []` turns `sql([userData])` into SQL text. The price is every tagged call compiled in loose mode (#38219).
- **An untyped `sql.array()` has no cast.** `sql.array(["a", "b"])` without a type was cast to `::JSON[]`, so both examples of main's docs fail -> no cast, the server decides (#41301, #41242). Where it can't tell, main and postgres.js work and this PR doesn't: `select ${sql.array([1, 2])}` is the string `{"1","2"}`, `unnest()` 42725, `array_length()` 42804. Pass the type.
- **`prepare: false`:** an object with its own `toString()` bound to `json` / `jsonb` was that string (decimal.js: the number `1.5`) -> its JSON, as in a prepared query on main (#39452).
- **bun:sqlite `db.transaction(fn)`, the minimal fix.** When `fn` returns a native promise that is already rejected (an `async` function that throws before its first `await`), main committed -> rolls back and returns the promise. All else is as on main: an `async` callback commits at its first `await`, a `throw` behind it keeps the rows, and the docs now warn. The alternative is to refuse async callbacks outright (#45132), which also refuses what works today: `async` without an `await`, an `await` that comes last, a returned fulfilled promise or thenable.
- **bun:sqlite, not changed:** without `safeIntegers` a BigInt parameter above 64 bits keeps its low 64 bits (`2n ** 64n + 5n` binds 5), as on main; a `RangeError` for it (a third of #32841) was in and is reverted. Proposed on this PR: reject below -2^63 or above 2^64 - 1, which still lets `Bun.hash()` output bind but makes a released call throw.

**Silent: the same code gives another result**

- bun:sqlite `changes` also counted rows written by triggers, cascades, FTS5 / rtree shadow tables -> only the statement's own, like node:sqlite and SQLite's `changes()`. An insert through an INSTEAD OF trigger on a view counted what the trigger wrote -> 0. The SQLite adapter's `count` follows (#43306).
- `affectedRows` was `null` on PostgreSQL and SQLite -> rows written, 0 for a `SELECT`, so `r.affectedRows ?? r.count` gives 0 after a `SELECT` (#40434, #40432). MySQL `count` of a write: 0 -> rows affected (#41886). SQLite adapter: `lastInsertRowid` of `INSERT ... RETURNING` `null` -> the rowid; `count` of `WITH ... INSERT` 0 -> 1; `command` is the first keyword: an upsert `UPDATE` -> `INSERT`, but `with ... insert` `INSERT` -> `WITH`.
- `uuid[]`, `int4range[]`, `tsvector[]`, `reg*[]`, 35 built-in array types in all, read as the literal `"{...}"` -> arrays, like postgres.js (pg: `uuid[]` only) (#41042, #41039). Columns named `01` and `1` collapsed into one key -> each keeps its name; `row[1]` no longer finds `01` (#45096).
- Into `text`: a `Date` was `Tue Jan 02 2024 03:04:05 GMT+0000 (...)` -> ISO 8601; a plain object was `[object Object]`, as in postgres.js -> JSON, as in pg (#41976, #29010).
- `null` in `sql.array(values, "TEXT")`, `"INT"` and the rest was the string `"null"` or an error -> SQL NULL (#35111); in a `"JSON"` / `"JSONB"` array it stays the JSON value `null`, as on main. A `Buffer` element there was its hex string -> `JSON.stringify(buffer)`.
- PostgreSQL, a late mode on a statement that is already prepared: `.execute().raw()` gives text for `numeric`, `time` and binary for `int2[]`, `float8[]`, the reverse of main; `.raw()` and a late `.values()` of `numeric`, `time` rejects with `ERR_POSTGRES_UNKNOWN_FORMAT_CODE`. All else of `.raw()` is as on main (658 of 666 cells).
- `prepare: false` sends what a prepared query sends (one more round trip next to an array, an `Int32Array`, an object with its own `toString()`): `[1]` into `jsonb` was the scalar `1`, an `Int32Array` into `bytea` the bytes of the text `1,2`; an `Array` into `bytea` is now `ERR_INVALID_ARG_TYPE` (#39452).
- bun:sqlite `get()` read every row, as documented, so an error in row 2 was thrown -> one step. `SELECT 1 a, 2 b, 3 a`: `{b, a}` -> `{a: 3, b: 2}` (key order). A column named like an index (`"2023"`) now comes first in `Object.keys(row)`, as JS orders keys (#37238). `values()` / `raw()` without columns: `null` -> `[]`. node:sqlite `run()` on a `SELECT` stepped through every row -> one step. All like Node.
- SQLite's `MEMSTATUS` is off: `PRAGMA hard_heap_limit` / `soft_heap_limit` are accepted and no longer enforced, as in node:sqlite (#44197, #44187).
- PostgreSQL: a Flush after every simple Query disarmed the server's idle timers; after an extended query they already worked -> `idle_in_transaction_session_timeout` / `idle_session_timeout` end Bun's sessions on both paths (25P03, 57P05), `listen()` included (docs: `connection: { idle_session_timeout: 0 }`) (#44784).
- After `SET datestyle = 'SQL, DMY'` April 3 read as March 4, as in postgres.js -> any non-ISO style reads Invalid Date, also MDY, which main read right.
- TCP keepalive (60 s idle, 10 probes 1 s apart) on every SQL socket, no option, as in postgres.js (pg, mariadb: none): a query silent for 60 s can reject after a 10 s outage, where main resumed (not run). #44983 asked for `listen()` only.
- `then()`, `catch()`, `finally()`, `run()` started the query a microtask later -> in the call, like `execute()` (#44105). `begin()` / `reserve()` overtook older plain queries -> one queue, oldest first, like postgres.js (#45275). `sql.file(path)` ran at once -> a lazy `Query`, as typed and as in postgres.js.
- A result returned from a `begin()` / `savepoint()` callback came back as a plain `Array` without `count`, `command`, `columns` -> the result itself, like postgres.js; `assert.deepStrictEqual` with an array literal then fails (#45387).
- A statement on an ended `tx`: `ERR_*_CONNECTION_CLOSED` -> `ERR_*_INVALID_TRANSACTION_STATE`, because retry helpers rerun on "connection closed" (#43263).
- With a URL, `host` / `user` / `pass` beat `hostname` / `username` / `password`; without one they lost -> the long names always win. `?path=A` beat `{ path: B }` (also `unix://A` and `{ socket: B }`) -> the option wins, like every other option.
- MySQL / MariaDB ignored `?connect_timeout=`, `?idle_timeout=`, `?max_lifetime=`, `?max=` in the URL -> they set the options (see New).
- MySQL / MariaDB: `close()` and the timers send COM_QUIT, like mysql2 and mariadb. "Connection closed" / "failed to bind and execute query" for most client-side codes -> one sentence per code (#38468). An ERR packet the server sends unasked reaches `onclose` as the server's error, not as `ERR_MYSQL_UNEXPECTED_PACKET` (#40913).
- Driver errors: `stack` is the header line only (it had two `internal:sql/*` frames); a non-`Error` thrown while a parameter is read is no longer wrapped (#44118, #44204). PostgreSQL statement names in server logs: `P` + 40 bytes of SQL + `$` + id -> `P<id>` (#44042).

**Loud: an error where main ran**

- `PGUSERNAME` and `USERNAME` are read as the PostgreSQL user, as main's docs promise; main ignored both. `PGUSERNAME` beats `USER`. `USERNAME` counts where `USER` isn't set, as on Windows, where main logged in as `postgres` (not run there) (#26711, #26684).
- `mariadb://` without a database asked for one named `mariadb`, which no server has, and couldn't connect (1049) -> selects none, like the mariadb connector; the first unqualified table is a 1046. MySQL keeps `mysql`, as main's docs promise; the types said the user name (#44367, #44373).
- MySQL / MariaDB: `sql("db.table")` was one name `` `db.table` `` -> `` `db`.`table` ``, as documented, as on PostgreSQL and SQLite and in mysql2 (mariadb keeps one name). Helper keys (`sql({ "c.d": 7 })`) and aliases split too, so a name with a real dot can't be passed. A name taken from a user can now reach another schema (#24050).
- A query created on `reserved` / `tx` / `sp` and first awaited after `release()`, COMMIT, ROLLBACK or RELEASE ran on the pooled connection, as in postgres.js: a `select` worked, an insert that landed in another caller's transaction resolved and kept 0 rows -> rejects unsent. Typical: ``return reserved`...` `` without `await` out of a `using` block (#43261, #43263).
- `sp === tx` in `tx.savepoint(sp => ...)` -> `sp` is its own handle, like postgres.js, and ends with its savepoint; on main and in postgres.js it kept running in the transaction (#43263).
- PostgreSQL: an array or object bound to `bool` / `int4` / `float8` / `timestamp` was coerced in the client (`[]`: `true`, 0, 0, 2000-01-01), so `delete ... where ${[]}` removed every row -> sent as text, which the server rejects (22P02, 22007) unless it is valid: `[5]` into `int4` is still 5, and `['2024-01-02']` into `timestamptz`, 2000-01-01 on main, is that date (#41976, #41970).
- An Invalid Date was stored as 1970-01-01 on PostgreSQL (#41976) and MySQL -> rejected. MySQL: a function, or an object whose `toJSON()` returns undefined, was bound as `""` (MariaDB: errno 1210) -> `TypeError`, like mysql2.
- bun:sqlite: `stmt.run(new Date())`, a Map, a Promise as the only argument bound NULL, as in node:sqlite; in any other position main throws already -> `TypeError` everywhere. An `ArrayBuffer` alone bound NULL -> BLOB.
- `channelBinding: "require"` / `?channel_binding=require` on MySQL, MariaDB, SQLite was an unknown key and ignored -> `TypeError`.
- A `path` that doesn't exist was dropped and TCP dialed -> `ERR_*_CONNECTION_REFUSED` (postgres.js, pg: ENOENT) (#41570).
- `tls.checkServerIdentity` was never called -> called, and it raises an `sslmode` below `verify-ca` to `verify-full`, so a self-signed certificate without `ca` fails, as in pg and postgres.js (#42054, #41856).
- SQLite adapter: what `onconnect` / `onclose` throw was swallowed -> uncaught exception, as on the other adapters. `?mode=RO`, `?mode=readonly`, `?mode=`, `{ readOnly: true }` opened read-write -> throw (#45153, #45079).
- The guard against `BEGIN` on a pooled client also catches `START\tTRANSACTION` and `/* c */ BEGIN`, and no longer trips on `begin_work()` or copies the query (#41758, #41740).
- A nested `sql.unsafe(text, values)` behind other parameters read the wrong parameter on PostgreSQL, as in postgres.js -> `SyntaxError` (#38220).
- A simple `FETCH` from a binary PostgreSQL cursor decoded `numeric`, `time` and read `int2` / `oid` as 0, `int8` / `uuid` as garbage -> these reject with `ERR_POSTGRES_UNKNOWN_FORMAT_CODE`.
- `COPY ... TO STDOUT` without a stream resolved `[]`, `COPY ... FROM STDIN` hung and wedged the connection -> both reject with `ERR_POSTGRES_COPY_NOT_SUPPORTED` (#45332).

**Main was broken; code that leaned on it will notice**

- A pooled connection that came back inside an open or failed transaction (a reservation after `BEGIN`, `sql.unsafe("select 1; begin")`) went to the next caller, who got 25P02 or lost its writes -> the drivers keep the server's transaction status and the pool closes it. For that status to be current, a failed PostgreSQL query rejects at its ReadyForQuery, like libpq, not at the ErrorResponse. Exempt: plain queries at `max: 1`, where the docs allow `BEGIN` by hand, and a prepared XA branch.
- `idleTimeout` / `maxLifetime` closed connections under a running query, a reservation or a `begin()` transaction -> they never interrupt those, as in postgres.js; past `maxLifetime` a connection takes nothing new, finishes, closes (#30648, #30646, #25405).
- What the caller's own `catch(f)`, `then(_, f)` or `finally(f)` handler throws was swallowed -> an unhandled rejection. MySQL / MariaDB: `begin()` never settled when a statement on `tx` was only started and failed -> settles. (94 cells against main: these 32 differ.)
- Callers waiting for a connection were rejected with the close error of somebody else's connection -> the pool dials for them (#44938). A client-side auth or TLS error before the first connection was cached until restart -> every query dials.
- A second `close()` resolved while a session was still open -> all calls wait for one close, the shortest timeout wins (#43959). `close({ timeout: 0 })` waited for running queries -> closes at once, as documented (#33740, #32038).
- `tx.close()` without `await`: `begin()` resolved with nothing stored, or committed -> one ROLLBACK, no COMMIT, `begin()` rejects (#44799).
- Two `reserved.begin()` at once shared one server transaction -> the second waits. SQLite: a second `sql.begin()` failed with "cannot start a transaction within a transaction" -> it waits. Side effect: the same call awaited inside its own callback never settles, like `max: 1` on a server; use `tx.savepoint()`.
- `where id in ${sql([])}` was a syntax error on the servers -> `in (NULL)`, no row, like postgres.js. SQLite keeps `in ()` / `not in ()`.
- Statement caches were unbounded -> a connection keeps the statements it used last, 1,024 on PostgreSQL and 256 on MySQL / MariaDB (there all connections share the server's `max_prepared_stmt_count`, 16,382 by default), and sends Close / COM_STMT_CLOSE for the rest (#28980, #33190). MySQL: a failed prepare was replayed, even after `CREATE TABLE` -> every use prepares again (#33189, #42720).
- A SCRAM password that SASLprep changes costs a second login (see New), so a wrong one counts as two failed logins per query (main: `max`) and a right one as at most one per `SQL` instance: the rest of the pool waits for the first login, which fills the pool one login later for such a password only. A `password` function that returns a promise is not held: `max` each. Other passwords, md5 and cleartext roles: one login, as before (#41605).
- `$PASSWORD` was sent as the database password -> not read. `postgres:///x` was a socket if `/x` existed -> always database `x` (#41570).
- `sql("a\0b")` returned a rejected promise no `try` could catch -> throws at the call (#45160). A `resolve` / `reject` overwritten on a query with a function that throws hung `close()` -> uncaught exception; under an `uncaughtException` listener it still hangs.
- bun:test: `expect(p).resolves` / `.rejects` / `expect(() => p).toThrow()` never called `then()`, so a lazy `Query` hung `bun test` forever -> they call it once while `p` is pending, like Jest (#19130).
- MySQL / MariaDB: `lastInsertRowid` above 2^53 - 1 was rounded -> exact, a string, or a BigInt with `bigint: true` (#44646). Commands are pipelined (see New). A command of 16 MB or more failed in the client (`ERR_MYSQL_OVERFLOW`, session kept) -> it is sent; over `max_allowed_packet` the query gets the server's 1153 and the connection fails, because the server hangs up. A 0xFB reply (a request for a local file) hung the query -> fails the connection, no file is read.
- MySQL / MariaDB: prepared `TIME(n)` lost its fraction -> `"12:34:56.789"`, padded to the column's precision like `.simple()` (mysql2 and mariadb don't pad) (#31738). `.raw()` on a prepared query gave a string for TIME, a `Date` for DATE, DATETIME, TIMESTAMP -> a `Buffer`, as documented. `caching_sha2_password` over a unix socket couldn't log in -> the password goes in clear, as the server expects there and as mysql2 and mariadb do (#37819).
- bun:sqlite: `{ readonly: true, create: true }` opened read-write -> `TypeError` (#41815). `db.run("a; b; c")` / `exec()` swallowed a runtime error of any statement but the last -> throws there, the rest doesn't run (#37418, #37415, #41010). Running a `Statement` again inside its own `iterate()` loop restarted the loop forever -> the newest run wins, the older iterator throws "This iterator was invalidated" (#32917). Both like node:sqlite.
- node:sqlite: `stmt.get()` on `INSERT ... RETURNING` returned a row although the commit failed (deferred foreign key) -> throws. Node 26.3 has the bug. Undo: 6 lines in `NodeSqlite.cpp`.

**Costs**

- The first `new Bun.SQL()` of a process: 23.3 M -> 25.1 M (+7.9 %); the first bun:sqlite database +4.6 % (release).
- bun:sqlite `prepare` + `finalize`: 21,739 -> 23,284 (+7.1 %, release), because statements report their native memory to the GC: 20,000 dropped 200-parameter statements 465 -> 27 MB.
- More distinct texts than the statement cache holds are prepared again and again. PostgreSQL, 24,000 queries over N texts at `max: 4` (release, limit still 256): N = 1,000: 2.0 -> 4.7 s, client CPU 470 -> 880 ms; N = 300: 1.4 -> 1.6 s. With 1,024 (debug builds) 1,000 texts cost what 300 cost; the step lies between 1,000 and 1,100. A full cache costs the server about 41 MB per connection (41 KB per statement with a join and ten columns); main has no bound.
- A bulk insert with an `undefined` cell on PostgreSQL: +2.1 % (see Memory and performance).

### New

**`query.cancel()` works.** It was typed and documented but did nothing. A query that isn't written yet leaves the queue, is never sent and rejects with `ERR_*_QUERY_CANCELLED`. On PostgreSQL a running query with nothing else written behind it is stopped by a CancelRequest on a second connection, outside `max` (same peer, same TLS settings and certificate check, at most 5 s) and rejects with the server's 57014. The session writes nothing until the server has closed that connection, so a late cancel can't hit the next caller's query. There `reserved.close()` / `tx.close()` also stop the running query (#33370, #44625).

**COPY streams (PostgreSQL).** `query.readable()` is a `ReadableStream<Uint8Array>` for `COPY ... TO STDOUT`, `query.writable()` a `WritableStream` for `COPY ... FROM STDIN`. Bytes only (text, CSV, binary), no row encoder. Backpressure both ways. `close()` / `pipeTo()` resolve when the server has stored the rows and reject with its error; `abort()` sends CopyFail, and so does the end of the transaction, savepoint or reservation that a stream is open on: `begin()` then rejects with that error and rolls back. The query still resolves with `count`. They are web streams, not Node streams, and a `COPY` without parameters always goes out as a simple query (#23350, #15088).

**`result.columns`, `result.statement`.** PostgreSQL: `[{ name, type, table, number }]` (OIDs, like postgres.js); MySQL / MariaDB: `[{ name, type, table, length, flags }]`. `statement` is `{ string, types, columns }`. Both are built on the first read, prepared or not. The runs of a prepared statement share one object, which isn't frozen. On SQLite both stay `null`: capturing them cost a 1-row query +17 % to +34 % (main's release build) (#30037, #26809, #18866).

**Column name transforms (all adapters).** `new SQL({ transform: SQL.camel })` reads column `user_id` as `userId` and writes `user_id` for `sql({ userId })`, `sql("userId")`. Presets `SQL.camel`, `pascal`, `kebab`; one direction with `SQL.toCamel`, `SQL.fromCamel` and so on; or `{ column: fn | { from, to }, undefined: value }`. Names, directions and casing are postgres.js's. With `undefined: value` (`null` counts) a helper leaves no column out, like postgres.js. No `value` / `row` transform, so keys inside json aren't renamed (#36451, #15087).

**`sql.json(value)` (all adapters)** stores any value as JSON: a number, a boolean, `null`, an object or array on SQLite, and a JSON *string* where Bun sends a string as it is (MySQL, MariaDB, SQLite, PostgreSQL with `prepare: false`). On PostgreSQL it returns a small wrapper, elsewhere `JSON.stringify(value)`.

**SCRAM-SHA-256-PLUS, `channelBinding: "disable" | "prefer" | "require"`**, also `?channel_binding=` and `PGCHANNELBINDING`. `tls-server-end-point`, hash as in RFC 5929, RSASSA-PSS included, checked against libpq 15. `require` fails with `ERR_POSTGRES_CHANNEL_BINDING_REQUIRED` before anything derived from the password is sent. Off (`disable`) unless asked for, on the maintainer's decision: the login on the wire is main's. With `prefer`, psql's default, a login fails behind a proxy that terminates TLS but forwards the server's `-PLUS` offer (28000) and with an Ed25519 certificate (#41703).

**SASLprep, by retry.** A non-ASCII SCRAM password typed in another Unicode form than it was set in (NFD, soft hyphen) got 28P01 while psql logged in -> the first login uses the password as typed, like main; on 28P01 from a SCRAM role, if SASLprep (two `replace` and `normalize("NFKC")`, no Unicode tables) changes the string, Bun logs in once more with that form. The pool remembers which form worked; the LISTEN connection does the same (#41605).

**`tls.checkServerIdentity(hostname, cert)` (PostgreSQL, MySQL, MariaDB).** Called once per connection, after the chain verified and before anything is sent inside TLS; replaces the built-in name check. A returned object or a throw fails the connection with that value; a Promise is `ERR_INVALID_RETURN_VALUE`. Not called next to `rejectUnauthorized: false` (SQL half of #42054, #41856).

**Re-prepare after DDL (PostgreSQL).** After `ALTER TABLE`, `DEALLOCATE ALL` or `DISCARD ALL` every run of a cached statement failed forever (0A000, 26000) -> it is prepared again under a new name and the old one closed. The query is re-sent only when nothing is written behind it (a re-send would reorder writes) and outside a transaction block; otherwise it rejects with the server's error and the next run works. 20 concurrent runs after `ADD COLUMN`: main rejects 20 and every later run; now 19 at `max: 1`, 15 at `max: 5`, then none (#35120, #29484).

**`bun --hot` keeps database connections (PostgreSQL, MySQL).** Every save opened up to `max` new connections and never closed the old ones (2 4 6 8 10 12 sessions at `max: 2`) -> a `new SQL()` with equal settings takes over the previous load's idle connections when it would dial. Settings are compared by value; a function counts as changed. A pool of an earlier load closes only when nobody asked it for a connection during a whole load: a replaced pool lives one reload longer, a pool kept on `globalThis` and used is never touched. Session state (`SET`, temporary tables) survives, `onconnect` runs again for a reused connection. A module-level `sql.listen()` shares one LISTEN connection across loads (#25623, #23215).

**MySQL / MariaDB pipelining.** `.simple()` queries and queries whose statement is already prepared go out without waiting for earlier replies; an error fails only its own query. Nothing is written behind a text with a `;` until it has ended, and a packet out of sequence fails the connection. Main's docs promise this; it sent one command at a time. Undo: `BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING=1`. 4 executes through a proxy that holds each reply 30 ms took 124 ms on main, now 31 ms on both servers, 1 write instead of 4 (release) (#44022).

**MySQL: `sha256_password`** was documented, but every login failed with errno 1045.

**Arrays as PostgreSQL array parameters.** `where id = any(${[1, 2]})` (08P01) and `sql({ tags: ["a"] })` into a `text[]` failed -> a JS array or an `Int32Array` bound to a built-in array type goes as an array literal, with NULLs and nesting, also with `prepare: false`. The other typed arrays stay `bytea`; `sql.array(new Float32Array([1.5]))`, which main rejects, puts them into an array column. Into `text`, an untyped position or a user-defined type an array stays `String(value)` (`a,b`), as on main and in postgres.js (pg: `{"a","b"}`), so an array of an enum needs `sql.array(values, "mood")`. A plain array into `json[]` / `jsonb[]` follows `sql.array(values, "JSONB")`: nested arrays are dimensions, `null` is the JSON value, `undefined` is SQL NULL, `sql.json([1, 2])` is one element (that half of #33579, #29551).

**MySQL / MariaDB `foundRows: true`**, also `?foundRows=true`: `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows matched, not rows changed, the default of mysql2 and mariadb. Off by default, as on main (#30848, #30843).

**libpq URL keys and variables.** `?connect_timeout=`, `?sslrootcert=` ... went to the server, which refused the connection, and `?password=` went out in the startup packet. Now read: `host`, `port`, `user`, `password`, `dbname`, `connect_timeout`, `idle_timeout`, `max_lifetime`, `max`, `prepare`, `channel_binding`, `sslrootcert`, `sslcert`, `sslkey`, `sslpassword`. Keys that only tune libpq (`keepalives*`, `target_session_attrs` ...) are dropped. Keys that restrict the connection (`require_auth`, `sslcrl` ...) still go to the server, which refuses them. New variables: `PGUSERNAME`, `USERNAME`, `PGAPPNAME`, `PGOPTIONS`, `PGCONNECT_TIMEOUT`, `PGCHANNELBINDING`.

**bun:sqlite: `db.function()`, `db.aggregate()`**, better-sqlite3's signatures (options `varargs`, `deterministic`, `directOnly`, `safeIntegers`); `inverse` makes a window function. What a callback throws comes out of `get()` / `all()` / `run()` unchanged. A BigInt result outside 64 bits follows the function's `safeIntegers`, like a parameter (#8558, #25703, #1474, #5051).

**bun:sqlite: `db.onInsert`, `db.onUpdate`, `db.onDelete`** `= function (database, tableName, rowid, schemaName)`; `null` removes it. What a hook throws is an uncaught exception at once, and the statement goes on. While a function, aggregate or hook runs, every other call on that `Database`, `close()` included, throws "This database connection is busy executing a query". No commit hook (#12631, #4175). **Small:** `db[Symbol.for("sqlite-type")]` (#22109), `stmt.reader`, `stmt.readonly` (#16469), `stmt.declaredTypes` before the first run.

### Fixed

What is listed above isn't repeated. A line without `->` names a symptom that is gone.

#### Pool and lifecycle

- A pool that lost connections dialed nothing while one was ready (it stayed at 4 of 5 for ever and ran 3 parallel queries on 1 backend) -> it grows back, one dial per waiting caller that no dial in flight covers.
- `execute()` / `begin()` inside `onclose` got the dead connection ("connection must be a PostgresSQLConnection") -> it has left the pool and the waiting callers are redialed for before `onclose` runs (#43958, #44938, #45039).
- A connection the server killed in the read that finished the handshake got `onclose`, then `onconnect`, and stayed in the pool: every later query failed, `close()` hung -> `onconnect` never comes after `onclose`, both drivers (#40913, #45039, #42804).
- `close({ timeout: 2 ** 31 })` overflowed `setTimeout` and killed the queries after 1 ms -> clamped (#32100, #32096). `close()`, then `close({ timeout: 0 })`, left a held reservation running (#43959).
- A replaced `Promise.prototype.finally` could wedge the pool -> a private promise; the connection is released before the caller's reactions run (#44315).
- `q.cancel(); await q` never settled -> rejects (#44799).
- The connect-retry budget read `Date.now()`: endless under `setSystemTime()`, short on a clock step -> monotonic clock (#37960).
- A `password` function that throws `undefined` left the query pending forever. `` sql`a; b`.values().simple() `` ignored `simple()`. `query.active` was always false.
- PostgreSQL: a FATAL error for the running query left the connection in the pool until the FIN, and a session ended while idle reached `onclose` as `ERR_POSTGRES_EXPECTED_REQUEST` -> fails at once with the server's error, e.g. 57P01 (#40913).
- `onconnect` / `onclose` never fired for the `sql.listen()` connection (#41051, #41050).
- `bun --sql-preconnect` with a refused connection ran the script, then exited 1, against the docs -> exit 0.

#### Handles, transactions, savepoints

- `sql.begin(tx => Promise.all([...]))` with one failing task: the other tasks' later statements ran after ROLLBACK in autocommit, so rows of a rejected `begin()` or `savepoint()` stayed -> they reject, on all four databases (#43263).
- `tx.unsafe()`, `tx.file()`, `reserved.unsafe()`, `reserved.file()`, `notify()`, `commitDistributed()` ignored the handle's state and ran after its end (#43249, #43261). `` tx`...`.values() `` on an ended handle threw `.values is not a function` -> a `Query` that rejects when run (#43257).
- `tx.close({ timeout })` whose queries finished in time committed -> rolls back (#44799, #32148); `reserved.close({ timeout })` then lost the pool slot. `close({ timeout: -1 })` left the handle unusable (#32100). A handled failing query was reported again as unhandled.
- `close()` or a lost connection started or rejected identifiers, fragments and unstarted queries, and a handle retained them all -> a query is tracked from start to settle (#44932).
- `tx.close()` and the end of the callback could both end the transaction -> one end. Now that `cancel()` works, `close()` spares Bun's own BEGIN / COMMIT / SAVEPOINT (#44799, #44625).
- Connection lost under a `begin()` whose callback doesn't return: later `begin()`, `reserve()`, `close()` hung -> the slot goes back at the loss (#43205). `reserved.unsafe()` then ran on the pool's next connection (#45039).
- `reserved.release()` during a running `reserved.begin()`: the nested COMMIT committed the next caller's work -> the connection goes back after the transaction (#43255).
- `tx.savepoint(sp => [q1, q2])` sent RELEASE before the queries (#43263). `tx.savepoint("audit", fn)` sent `SAVEPOINT s0` -> `s0_audit` (#45111). A failed ROLLBACK lost the callback's error -> it is the `cause` (#41817).
- The `begin()` callback saw no `AsyncLocalStorage` store on a cold pool, another request's on a busy one -> its caller's (#43652).
- A `reserve()` handle dropped without `release()` kept its connection forever -> released when collected (#35117).
- `sql instanceof Bun.SQL` threw -> true for every handle; `class X extends Bun.SQL {}` threw at the definition -> `new X()` throws "Bun.SQL cannot be subclassed" (#42637). `new Bun.sql(url, { tls: true })` dropped the options and connected in plaintext (#45118).
- SQLite: a failed COMMIT left the transaction open and every later `begin()` failed -> rolled back. After `INSERT OR ROLLBACK`, `RAISE(ROLLBACK)`, `SQLITE_FULL` the rest of the callback ran in autocommit -> nothing more runs (#45065). `close()` didn't reject a running `begin()` (#43116).

#### Options, URLs, environment

- `{ filename: "./dev.db" }` without `adapter` dialed PostgreSQL on host `.`; `SQLITE_URL=x.db` opened `postgres://x.db` (#44483). A server's URL in `filename`, or `hostname`, `port` ... next to it, still make it a connection string. `{ adapter: "sqlite", url }` opened `:memory:` (that half of #38427).
- `{ adapter: "sqlite" }` next to a server `DATABASE_URL` used it as the file name -> `:memory:`. `{ adapter: "mysql" }` without a host next to `DATABASE_URL=postgres://...` dialed that host and timed out -> `MYSQL_URL`, else defaults, and then with the TLS level the passed-over URL asks for, as on main (`tls: false` or `ssl: "..."` replaces it); such a URL without a host fills in as on main (#45080).
- `hostname`, `host`, `path`, `socket` or five explicit PostgreSQL fields next to `DATABASE_URL=:memory:` or another SQLite URL opened SQLite -> the server. `{ url: "postgres://..." }` next to `MYSQL_URL` ran the MySQL driver on it and timed out -> the scheme of `url` wins over the name of a variable (those parts of #41586).
- SQLite: `?mode=rw` created the file; `?mode=rw&mode=ro`, a `mode=ro` next to a `#` (`?mode=ro#x`, `?a=b#mode=ro`), `?mode=ro` + `{ readonly: undefined }`, `{ readwrite: false }` opened read-write -> read-only (#38417, #45079, #45153). `bigint: true` did nothing -> `safeIntegers` (#41816).
- `socket:` and `?socket=`, both documented, did nothing -> aliases of `path`. `mysql://user:pass@/db?socket=...` threw `Invalid URL` -> URLs without a host parse (#37807, #23954).
- A host that starts with `/` (`hostname`, `PGHOST`, `?host=`, `%2F...`) went to DNS -> socket directory. `sslmode=require` on a PostgreSQL unix socket failed -> ignored there, like libpq.
- PostgreSQL: a `path` that is a directory was dialed as it is -> `<directory>/.s.PGSQL.<port>`. The pathname of `unix:///run/a.sock` was also the database `run/a.sock` -> the default database. `SQLITE://` / `FILE:` in `filename` or in a variable were `Invalid URL` or PostgreSQL -> SQLite, as in the string form. A NUL in `path` was dropped and TCP dialed -> rejected.
- `console.log(sql.options)` printed the password, `tls.key`, `tls.passphrase` -> `[REDACTED]` (#41725). `sql.options.sslMode = "require"` meant `disable` -> the next query rejects (#43818).

#### Query building

- ``where ${[sql`a`, sql`or b`]}`` bound one parameter and matched every row on PostgreSQL -> the fragments are joined. ``sql({ ts: sql`now()` })``, `sql({ n: sql.array(a, "INT4") })` failed -> work.
- The MySQL update helper knew one spelling of `ON DUPLICATE KEY UPDATE` -> any (#32040, #32035). Keywords inside strings and comments confused helpers. Each helper copied and lower-cased the whole text so far (#43789).
- `Buffer` / `Uint8Array` elements in `sql.array` stored garbage -> `\x` hex; a typed array stored `{NaN,NaN}` -> its numbers (#36241).
- `sql({ "-1": 1 })` threw. An identifier used in another client's query was quoted for the wrong dialect (#24050). `sql.unsafe(text, values)` read the caller's array when the query ran.
- `globalThis.Array = ...` before the first use of `Bun.SQL` threw "The superclass is not a constructor" (#32558).

#### PostgreSQL protocol, types, errors

- Every pattern of NULLs in a statement's parameters prepared another statement, and none was ever closed, so backends grew to several GB -> the cache key is the text plus the declared types, NULL fits any (#28980).
- A statement name could end inside a multi-byte character, and two texts with the same first 40 bytes could share one (42P05) (#44042). A text that ends in NUL put the stream out of step; extended batches sent a Flush before Sync (#44784).
- An `int4[]` / `float4[]` with a NULL or more than one dimension was rejected on a binary path (`NULLS_IN_ARRAY_NOT_SUPPORTED_YET`, `MULTIDIMENSIONAL_ARRAY_NOT_SUPPORTED_YET`) -> a plain `Array`, `null` for NULL, `float4` elements widened. The elements decide, not the header's has-nulls flag, which the server leaves set after the last NULL is gone (those parts of #45211, #45062).
- A `numeric` of 131,069+ digits read `""` or `"-"` when sent in binary -> to decode, `numeric` and `time` are asked for in text and their binary decoders are deleted (#45014); `int2[]` and `float8[]` are asked for in binary. The values are the same.
- Text arrays had a parser with its own number, JSON, date and bytea decoders -> a tokenizer that hands each element to the scalar decoder. Fixed by it: explicit bounds, `'[0:2]={1,2,3}'`, `indkey::int2[]` (#43335, #43332); `1e-07`, `1e+20` in float arrays, `12345678901`, `1e5`, `[null]` in `json[]`, `-0` (#43336, #45305); `json[]` nested over 100 deep.
- BC dates were Invalid Date, dates past year 9999 were read in local time on the text paths -> one parser, same instant under every `TZ`.
- 65,536 parameters, and on MySQL the second run of a failed prepare or a wrong parameter count, rejected with a plain object (no `stack`, not `instanceof Error`) (#44118, #43992, #43993). A failing query stayed pending forever when `Error.prepareStackTrace` threw -> `stack` is made on the first read (#44204).
- `PostgresError.column` / `.line` held a position in Bun's own source when the server sent none -> absent (#45151, #45064). `detail`, `hint`, `constraint` ... were UTF-16 bytes read as text when non-ASCII. `severity` was translated on a server with another `lc_messages`.
- The Bind format code came from the list of types Bun can decode, so `numeric`, `float4`, `time` were announced binary and sent as text (08P01) -> binary only where value and type match (`bool`, `int4`, `float8`, `bytea`, `timestamp(tz)`); the rest is text (#41976).
- `Date` into `date` / `time` failed (#41976, #29010). `prepare: false` sent a `Date` as `toString()` and an object as `[object Object]` (#39452, #29010, #30221, #39450). decimal.js / Temporal values into `numeric`, `time` failed; `URL`, `DataView` threw; a bigint outside int64 failed -> `numeric`.
- A CopyBothResponse hung the query -> "Streaming replication is not supported".
- Ten codes that nothing can produce any more are gone: six `ERR_POSTGRES_*` (`*_NOT_SUPPORTED_YET`, `UNSUPPORTED_NUMERIC_FORMAT`, `INVALID_QUERY_BINDING`, `OVERFLOW`, `INVALID_BYTE_SEQUENCE_FOR_ENCODING`), four `ERR_MYSQL_*` (`INVALID_QUERY_BINDING`, `*LOCAL_INFILE*`, `PASSWORD_REQUIRED`).

#### PostgreSQL authentication and TLS

- `?channel_binding=` was forwarded as a startup parameter (42704). A server that offers no known mechanism got a SCRAM-SHA-256 answer anyway -> `ERR_POSTGRES_SASL_NO_KNOWN_MECHANISM`. A mandatory extension (`m=`) is rejected before the proof (#41703).
- A SCRAM iteration count out of bounds failed with `ERR_POSTGRES_INVALID_MESSAGE` "Failed to read data" -> `ERR_POSTGRES_SASL_ITERATION_COUNT_OUT_OF_RANGE` with the count and the bounds (the error of #38133). A GSSAPI / SSPI request showed an unfilled format string -> "GSSAPI authentication is not supported".
- Over TLS the StartupMessage (user, database) was queued when the handshake started -> written once chain and identity are accepted.
- A TLS 1.2 server that renegotiates reports a second handshake: both drivers sent their login again, and the next query got the reply to it -> ignored after the first.

#### MySQL / MariaDB

- Two queries on one connection could resolve with each other's rows when a parameter's `toJSON` started a query (#32005). `BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING=1` hung every prepared query (#44022).
- One INSERT with K nullable columns was up to 2^K server statements, and statements piled up until the server ran out of memory (a production report is on #33190) (#33190, #28980).
- `caching_sha2_password`: the scramble hashed 21 bytes (nonce + NUL), so fast authentication always failed: plain TCP needed `allowPublicKeyRetrieval: true` at every login, not just the first; a password of 20+ bytes failed with errno 1045 -> 20-byte nonce (#33504, #28161, #26195).
- A length that points past its packet's end hung the query or decoded the next packet as data (a phantom row) -> `ERR_MYSQL_MALFORMED_PACKET` (#35118).
- A `Date` before 1970 was rejected -> years 0 to 9999 (#31209). Prepared `TIMEDIFF()`, `SEC_TO_TIME(3661.25)` lost their fraction (#31738). MariaDB: every object or array parameter failed with errno 1210 (#37125). `DataView` was rejected.
- An idle connection that got a query didn't ref the event loop: exit 0 with the query in flight, or a spinning core under top-level await (#38163, #27102). A short `maxLifetime` with a slow handshake rejected the first query.
- Every `close()` counted as an aborted client on the server (`Aborted_clients` +3 after 3 closes -> +0).

#### SQLite adapter

- A valid `SELECT` resolved `[]` (`'%"%'` in the text, `/*c*/select`, `select*from`, `values (...)`), and `INSERT ... SELECT`, `WITH ... INSERT` reported `count` 0 -> the adapter asks SQLite whether the statement returns columns instead of tokenizing (#33583, #30811).
- A text whose first statement returns rows (`sql.unsafe("SELECT 1; INSERT ...")`, a migration file that starts with a `PRAGMA`) ran only that statement -> every statement runs; the result is the first statement's rows. Only the first statement can have parameters: a later one that has any is refused, where main bound NULL or skipped it.
- Named parameters on a nested `sql.unsafe()` were dropped and NULL was bound: `delete ... where name is not ${sql.unsafe("$o", { $o: "bob" })}` emptied the table -> `SyntaxError`. `sql.unsafe("... = ?", 0)` (also `""`, `false`, `0n`) bound NULL -> binds the value. PostgreSQL: a fragment with a second argument that is no array, in front of a parameter, sent `$NaN` -> works (those parts of #44745, #44867).
- More than about 640,000 rows rejected with `RangeError: Maximum call stack size exceeded` (#38438). `sql.flush()` threw -> no-op (#41816).
- Through the adapter too: multi-statement errors (#37418), `byteOffset` (#45229); a NUL in a file name throws from `new SQL()` (#37006).

#### bun:sqlite

- `{}`, `{ create: false }`, `{ readonly: false }`, `{ readwrite: false }` failed with `SQLITE_MISUSE`, `{ create: false, strict: true }` created the file -> the mode comes from `readonly`, `create`, `readwrite` only (#44908, #32422, #9410, #15876).
- `SQLiteError.byteOffset` counted from the failing statement -> from the start of the text (#45229).
- A kept statement returned rows with the old keys, or values under wrong keys, after `ALTER TABLE` (#32841, #1332); `stmt.columnNames` / `declaredTypes` still update at the next run, as in Node. `SELECT 1`: `row["1"]` was `undefined` (#37238, #18326).
- `{ name: 1 }` for `$name` bound NULL (default), `{ $name: 1 }` threw (strict) -> both work (#13409, #18456). Strict `$1` read key `0` (#37240).
- UTF-16 strings: a lone surrogate read back as three U+FFFD, a leading U+FEFF was dropped, a leading U+FFFE byte-swapped the rest (#32841). A whole number from 2^51 on was stored as REAL (#43281). `db.prepare(sql, "x")` ignored the binding (#25472, #45367); a lone value is still dropped unless the statement has one parameter. A bind that threw in `prepare()` kept the file open after `close()` until a GC.
- `break` out of `iterate()` left the cursor open (`SQLITE_LOCKED`, "database is locked") -> reset (#32917).
- A NUL byte in a file name, extension path or schema name cut the string short -> throws (#37006, part of #38514). `Database.setCustomSQLite()` returned `true` where it does nothing (#36371).
- A bind failure was a plain `Error` -> `SQLiteError`. SQL text over 1e9 bytes failed as a whole -> the limit is per statement. System-library builds asked `dlopen` for `sqlite3` -> `libsqlite3.so.0` (#26629).

#### node:sqlite

- `db.close()` / `using db` kept the file and `-wal` / `-shm` open until every `StatementSync` was collected (`EBUSY` on Windows) -> `close()` finalizes the connection's statements and releases the file at once, like Node (#40005, #41890, #40001).
- `stmt.get()` built a new Structure per call (#44103, #44084). A `Proxy` (Vue `reactive()`, MobX), a module namespace or a `String` object as named parameters bound NULL to everything. `applyChangeset()` errors had an `errstr` Node's don't have (#34453).

#### Crashes and memory safety

- PostgreSQL: a query started from a parameter's `toString()` / `toJSON()` on the same connection panicked (`@intCast`) with `prepare: false`, or gave 08P01 plus two queries that never settle -> parameters are converted first, writing Bind runs no JS, and the nested query runs behind the outer one (#43918).
- `sql.options.idleTimeout = -1` (or `"abc"`, `{}`) panicked at the next connection, both drivers (#43818). `run()` on the private PostgreSQL query handle with a missing argument panicked (SQL half of #44897).
- MySQL: a later parameter's `toJSON` that shrank an earlier resizable `ArrayBuffer` segfaulted -> bytes are read after all user code ran (MySQL half of #38289, #38277).
- bun:sqlite: `db.fileControl(op, null)` segfaulted, `(op)` threw -> storage Bun owns (#44089, covers #36301). A `Database` could be closed under its own running `serialize()` (#41611). `RAISE(ABORT, '')` hit a debug assertion (part of #43797).
- bun:sqlite, node:sqlite: a 2^30-character string aborted the process -> `RangeError` (parts of #42868).

#### Memory and performance

- GC cells per awaited `select 1` (release): PostgreSQL 84 -> 28, MySQL 76 -> 27, SQLite 52 -> 33 (whole PR; #44105, #44315). Per cached PostgreSQL query: 47,770 -> 23,291 instructions (-51 %).
- A bulk insert of 1,000 x 10, end to end (release, 43ad527e44, the union of keys still in): PostgreSQL -38.0 %, MySQL -52.6 %; with one `undefined` cell +2.1 % and -15.7 %.
- The PostgreSQL read buffer was never compacted while reads ended inside a message (rows larger than a socket read): a 1,000 MiB COPY stream grew RSS by 1,235 MB without the fix. Now (release): 255 MiB/s, peak RSS 59 MiB.
- bun:sqlite (release): `all()` per row, `get` by key and an insert of 3 ints cost 4,613, 8,375 and 4,489 on main, now 3,225, 6,502 and 3,381; 29 of 31 cases are cheaper. Rows of 63+ columns made one Structure per row -> one per statement, 1,665 -> 310 per cell (#31669); an `exec()` of 80,000 statements 5.34 -> 0.11 s of CPU (#45207).
- bun:sqlite: the `Database` is a GC cell that owns its connection (#41611), so dropped `deserialize()` databases and failed opens (#34898) no longer pile up: 40,000 of the first +3,016 -> +3.9 MB. #28911's `db.query()` loop 953 -> 92 MB; text over 64 KB isn't cached (#28912, #28911). Workers shared SQLite's malloc mutex -> configured once per process, `MEMSTATUS` off and `MULTITHREAD` (#44197).
- node:sqlite (release): a 5-column `get()` 13,458 -> 4,077 (#44103); all 10 cases are cheaper.

#### Tests and CI

- **The PostgreSQL test containers crash-restarted under load** (57P03 "the database system is in recovery mode"). We infer: docker kills the `sh -c pg_isready` health check at its 5 s timeout, the postmaster (PID 1) adopts the orphan and takes its exit 2 for a crashed backend. `test/docker/docker-compose.yml` now has an exec-form check and `init: true`.
- `sql-tls-ip-literal-host.test.ts` failed 16 of 108 runs on main (mock on 127.0.0.1, client dialed `::1`) -> 0.
- `postgres_auth` had the md5 and SCRAM verifiers swapped and no test used it -> fixed; the login tests are in `test/js/sql/postgres-auth.test.ts`.
- MariaDB runs the whole `sql-mysql.test.ts` matrix. `prestart-map.mjs` is complete (#35912, #44043), and `test/internal/prestart-map.test.ts` fails when a test's container is missing. The `isDockerEnabled()` wrappers are gone, so `BUN_TEST_SERVICE_*` overrides run the suites (#37123, SQL part of #39850).
- Ported test-only PRs: #36948 #37114 #38221 #40193 #40194 #40537 #40633 #41104 #41176 #42587 #44035 #44850.

#### Docs and types

- `docs/runtime/sql.mdx` was rewritten.
- Types: ``await sql`...` `` carries the result metadata (`SQL.ResultMetadata`) (#40434, #30037); `release()` is `Promise<void>` (#43255); `SavepointSQL` has `savepoint()`; bun:sqlite options accept `undefined` under `exactOptionalPropertyTypes` (#45079).
- `onconnect` / `onclose` were documented as receiving a client -> an error or `null` (#38625, #38634). `timestamp` decodes as UTC (#39881, #39879). Multi-statement files are atomic on PostgreSQL only (#41744). Also #41813 (SQLite codes, placeholders, MySQL DDL), #44998 (what a savepoint holds), #44373 (defaults), #42206 (SQL half), #41094.
- bun:sqlite: examples call `db.run()`, not the deprecated `db.exec()` (#24482); typings and docs for `safeIntegers()` (#26627), `isFinalized`, `toJSON()`, `clearQueryCache()`; `get()` / `all()` without arguments reuse the last bound values (#31747, #26622).

### Found and not fixed

"Also on main" is marked (m).

- **Worth a close read:** `signature_digest_nid` in `src/boringssl_sys/boringssl.rs` is new unsafe FFI over a certificate the peer chooses, and `on_handshake` now runs user code.
- **node:sqlite, macOS:** #45171 (read-only open of a WAL database whose sidecars are gone) isn't fixed and gets easier to hit, because every `close()` is now a real close.
- **Options, decided by the maintainer: as on main.** `NODE_TLS_REJECT_UNAUTHORIZED=0` also switches off `sslmode=verify-ca` / `verify-full` (#41589). `new SQL({ hostname })` takes the other fields of an environment URL, its user and password too (#41586). A `TLS_*` URL is taken as written: `?sslmode=disable` or `tls: false` make it plaintext. `PGDATABASE` / `MYSQL_DATABASE` / `MARIADB_DATABASE` beat the URL's database (#45276, #45287). The hardening was in this PR, is out again, and stays open in those pull requests.
- **Pool (m):** no lazy or `min`-sized pool (#30631, #30632, #30636), no `AbortSignal` (#23175). Queries assigned to a connection but not sent yet die with it (#44924). At `max: 1` with a `BEGIN` by hand, `idleTimeout` / `maxLifetime` can close the connection between two statements (documented). Other callers' queries already sent behind a query that leaves a transaction open are rolled back with it.
- **`--hot`:** a kept pool that nobody asks for a connection during a whole load has its idle connections closed and dials again on demand. An uncleared `setInterval` of an earlier load that queries makes its old pool fill again (m). `listen()` callbacks of earlier loads keep running (m).
- **cancel():** a running PostgreSQL query with another one written behind it rejects at once but runs to its end on the server, as on main. MySQL / MariaDB (m): a query on the wire runs to its end. No `KILL QUERY` is sent: it names a session, and a late kill hit the session's next query in 34 % to 88 % of our tries, depending on server and set-up (#44625).
- **MariaDB XA**, `max > 1` (m): the connection that prepared a branch answers other callers with 1399, and `commitDistributed()` elsewhere gets 1397. New: `reserved.beginDistributed()` directly followed by `reserved.release()` closes the connection.
- **PostgreSQL (m):** `real`, `int4[]` and `float4[]` follow the wire format: `0.1` / `Array` in text, `0.10000000149011612` / a typed array in binary (#41888, #45211). A string bound to `json` / `jsonb` is stored as a JSON string when prepared, like postgres.js, and sent as it is with `prepare: false`, like pg; so `${JSON.stringify(obj)}` stores a string (#28819, #40944). Arrays of user-defined types read as the literal `"{a,b}"` (#41040), composites as `"(1,q)"` (#20379). A fractional number into `numeric` keeps 15 digits, a bare number into `json` / `money` fails (42804). `prepare: false`: a `Date` into `time`, `json`, `jsonb` is rejected.
- **PostgreSQL auth:** `prefer` doesn't stop a server from asking for a cleartext or md5 password; `require` does. (m): the SCRAM iteration count must be within 4096..10,000,000, deliberate hardening; libpq, postgres.js and pg accept any count >= 1. Parity is the range check in the SASLContinue arm of `PostgresSQLConnection.rs` (#38133).
- **MySQL:** `sha256_password` over a unix socket still needs `allowPublicKeyRetrieval: true`. (m): FLOAT reads `0.1` from `.simple()`, `0.10000000149011612` prepared, like mysql2 (#41888); BIGINT above 2^31 is a string; after `USE` a cached text still runs in the OLD database, silently; a multi-statement text that `KILL QUERY` hits between two statements never settles (`maxLifetime` bounds it).
- **SQLite:** on every adapter `transform.undefined` skips `sql.unsafe()` values. `db.prepare("a; b").run()` runs only `a`, like node:sqlite (documented).
- **Not reproduced on Linux, no claim:** #24130 #24845 #24434 #23305 #30494 #16844 #17173 #23654 #32099.

### Decisions reversed while writing this

- `--hot` was first going to close the old load's connections; the maintainer overruled it. "Close when the settings changed" then closed pools the program still used; now use decides.
- The MySQL statement cache was going to be keyed on the text alone, like mysql2 and mariadb. MySQL >= 8.0.22 settles a parameter's type at its first non-NULL value, so `WHERE name = ${"bob"}` after `${5}` returned wrong rows: one statement per text and set of non-NULL types.
- Ed25519 under `prefer` was going to fall back to unbound SCRAM, as in #41703; psql 15 fails, and a fallback lets whoever picks the certificate switch the binding off.
- `SQLITE_DEFAULT_WAL_SYNCHRONOUS=1` (#20662) was in and is reverted: an upgrade must not lower the durability of a COMMIT.
- On the maintainer's decision: a bulk insert takes the first row's keys as its columns, as on main and in postgres.js, not the union of all rows' keys (half of #35111); `int4[]` / `float4[]` and `real` follow the wire format, not one form on every path (#45211, #41888); `console.log(rows)`, `Object.keys(rows)` and a spread list `count`, `command`, `lastInsertRowid`, `affectedRows`, as on main (#38443; `columns` and `statement`, new, are not listed); MySQL's default database stays `mysql`.
- Five more are main's again, because main works there and nobody asked: a failing query that nobody handles no longer ends the process; the first query fills the pool to `max` (the lazy pool, #30636); a caller whose dial is refused waits for a connection that is reserved or in a transaction; PostgreSQL `.raw()` returns main's bytes, not text; in `sql.array(values, "JSON" | "JSONB")` `null` is the JSON value and a nested array a dimension.
- Taken out before the merge, because main works there. Values: a string into `json` / `jsonb` sent as it is (#40944), a JS array into `text` as an array literal, `Infinity` untyped, typed arrays by the server's type, `foundRows: true` by default, `not in (NULL)` for an empty list on SQLite, the result mode fixed at `execute()`. Errors: for an `async` `db.transaction()` callback (#45132), `parts.map(sql.unsafe)`, `sql.unsafe(text, 0)` (#44745, #44867), `new Database(path, true)`, `{ readonly: true, create: true }` on the SQLite adapter, `prepare: false` on MariaDB. Options: `?` inside a plain SQLite file name (#38427), `new SQL("")` reading the environment.

### From outside contributors

PRs by people, team members included. Every item is reimplemented from the idea unless it says otherwise.

- #40913 @dylan-conway: his mechanism as it is (`onconnect` at the end of the read, only if still connected), ported by hand to both drivers. Also the error of his #38514.
- #25623 @eriklangille: the diagnosis in #23215 and the goal, reuse across `--hot` reloads. Other mechanism: connections move between pools in `pool.ts`, no GC hook.
- #23350 @gohryt: the request for COPY and the scenarios to test. Other API.
- #36451 @pjb3: the `transform` option on all adapters, with postgres.js's directions, applied to the row shape.
- #8558 @nikeee, #25703 @jdjkelly: user-defined functions; the name `function`, option names, arity rule, test ideas.
- #12631 @jecquas: `sqlite3_update_hook`, in the shape its review suggested.
- #31669 @alii: one cached Structure for rows wider than 62 columns. #31209, #31738 @alii: `Date` parameters before 1970, fractional seconds of binary TIME.
- #43789 @ralyodio: the diagnosis that `detectCommand` copies and lower-cases the whole text per helper. #45151 @RubenPari: the cause (every JSC `Error` has its own `line` / `column`) and the `delete`.
- #26711 @RajeshKumar11: `PGUSERNAME`. #45287 @wahajahmed010 (the URL's database above `PGDATABASE`) stays open: decided as on main.
- #22109 @himself65: `Symbol.for("sqlite-type")`. #26627 @0xh3x: `safeIntegers()` typings. #31747 @Artoria2e5: the JSDoc correction. #24482 @jobansd: `exec` -> `run`, the same 15 edits. #41094 @deepshekhardas: trailing spaces.
- Not taken, with thanks (reasons below): #26503 @S4N-T0S, #31751 @EffortlessSteven, #27442 @sosukesuzuki (cases of both are tests now), #22934 @nektro, #16884 @chrisbenincasa, #25836 @alimd, #26880 @theshadow27, #31078 @countradooku, #25403 @abelcha, #44854 and #44860 @deepshekhardas.
- Not in this set: #41890 @steipete (only statements a `StatementSync` owns may be finalized), #28161 @kernoeb (found the 21-byte nonce first). Reports and measurements: @bompus (#44084, #44187), @gerardp (#35120, his burst script is a test), @Dex4Sure (#38163), @filipesarturi (#33190), @KilianB (#30037).

### Replaces

#8558 #12631 #22109 #23350 #24482 #25623 #25703 #26627 #26711 #28912 #30037 #30648 #30848 #31209 #31669 #31738 #31747 #32040 #32100 #32422 #32558 #32841 #32917 #33189 #33190 #33370 #33504 #33579 #33583 #33740 #34453 #34898 #35111 #35117 #35118 #35120 #35912 #36241 #36301 #36371 #36451 #36948 #37006 #37114 #37123 #37125 #37238 #37240 #37418 #37807 #37819 #37960 #38163 #38219 #38220 #38221 #38417 #38438 #38468 #38625 #38634 #39452 #39881 #40005 #40193 #40194 #40434 #40537 #40633 #40913 #41042 #41051 #41094 #41104 #41176 #41301 #41570 #41605 #41611 #41703 #41725 #41744 #41758 #41813 #41815 #41816 #41817 #41886 #41970 #41976 #42587 #42637 #43116 #43205 #43249 #43255 #43257 #43261 #43263 #43281 #43293 #43306 #43335 #43336 #43652 #43789 #43818 #43918 #43958 #43959 #44022 #44035 #44042 #44043 #44089 #44103 #44105 #44118 #44197 #44204 #44315 #44373 #44483 #44625 #44646 #44784 #44799 #44850 #44908 #44932 #44938 #44983 #44998 #45014 #45065 #45079 #45080 #45096 #45111 #45118 #45151 #45153 #45160 #45207 #45229 #45305 #45332 #45367 #45387

### Closed without porting

- #16884: the compile option is already on main
- #22934: those suppressions are already gone (#30875)
- #25403: would make the private adapter interface public
- #25836: part landed in #43649, two lines mistype
- #26503: four claims already fixed on main; default `sslmode` stays `disable`
- #26880: superseded by the author's own #26884
- #27442: already fixed on main (#35246)
- #31078: nothing can call `interrupt()` while a statement runs
- #31751: no observable bug on a real server
- #34435: `numeric` is read as text; binary decoder deleted
- #34673: its Structure slots are never freed on eviction
- #35738: the mock repeats a real-server test
- #35753: duplicate export already removed (#44614)
- #37544: main already holds statements in `RefPtr` (#40516)
- #37620: its leak was fixed on main (#40238)
- #44040: superseded by #44105's synchronous start
- #44854: makes the page's spelling less consistent
- #44860: makes the guide's wording less consistent

### Left open

- #26884: backup API is a product decision
- #30636: main's docs promise the eager fill; a lazy pool is a product decision
- #34281: `mkdir -p` on open is a product decision
- #34863: `AsyncDatabase` is a product decision
- #36712: the `better-sqlite3` alias is a product decision
- #37013: SQL part obsolete since #44618; the rest is not SQL
- #38133: the SCRAM floor is kept; only its better error is here
- #38289: MySQL half is here; `Bun.Image` is not SQL
- #38427: its `url` half is here; the `?` half changes what main opens
- #38443: `console.log(rows)` keeps showing the four properties, as on main, decided
- #38514: bun:sqlite part is here; the rest is not SQL
- #39850: SQL gates are here; the rest is not SQL
- #40250: the SQL code it repairs is deleted here; the rest is not SQL
- #40944: main works, like postgres.js; the change breaks stored data
- #41586: two parts are here; the rest is decided: as on main (Limits)
- #41589: decided: as on main (Limits)
- #41888: `real` follows the wire format, as on main, decided
- #42054: SQL half is here; the Redis half is not
- #42206: SQL half is here; the `nodejs-compat` half is not
- #42868: SQLite parts are here; the rest is not SQL
- #43797: SQLite half is here; the V8 addon half is not
- #44745: its SQLite part is here; `parts.map(sql.unsafe)` works on main and in postgres.js
- #44867: its SQLite part is here; on the servers `sql.unsafe(text, 0)` runs on main
- #44897: SQL half is here; the node:http2 half is not
- #44924: handing unsent requests back can half-commit at `max: 1`; needs a maintainer's yes
- #44962: SQLite 3.54.0 should merge, and revert, alone
- #45132: an `async` callback without `await` works on main
- #45211: NULLs and dimensions on the binary paths are here; typed arrays stay, decided
- #45287: decided: as on main (Limits)
- #45437: a source break in the types, on its own

### Partly addressed

- #39877: a bare key binds; an unknown key in default mode still binds NULL (`strict: true` throws).
- #43113: not reproduced; the one route found to its errors (an open `iterate()` cursor) is closed.
- #19130, #40077: `expect()` no longer hangs on a lazy `Query`; #19130's own script (Windows) didn't hang on Linux, #40077's other items are untouched.
- #27102: the process no longer exits with a query in flight; its `CONNECTION_CLOSED` under load wasn't reproduced. #38277: MySQL half only.
- #15088: its COPY item. #15087: `SQL.toCamel` and friends cover its SQL use. #18866: `columns` and `statement`, not `state`. #23175: `cancel()` works, no `signal`. #16665: `new Bun.sql(url, options)` applies the options.
- #39450: Date and object are fixed; a Date into `time` / `json` with `prepare: false` still differs.
- #28819, #40942: not changed, see Limits; `sql.json()` and the docs now say how to store each kind of value.
- #26629: changed by reading, no such build was made. #16717: documented (macOS reports Apple's version). #26369: already fixed on main (#44618).
- #44187: memory statistics and the connection mutex are gone; scaling like Node was measured on a loaded box only.
- #20662: not changed; the docs give the `PRAGMA synchronous = NORMAL` tip.
- #24662: async support isn't added; only a promise that is already rejected at the return rolls back, and the docs warn.
- #45276: not changed, decided as on main (Limits).

### Issues fixed

Fixes #1332.
Fixes #1474.
Fixes #4175.
Fixes #5051.
Fixes #9410.
Fixes #13409.
Fixes #15876.
Fixes #16469.
Fixes #18326.
Fixes #18456.
Fixes #23215.
Fixes #23954.
Fixes #24050.
Fixes #25405.
Fixes #25472.
Fixes #26195.
Fixes #26622.
Fixes #26684.
Fixes #26809.
Fixes #28911.
Fixes #28980.
Fixes #29010.
Fixes #29484.
Fixes #29551.
Fixes #30221.
Fixes #30646.
Fixes #30811.
Fixes #30843.
Fixes #32005.
Fixes #32035.
Fixes #32038.
Fixes #32096.
Fixes #32148.
Fixes #37415.
Fixes #39879.
Fixes #40001.
Fixes #40432.
Fixes #41010.
Fixes #41039.
Fixes #41050.
Fixes #41242.
Fixes #41740.
Fixes #41856.
Fixes #42720.
Fixes #42804.
Fixes #44084.
Fixes #44367.
Fixes #45039.
Fixes #45062.
Fixes #45064.
Fixes #45275.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

(This is Claude, working with Jarred on consolidating the open SQL PRs.)

@pjb3, thank you for picking up the Roadmap item: one transform option for every adapter, renaming both result columns and sql() helper keys, and case conversion in plain JS where the Roadmap note had it blocked on a C++ implementation. Sorry that no person reviewed it.

The feature is in #45304: new SQL({ transform: SQL.camel }), SQL.pascal, SQL.kebab, the six functions SQL.toCamel ... SQL.fromKebab, and transform: { column: fn | { from, to }, undefined: value }. Your diff was read line by line for the idea and the API, and the code was reimplemented, so here is what differs.

from and to follow postgres.js: from renames a name that comes from the database, to a name that goes to it. The PR applied column.to to results and column.from to helper keys, so a config copied from postgres.js would have done the reverse.

Columns are renamed once, when Bun learns the shape of a result (RowDescription, MySQL column definitions, SQLite prepare) and before the row structure is built. There is no pass over the rows in JS, and result.columns[i].name is the new name. The function must return the same name every time.

The casing rules are postgres.js's too: _private stays, USER_ID becomes USERID, a__b becomes a_b. The word splitter in the PR turns _private into private, and the way back then names a column that does not exist.

Not taken: snake / toSnake / fromSnake, transform.value and transform.row (so keys inside json / jsonb values are not renamed), the copies of the names on sql and as module exports (they are on SQL only), and the globalThis.$isArray fallback. Added: sql("userId") goes through to, and a wrong transform throws ERR_INVALID_ARG_TYPE.

test/js/sql/sql-transform.test.ts (the PR's file name, new content) runs on SQLite, PostgreSQL, MySQL and MariaDB; "is called once for a column of a result shape, not once for a row" pins the call count. docs/runtime/sql.mdx has "Column name transforms" and the Roadmap line is gone. #15087 (Bun.camelCase) is a separate request and stays as it is. You are credited in the description of #45304.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants