An UPDATE or DELETE that has no FROM/USING clause still gets a second, fabricated source whose table name is the type string. Because ResolveKey<DB, string> matches every table in the schema, that phantom source contains every column of every table — so every RETURNING/OUTPUT column looks like it matches two sources and strict mode reports ambiguous column.
Reproduction
Schema { users: { id: number; name: string; created_at: Date; deleted_at: string | null }, orders: { id: number; user_id: number; price: number } }.
| query |
StrictRow<DB, Q> |
expected |
delete from users where id = 1 returning id |
QueryTypeError<"ambiguous column: id"> |
{ id: number } |
update users set name = 1 where id = 1 returning name |
QueryTypeError<"ambiguous column: name"> |
{ name: string } |
insert into users (id) values (1) returning id |
{ id: number } |
✅ (INSERT does not take this path) |
The parse result shows the phantom directly:
ParseStatement<'delete from users where id = 1 returning id'>
// {
// columns: "id";
// sources: [
// { table: "users"; alias: "users"; nullable: false },
// { table: string; alias: string; nullable: false } // <-- phantom
// ];
// whereText: "id = 1 returning id";
// fromText: "";
// }
Non-strict mode is affected too, just more quietly: Row<DB, 'update users set x = 1 returning price'> resolves price against the phantom (which sees orders) and returns number instead of unknown.
Root cause
src/parse.ts:
type ExtraSourcesAfterKeyword<S extends string, Keyword extends string> =
SplitAtTopLevelKeyword<S, Keyword> extends { after: infer AfterClause extends string }
? ParseFromClause<AfterClause>
: [];
When the statement has no FROM/USING, SplitAtTopLevelKeyword resolves to never — and never extends { after: infer AfterClause extends string } is true, with AfterClause inferred as string. So the [] branch is dead code and ParseFromClause<string> produces [{ table: string; alias: string; nullable: false }]. Same never extends … trap as the placeholder bug filed alongside this one.
Guarding with [SplitAtTopLevelKeyword<S, Keyword>] extends [never] ? [] : … should be enough.
Related, same statement path
ExtractUpdateDeleteWhereText uses TakeUntilClauseBoundary, whose boundary list (src/from.ts, ClauseBoundary) has no returning/output. That is why whereText above is "id = 1 returning id" — the RETURNING list is scanned as if it were part of the WHERE clause. It does not produce a visible error today (the extra tokens happen to validate), but it is the second reason id gets validated twice here, and it should be fixed with the phantom source.
Found in an audit of master @ dc1afdb (v0.1.8).
An
UPDATEorDELETEthat has noFROM/USINGclause still gets a second, fabricated source whose table name is the typestring. BecauseResolveKey<DB, string>matches every table in the schema, that phantom source contains every column of every table — so everyRETURNING/OUTPUTcolumn looks like it matches two sources and strict mode reportsambiguous column.Reproduction
Schema
{ users: { id: number; name: string; created_at: Date; deleted_at: string | null }, orders: { id: number; user_id: number; price: number } }.StrictRow<DB, Q>delete from users where id = 1 returning idQueryTypeError<"ambiguous column: id">{ id: number }update users set name = 1 where id = 1 returning nameQueryTypeError<"ambiguous column: name">{ name: string }insert into users (id) values (1) returning id{ id: number }The parse result shows the phantom directly:
Non-strict mode is affected too, just more quietly:
Row<DB, 'update users set x = 1 returning price'>resolvespriceagainst the phantom (which seesorders) and returnsnumberinstead ofunknown.Root cause
src/parse.ts:When the statement has no
FROM/USING,SplitAtTopLevelKeywordresolves tonever— andnever extends { after: infer AfterClause extends string }is true, withAfterClauseinferred asstring. So the[]branch is dead code andParseFromClause<string>produces[{ table: string; alias: string; nullable: false }]. Samenever extends …trap as the placeholder bug filed alongside this one.Guarding with
[SplitAtTopLevelKeyword<S, Keyword>] extends [never] ? [] : …should be enough.Related, same statement path
ExtractUpdateDeleteWhereTextusesTakeUntilClauseBoundary, whose boundary list (src/from.ts,ClauseBoundary) has noreturning/output. That is whywhereTextabove is"id = 1 returning id"— the RETURNING list is scanned as if it were part of the WHERE clause. It does not produce a visible error today (the extra tokens happen to validate), but it is the second reasonidgets validated twice here, and it should be fixed with the phantom source.Found in an audit of
master@ dc1afdb (v0.1.8).