From 16afaadd58f34b74e366e1de7f3f55aee7b56c24 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:00:45 +0000 Subject: [PATCH 1/2] bun:sqlite: bind strict-mode numeric named parameters by their literal key In strict mode, $N / :N / @N names were treated like the ?N positional form: the digit name was parsed and looked up at object key N-1, so $1 bound from key 0 and $0 underflowed to key 4294967295. Named parameters now bind from the key matching the name without its prefix, while ?N keeps the array-like 0-based mapping. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 16 ++++++-- test/js/bun/sqlite/sqlite.test.js | 44 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 4ec2aa85707..ea1f67bf04b 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -996,6 +996,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin return target->getDirectIndex(globalObject, i); } + const bool isPositional = name[0] == '?'; if (trimLeadingPrefix) { name += 1; } @@ -1003,9 +1004,18 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin const WTF::String str = WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast(name), strlen(name) }); if (trimLeadingPrefix && name[0] >= '0' && name[0] <= '9') { - auto integer = WTF::parseInteger(str, 10); - if (integer.has_value()) { - return target->getDirectIndex(globalObject, integer.value() - 1); + if (isPositional) { + // "?NNN" is the 1-based position NNN, bound from array-like 0-based + // keys ({ 0: ..., 1: ... }). SQLite rejects "?0" when preparing. + auto integer = WTF::parseInteger(str, 10); + if (integer.has_value() && integer.value() > 0) { + return target->getDirectIndex(globalObject, integer.value() - 1); + } + } else if (auto index = JSC::parseIndex(*str.impl())) { + // "$1" / ":1" / "@1" are names, so the key is the name without the + // prefix. A canonical numeric key is an index property, which the + // named lookup below cannot see. + return target->getDirectIndex(globalObject, *index); } } diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index ee80f3f2db3..5b4313cd67a 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -322,6 +322,50 @@ describe("bind parameters object mutated by a getter during bind", () => { }); }); +describe("strict mode numeric parameter names", () => { + it("binds $N, :N, @N from the key matching the name without the prefix", () => { + const db = Database.open(":memory:", { strict: true }); + expect(db.query("select $1 as a").all({ 1: "one" })).toEqual([{ a: "one" }]); + expect(db.query("select :1 as a").all({ 1: "one" })).toEqual([{ a: "one" }]); + expect(db.query("select @1 as a").all({ 1: "one" })).toEqual([{ a: "one" }]); + expect(db.query("select :2 as two, :1 as one").all({ 1: "A", 2: "B" })).toEqual([{ two: "B", one: "A" }]); + }); + + it("does not bind a numeric name from the neighboring key", () => { + const db = Database.open(":memory:", { strict: true }); + expect(() => db.query("select $1 as a").all({ 0: "zero" })).toThrow('Missing parameter "$1"'); + expect(() => db.query("select :2 as two, :1 as one").all({ 0: "A", 1: "B" })).toThrow('Missing parameter ":2"'); + }); + + it("$0 binds from key 0, not key 4294967295", () => { + const db = Database.open(":memory:", { strict: true }); + expect(db.query("select $0 as a").all({ 0: "zero" })).toEqual([{ a: "zero" }]); + expect(() => db.query("select $0 as a").all({ "4294967295": "wrapped" })).toThrow('Missing parameter "$0"'); + }); + + it("non-canonical numeric names bind from the literal key", () => { + const db = Database.open(":memory:", { strict: true }); + expect(db.query("select $01 as a").all({ "01": "x" })).toEqual([{ a: "x" }]); + expect(db.query("select $4294967295 as a").all({ "4294967295": "x" })).toEqual([{ a: "x" }]); + expect(db.query("select $1abc as a").all({ "1abc": "x" })).toEqual([{ a: "x" }]); + }); + + it("mixes word names and numeric names", () => { + const db = Database.open(":memory:", { strict: true }); + expect(db.query("select $name as n, $2 as b").all({ name: "x", 2: "y" })).toEqual([{ n: "x", b: "y" }]); + }); + + it("?N still binds from 0-based array-like keys", () => { + const db = Database.open(":memory:", { strict: true }); + expect(db.query("select ?2 as two, ?1 as one").all({ 0: "A", 1: "B" })).toEqual([{ two: "B", one: "A" }]); + }); + + it("default mode still binds numeric names by the prefixed key", () => { + const db = Database.open(":memory:"); + expect(db.query("select $1 as a").all({ $1: "one" })).toEqual([{ a: "one" }]); + }); +}); + var encode = text => new TextEncoder().encode(text); // Use different numbers of columns to ensure we crash if using initializeIndex() on a large array can cause bugs. From 77ec31d1f550977eae2968b46b6dc398887cf36a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:52:07 +0000 Subject: [PATCH 2/2] bun:sqlite: drop unreachable ?0 guard and trim comments SQLite rejects ?0 at prepare time, so the ?N branch is back to the original code. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index ea1f67bf04b..f642efcc3af 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1005,16 +1005,12 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin if (trimLeadingPrefix && name[0] >= '0' && name[0] <= '9') { if (isPositional) { - // "?NNN" is the 1-based position NNN, bound from array-like 0-based - // keys ({ 0: ..., 1: ... }). SQLite rejects "?0" when preparing. auto integer = WTF::parseInteger(str, 10); - if (integer.has_value() && integer.value() > 0) { + if (integer.has_value()) { return target->getDirectIndex(globalObject, integer.value() - 1); } } else if (auto index = JSC::parseIndex(*str.impl())) { - // "$1" / ":1" / "@1" are names, so the key is the name without the - // prefix. A canonical numeric key is an index property, which the - // named lookup below cannot see. + // A canonical numeric key is an index property; getOwnNonIndexPropertySlot below cannot see it. return target->getDirectIndex(globalObject, *index); } }