From 2800d8792210a83068c82bf469daa17c0079ef01 Mon Sep 17 00:00:00 2001 From: Tiago Lauer Date: Sun, 2 Aug 2026 01:03:02 -0300 Subject: [PATCH] fix(params): give a placeholder inside an INSERT VALUES call its slot Params // was [string] - one slot for two placeholders MatchInsertValues runs IsPlaceholder on each VALUES entry as a single token, and StripCallWrapper gives up on a call carrying an inner comma, so the placeholder inside it registered nothing. WHERE only works by accident of space-splitting, which isolates `coalesce($1,` as its own token. The `@name` spelling is the one that bites hardest. The caller can only pass one value, resolveMixedParameters scans `@a` first and gives it that value, binds `@b` to null, and the INSERT succeeds having written into the wrong column. No error anywhere. Splitting the call's own argument list is enough to fix it: each argument is a token again, and a single-argument call around a placeholder (`lower(?)`) is something CleanScanToken already unwraps, so nesting keeps working. The column type still comes from the matching entry in the INSERT column list. Fixes #269 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++ src/params.ts | 56 +++++++++++++++++- tests/insert-values-call-params.test-d.ts | 71 +++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/insert-values-call-params.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 684fed8..d5dcdb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes to this project, following [Keep a Changelog](https://keepachang ## [Unreleased] +### Fixed + +- A placeholder inside a multi-argument call in INSERT VALUES gets its tuple slot. `values (coalesce(?, 0), ?)` typed one slot for two placeholders, and with `@name` the consequence was silent: the caller could pass only one value, the adapter bound it to the first name and bound the second to null, and the INSERT succeeded having written into the wrong column ([#269](https://github.com/tiagolauer/OwlSQL/issues/269)). + ## [0.2.0] - 2026-07-29 ### Changed diff --git a/src/params.ts b/src/params.ts index 1fac6d6..d672e9e 100644 --- a/src/params.ts +++ b/src/params.ts @@ -349,6 +349,40 @@ type ColumnTypeAt = Entry extends `${string}(${infer AfterOpen}` + ? ExtractParenGroup extends { inner: infer Inner extends string } + ? SplitColumnList + : [] + : []; + +type AddCallArgumentParams< + Arguments extends string[], + Type, + Indexed extends unknown[], + Sequential extends unknown[], + SequentialNames extends string[], +> = Arguments extends [infer Head extends string, ...infer Tail extends string[]] + ? IsPlaceholder> extends true + ? AddParam, Type, Indexed, Sequential, SequentialNames> extends { + indexed: infer NextIndexed extends unknown[]; + sequential: infer NextSequential extends unknown[]; + sequentialNames: infer NextSequentialNames extends string[]; + } + ? AddCallArgumentParams + : never + : AddCallArgumentParams + : { indexed: Indexed; sequential: Sequential; sequentialNames: SequentialNames }; + type MatchInsertValues< DB extends SchemaLike, Table extends string, @@ -381,7 +415,27 @@ type MatchInsertValues< NextSequentialNames > : never - : MatchInsertValues + : AddCallArgumentParams< + CallArguments>, + ColumnTypeAt, + Indexed, + Sequential, + SequentialNames + > extends { + indexed: infer NextIndexed extends unknown[]; + sequential: infer NextSequential extends unknown[]; + sequentialNames: infer NextSequentialNames extends string[]; + } + ? MatchInsertValues< + DB, + Table, + ColumnsTail, + ValuesTail, + NextIndexed, + NextSequential, + NextSequentialNames + > + : never : { indexed: Indexed; sequential: Sequential; sequentialNames: SequentialNames } : { indexed: Indexed; sequential: Sequential; sequentialNames: SequentialNames }; diff --git a/tests/insert-values-call-params.test-d.ts b/tests/insert-values-call-params.test-d.ts new file mode 100644 index 0000000..045914f --- /dev/null +++ b/tests/insert-values-call-params.test-d.ts @@ -0,0 +1,71 @@ +import type { Params } from '../src/index.js'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +type Expect = T; + +interface DB { + users: { id: number; name: string }; +} + +// The README promises "a placeholder inside a call is typed", and it is in a +// WHERE clause. In INSERT VALUES the entry was one token and the call carried +// an inner comma, so the placeholder registered no slot at all: the caller +// could pass only one value, the runtime scanner bound it to the first name +// and the second to null, and the INSERT wrote into the wrong column (#269). +type QuestionInsideMultiArgumentCall = Expect< + Equal< + Params, + [number, string] + > +>; + +type NamedInsideMultiArgumentCall = Expect< + Equal< + Params, + [number, string] + > +>; + +type NumberedInsideMultiArgumentCall = Expect< + Equal< + Params, + [number, string] + > +>; + +type NestedCallAroundThePlaceholder = Expect< + Equal< + Params, + [number, string] + > +>; + +// Controls: the shapes that already worked. +type BarePlaceholders = Expect< + Equal, [number, string]> +>; + +type SingleArgumentCall = Expect< + Equal, [number, string]> +>; + +type LiteralsTakeNoSlot = Expect< + Equal, [string]> +>; + +type PlaceholderInACallInWhere = Expect< + Equal, [number]> +>; + +export type InsertValuesCallParamsLock = [ + QuestionInsideMultiArgumentCall, + NamedInsideMultiArgumentCall, + NumberedInsideMultiArgumentCall, + NestedCallAroundThePlaceholder, + BarePlaceholders, + SingleArgumentCall, + LiteralsTakeNoSlot, + PlaceholderInACallInWhere, +];