Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,40 @@ type ColumnTypeAt<DB extends SchemaLike, Table extends string, Column extends st
: unknown
: unknown;

// A VALUES entry is one token to this matcher, and StripCallWrapper gives up
// on a call carrying an inner comma - so `coalesce(?, 0)` registered no
// placeholder at all and the tuple came up a slot short. With `@name` that is
// worse than a compile error: the caller can only pass one value, the runtime
// scanner binds it to the first name it meets and binds the second to null, and
// the INSERT succeeds having written values into the wrong columns (issue #269).
//
// Splitting the call's own argument list is enough: each argument is a token
// again, and a single-argument call around a placeholder (`lower(?)`) is
// something CleanScanToken already unwraps.
type CallArguments<Entry extends string> = Entry extends `${string}(${infer AfterOpen}`
? ExtractParenGroup<AfterOpen> extends { inner: infer Inner extends string }
? SplitColumnList<Inner>
: []
: [];

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<Trim<Head>> extends true
? AddParam<Trim<Head>, Type, Indexed, Sequential, SequentialNames> extends {
indexed: infer NextIndexed extends unknown[];
sequential: infer NextSequential extends unknown[];
sequentialNames: infer NextSequentialNames extends string[];
}
? AddCallArgumentParams<Tail, Type, NextIndexed, NextSequential, NextSequentialNames>
: never
: AddCallArgumentParams<Tail, Type, Indexed, Sequential, SequentialNames>
: { indexed: Indexed; sequential: Sequential; sequentialNames: SequentialNames };

type MatchInsertValues<
DB extends SchemaLike,
Table extends string,
Expand Down Expand Up @@ -381,7 +415,27 @@ type MatchInsertValues<
NextSequentialNames
>
: never
: MatchInsertValues<DB, Table, ColumnsTail, ValuesTail, Indexed, Sequential, SequentialNames>
: AddCallArgumentParams<
CallArguments<Trim<Head>>,
ColumnTypeAt<DB, Table, ColumnHead>,
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 };

Expand Down
71 changes: 71 additions & 0 deletions tests/insert-values-call-params.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Params } from '../src/index.js';

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type Expect<T extends true> = 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<DB, 'insert into users (id, name) values (coalesce(?, 0), ?)'>,
[number, string]
>
>;

type NamedInsideMultiArgumentCall = Expect<
Equal<
Params<DB, 'insert into users (id, name) values (coalesce(@a, 0), @b)'>,
[number, string]
>
>;

type NumberedInsideMultiArgumentCall = Expect<
Equal<
Params<DB, 'insert into users (id, name) values (coalesce($1, 0), $2)'>,
[number, string]
>
>;

type NestedCallAroundThePlaceholder = Expect<
Equal<
Params<DB, 'insert into users (id, name) values ($1, coalesce(lower(@b), $$x$$))'>,
[number, string]
>
>;

// Controls: the shapes that already worked.
type BarePlaceholders = Expect<
Equal<Params<DB, 'insert into users (id, name) values ($1, $2)'>, [number, string]>
>;

type SingleArgumentCall = Expect<
Equal<Params<DB, 'insert into users (id, name) values ($1, lower($2))'>, [number, string]>
>;

type LiteralsTakeNoSlot = Expect<
Equal<Params<DB, 'insert into users (id, name) values (1, ?)'>, [string]>
>;

type PlaceholderInACallInWhere = Expect<
Equal<Params<DB, 'select id from users where id = coalesce($1, 0)'>, [number]>
>;

export type InsertValuesCallParamsLock = [
QuestionInsideMultiArgumentCall,
NamedInsideMultiArgumentCall,
NumberedInsideMultiArgumentCall,
NestedCallAroundThePlaceholder,
BarePlaceholders,
SingleArgumentCall,
LiteralsTakeNoSlot,
PlaceholderInACallInWhere,
];