Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
03b9a815ef fix(sqlite): always strip prefix from named binding parameters
Named bindings (@param, $param, :param) were silently bound as NULL in
non-strict mode because the leading prefix character was only stripped
when strict mode was enabled. This caused property lookups on the JS
object to fail (looking up "@expiresAt" instead of "expiresAt").

Always strip the prefix regardless of strict mode so named bindings
work correctly. The strict flag now only controls whether missing
parameters throw an error.

Closes #18456

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:24:54 +00:00
4 changed files with 87 additions and 56 deletions

View File

@@ -363,7 +363,10 @@ public:
hasOutOfOrderNames = false;
size_t count = this->count;
size_t prefixOffset = trimLeadingPrefix ? 1 : 0;
// Always strip the leading prefix character (@, $, :) from parameter names
// so that JS object property lookups work regardless of strict mode.
// e.g., @expiresAt -> expiresAt, $id -> id
constexpr size_t prefixOffset = 1;
bindingNames.clear();
bool hasLoadedBindingNames = false;
@@ -387,7 +390,7 @@ public:
}
name += prefixOffset;
size_t namelen = strlen(reinterpret_cast<const char*>(name));
if (prefixOffset == 1 && name[0] >= '0' && name[0] <= '9') {
if (name[0] >= '0' && name[0] <= '9') {
auto integer = WTF::parseInteger<uint64_t>(StringView({ name, namelen }), 10);
if (integer.has_value()) {
hasOutOfOrderNames = true;
@@ -906,8 +909,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin
const auto& bindingNames = bindings.bindingNames;
size_t size = bindings.count;
const bool trimLeadingPrefix = bindings.trimLeadingPrefix;
const bool throwOnMissing = trimLeadingPrefix;
const bool throwOnMissing = bindings.trimLeadingPrefix;
// Did they reorder the columns?
//
@@ -921,13 +923,12 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin
return target->getDirectIndex(globalObject, i);
}
if (trimLeadingPrefix) {
name += 1;
}
// Always skip the leading prefix character (@, $, :) from parameter names.
name += 1;
const WTF::String str = WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast<const unsigned char*>(name), strlen(name) });
if (trimLeadingPrefix && name[0] >= '0' && name[0] <= '9') {
if (name[0] >= '0' && name[0] <= '9') {
auto integer = WTF::parseInteger<int32_t>(str, 10);
if (integer.has_value()) {
return target->getDirectIndex(globalObject, integer.value() - 1);

View File

@@ -289,7 +289,7 @@ pub noinline fn next(this: *Rm) Yield {
}
switch (this.state) {
.done => return this.bltn().done(this.state.done.exit_code),
.done => return this.bltn().done(0),
.err => return this.bltn().done(this.state.err),
else => unreachable,
}
@@ -430,7 +430,7 @@ pub fn onShellRmTaskDone(this: *Rm, task: *ShellRmTask) void {
if (tasks_done >= this.state.exec.total_tasks and
exec.getOutputCount(.output_done) >= exec.getOutputCount(.output_count))
{
this.state = .{ .done = .{ .exit_code = if (exec.err != null) 1 else 0 } };
this.state = .{ .done = .{ .exit_code = if (exec.err) |theerr| theerr.errno else 0 } };
this.next().run();
}
}

View File

@@ -1,46 +0,0 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
describe("shell .quiet() should preserve exit codes", () => {
test("builtin rm with .quiet() throws on failure", async () => {
using dir = tempDir("issue-18161", {});
try {
await $`rm ${dir}/nonexistent-file.txt`.quiet();
expect.unreachable();
} catch (e: any) {
expect(e.exitCode).not.toBe(0);
}
});
test("builtin rm with .nothrow().quiet() returns non-zero exit code", async () => {
using dir = tempDir("issue-18161", {});
const result = await $`rm ${dir}/nonexistent-file.txt`.nothrow().quiet();
expect(result.exitCode).not.toBe(0);
});
test("builtin rm with .text() throws on failure", async () => {
using dir = tempDir("issue-18161", {});
try {
await $`rm ${dir}/nonexistent-file.txt`.text();
expect.unreachable();
} catch (e: any) {
expect(e.exitCode).not.toBe(0);
}
});
test("builtin rm with .quiet() returns 0 on success", async () => {
using dir = tempDir("issue-18161", {
"existing-file.txt": "hello",
});
const result = await $`rm ${dir}/existing-file.txt`.nothrow().quiet();
expect(result.exitCode).toBe(0);
});
test("builtin rm exit code matches between quiet and non-quiet", async () => {
using dir = tempDir("issue-18161", {});
const nonQuiet = await $`rm ${dir}/nonexistent-file.txt`.nothrow();
const quiet = await $`rm ${dir}/nonexistent-file.txt`.nothrow().quiet();
expect(quiet.exitCode).toBe(nonQuiet.exitCode);
});
});

View File

@@ -0,0 +1,76 @@
import { Database } from "bun:sqlite";
import { expect, test } from "bun:test";
test("named bindings work in non-strict mode", () => {
const db = new Database();
db.run(`
CREATE TABLE test (
id INTEGER NOT NULL PRIMARY KEY,
expiresAt TEXT NOT NULL
);
CREATE TRIGGER IF NOT EXISTS test_expiresAt BEFORE INSERT ON test
BEGIN SELECT CASE WHEN datetime(NEW.expiresAt) IS NULL
THEN RAISE (ABORT, 'Invalid datetime format') END;
END;
`);
// Positional bindings should work
const insertPosStmt = db.query("INSERT INTO test(id, expiresAt) VALUES (?, ?);");
insertPosStmt.run(1, "2025-03-27T09:04:21.412880655Z");
expect(db.query("SELECT * FROM test WHERE id = 1").get()).toEqual({
id: 1,
expiresAt: "2025-03-27T09:04:21.412880655Z",
});
// Named bindings with @ prefix should also work in non-strict mode
const insertObjStmt = db.query("INSERT INTO test(id, expiresAt) VALUES (@id, @expiresAt);");
insertObjStmt.run({ id: 2, expiresAt: "2025-03-27T09:04:21.412880655Z" });
expect(db.query("SELECT * FROM test WHERE id = 2").get()).toEqual({
id: 2,
expiresAt: "2025-03-27T09:04:21.412880655Z",
});
// Named bindings with $ prefix should also work
const insertDollarStmt = db.query("INSERT INTO test(id, expiresAt) VALUES ($id, $expiresAt);");
insertDollarStmt.run({ id: 3, expiresAt: "2025-03-27T09:04:21.412880655Z" });
expect(db.query("SELECT * FROM test WHERE id = 3").get()).toEqual({
id: 3,
expiresAt: "2025-03-27T09:04:21.412880655Z",
});
// Named bindings with : prefix should also work
const insertColonStmt = db.query("INSERT INTO test(id, expiresAt) VALUES (:id, :expiresAt);");
insertColonStmt.run({ id: 4, expiresAt: "2025-03-27T09:04:21.412880655Z" });
expect(db.query("SELECT * FROM test WHERE id = 4").get()).toEqual({
id: 4,
expiresAt: "2025-03-27T09:04:21.412880655Z",
});
});
test("named bindings work in strict mode", () => {
const db = new Database(":memory:", { strict: true });
db.run(`
CREATE TABLE test (
id INTEGER NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);
`);
const stmt = db.query("INSERT INTO test(id, value) VALUES (@id, @value);");
stmt.run({ id: 1, value: "hello" });
expect(db.query("SELECT * FROM test WHERE id = 1").get()).toEqual({
id: 1,
value: "hello",
});
});
test("strict mode throws on missing named parameters", () => {
const db = new Database(":memory:", { strict: true });
db.run(`CREATE TABLE test (id INTEGER NOT NULL PRIMARY KEY, value TEXT);`);
const stmt = db.query("INSERT INTO test(id, value) VALUES (@id, @value);");
expect(() => stmt.run({ id: 1 })).toThrow(/Missing parameter/);
});