From 8497ac278eaf59b7e926632368ceb692ce906950 Mon Sep 17 00:00:00 2001 From: Russell Dunphy Date: Sat, 24 Jan 2026 14:11:55 +0000 Subject: [PATCH] Fix NULL being appended to SQL statements with no interpolations The zip function from es-toolkit pads arrays of different lengths with undefined. Since template literals always have one more fragment than values, when there were no interpolated values, zip would pair the single fragment with undefined, which then got converted to "NULL". Extracted a processTemplateParts helper that properly handles the template literal structure by pairing each value with its preceding fragment and appending the final fragment separately. Co-Authored-By: Claude Opus 4.5 --- packages/sql/src/sql.test.ts | 6 ++++++ packages/sql/src/sql.ts | 37 +++++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/sql/src/sql.test.ts b/packages/sql/src/sql.test.ts index 3926e90..8474c39 100644 --- a/packages/sql/src/sql.test.ts +++ b/packages/sql/src/sql.test.ts @@ -24,6 +24,12 @@ describe("sql", () => { expect(statement.values).toEqual([]); }); + test("interpolating undefined adds it as null rather than as a parameter", () => { + const statement = sql`SELECT ${undefined} FROM dual`; + expect(statement.text).toEqual("SELECT NULL FROM dual"); + expect(statement.values).toEqual([]); + }); + test("interpolating true and false adds them literally rather than as a parameter", () => { const statement = sql`SELECT ${true}, ${false} FROM dual`; expect(statement.text).toEqual("SELECT TRUE, FALSE FROM dual"); diff --git a/packages/sql/src/sql.ts b/packages/sql/src/sql.ts index c202827..daa649a 100644 --- a/packages/sql/src/sql.ts +++ b/packages/sql/src/sql.ts @@ -17,7 +17,6 @@ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ -import { zip } from "es-toolkit"; import pg from "pg"; import { format } from "sql-formatter"; import { ZodType, z } from "zod"; @@ -42,12 +41,35 @@ const joinFragments = ( }; }; +/** + * Process template literal fragments and values into an ExpandedFragment. + * Template literals always have one more fragment than values, so we pair + * each value with its preceding fragment, then append the final fragment. + */ +const processTemplateParts = ( + fragments: readonly string[], + values: readonly unknown[], +): ExpandedFragment => { + const fragmentsCopy = [...fragments]; + const lastFragment = fragmentsCopy.pop()!; + const pairs = fragmentsCopy.map( + (frag, i) => [frag, values[i]] as [string, unknown], + ); + + const result = pairs + .map(expandFragment) + .reduce(joinFragments, { text: [""], values: [] }); + + result.text[result.text.length - 1]! += lastFragment; + return result; +}; + const expandFragment = ([fragment, value]: [ string, unknown, ]): ExpandedFragment => { if (value === undefined) { - return { text: [fragment], values: [] }; + return { text: [fragment + "NULL"], values: [] }; } else if (value === null) { return { text: [fragment + "NULL"], values: [] }; } else if (value === true) { @@ -55,12 +77,7 @@ const expandFragment = ([fragment, value]: [ } else if (value === false) { return { text: [fragment + "FALSE"], values: [] }; } else if (value instanceof SQLStatement) { - const expanded = zip(value._text, value._values) - .map(expandFragment) - .reduce(joinFragments, { - text: [""], - values: [], - }); + const expanded = processTemplateParts(value._text, value._values); return { text: [fragment + expanded.text[0]!, ...expanded.text.slice(1)], values: [...expanded.values], @@ -126,10 +143,8 @@ function sql( return (fragments, ...values) => sql(fragments, ...values).withSchema(fragmentsOrSchema); } - const result = zip(fragmentsOrSchema, values) - .map(expandFragment) - .reduce(joinFragments, { text: [""], values: [] }); + const result = processTemplateParts(fragmentsOrSchema, values); return new SQLStatement(result.text, result.values); }