From a8d5ffe3a2b431db448c3c619b1c08dcf9fc8724 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:27:47 +0000 Subject: [PATCH 1/3] Fix wrong helper selection when a keyword is followed by ( - fixes #701 The builder keyword regexes used a backreference (\1) in their trailing negative lookahead, so the 'no later occurrence' assertion only rejected a repeat that matched the SAME delimiters as the first hit. ' in(' and ' in ' are different text, so a later ' in ' did not cancel an earlier ' in('. Builder.build picks the keyword with the highest index, so 'where x in(select ... where x in ${...}' kept the leading in(, ranked the '(select ' hit above it, and handed the array to the select helper, which threw TypeError: str.replace is not a function. Repeat the keyword pattern inside the lookahead instead of backreferencing the captured text, so any later occurrence counts regardless of which delimiters surround it. --- src/types.js | 5 ++++- tests/index.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/types.js b/src/types.js index 7c7c2b9..f0cf15d 100644 --- a/src/types.js +++ b/src/types.js @@ -173,7 +173,10 @@ const builders = Object.entries({ return '(' + escapeIdentifiers(columns, options) + ')values' + valuesBuilder(Array.isArray(first) ? first : [first], parameters, types, columns, options) } -}).map(([x, fn]) => ([new RegExp('((?:^|[\\s(])' + x + '(?:$|[\\s(]))(?![\\s\\S]*\\1)', 'i'), fn])) +}).map(([x, fn]) => { + const keyword = '(?:^|[\\s(])' + x + '(?:$|[\\s(])' + return [new RegExp(keyword + '(?![\\s\\S]*' + keyword + ')', 'i'), fn] +}) function notTagged() { throw Errors.generic('NOT_TAGGED_CALL', 'Query not called as a tagged template literal') diff --git a/tests/index.js b/tests/index.js index 845c6ce..4ac6d09 100644 --- a/tests/index.js +++ b/tests/index.js @@ -2615,6 +2615,38 @@ t('Last keyword used even with duplicate keywords', async() => { return [x, true, await sql`drop table test`] }) +t('Last keyword used even when an earlier keyword is followed by (', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in(select x from test where x in ${ sql([1, 2]) })` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used even when it ends the string', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in (select x from test where x in${ sql([1, 2]) })` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used when nested keywords are all spaced', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in (select x from test where x in ${ sql([1, 2]) })` + + return [1, x, await sql`drop table test`] +}) + +t('Single keyword followed by ( is still used', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in(${ sql([1, 2]) })` + + return [1, x, await sql`drop table test`] +}) + t('Insert array with null', async() => { await sql`create table test (x int[])` await sql`insert into test ${ sql({ x: [1, null, 3] }) }` From 097e474136f075382194d2ae71307e1ab4240c45 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:01:41 +0000 Subject: [PATCH 2/3] Fix invalid SQL in keyword builder test and cover non-in keywords The single-keyword test built `x in((1,2))` - the template supplies the ( and the in builder emits its own ( ) - so the operand was a row constructor and Postgres rejected it with "operator does not exist: integer = record" on both arms. Compare a row constructor on both sides instead, which keeps the in( delimiter the test exists to cover. Also cover the cases the fix changes that no test pinned: a keyword other than in, the reverse ordering, three occurrences with three delimiters, and uppercase. --- tests/index.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/index.js b/tests/index.js index 4ac6d09..0428b73 100644 --- a/tests/index.js +++ b/tests/index.js @@ -2642,7 +2642,38 @@ t('Last keyword used when nested keywords are all spaced', async() => { t('Single keyword followed by ( is still used', async() => { await sql`create table test (x int)` await sql`insert into test values(1)` - const [{ x }] = await sql`select x from test where x in(${ sql([1, 2]) })` + const [{ x }] = await sql`select x from test where (x,x) in(${ sql([[1, 1]]) })` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used for keywords other than in', async() => { + await sql`create table test (x int)` + const [{ x }] = await sql`with a as (insert into test values(2) returning(x)) insert into test values(3) returning ${ sql(['x']) }` + + return [3, x, await sql`drop table test`] +}) + +t('Last keyword used when the spaced keyword comes first', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in (select x from test where x in(select x from test where x in ${ sql([1, 2]) }))` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used with three occurrences and three delimiters', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in(select x from test where x in (select x from test where x in${ sql([1, 2]) }))` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used case insensitively', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`SELECT x FROM test WHERE x IN(SELECT x FROM test WHERE x IN ${ sql([1, 2]) })` return [1, x, await sql`drop table test`] }) From 280ff1dcdcdaea5e784f401725bbd2e060f4c766 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:55:17 +0000 Subject: [PATCH 3/3] Add keyword builder tests for nested fragments, newline delimiters and empty arrays --- tests/index.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/index.js b/tests/index.js index 0428b73..89f04f5 100644 --- a/tests/index.js +++ b/tests/index.js @@ -2678,6 +2678,42 @@ t('Last keyword used case insensitively', async() => { return [1, x, await sql`drop table test`] }) +t('Last keyword used when the earlier keyword is in a nested fragment', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x ${ sql`in(select x from test where x in ${ sql([1, 2]) })` }` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used when an earlier keyword is newline delimited', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x +in(select x from test where x in ${ sql([1, 2]) })` + + return [1, x, await sql`drop table test`] +}) + +// Control: identical `(` delimiters were never affected by the backreference, +// so this passes with and without the fix. It pins that the repeated-pattern +// lookahead does not regress the same-delimiter case. +t('Repeated keyword with identical ( delimiters is unchanged (control)', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const [{ x }] = await sql`select x from test where x in(select x from test where (x,x) in(${ sql([[1, 1]]) }))` + + return [1, x, await sql`drop table test`] +}) + +t('Last keyword used with an empty array', async() => { + await sql`create table test (x int)` + await sql`insert into test values(1)` + const rows = await sql`select x from test where x in(select x from test where x in ${ sql([]) })` + + return [0, rows.length, await sql`drop table test`] +}) + t('Insert array with null', async() => { await sql`create table test (x int[])` await sql`insert into test ${ sql({ x: [1, null, 3] }) }`