Skip to content

UPDATE/DELETE without FROM/USING gets a phantom source, so strict mode reports a false ambiguous column on RETURNING #229

Description

@tiagolauer

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions