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

- Strict mode validates the `JOIN ... ON` conditions inside `UPDATE ... FROM` and `DELETE ... USING`. The joined sources were registered, so selecting from them worked, but the ON expressions were never checked because both branches passed an empty FROM text to the check - `... join refunds r on r.nope = o.id` was accepted silently ([#281](https://github.com/tiagolauer/OwlSQL/issues/281)).

## [0.2.0] - 2026-07-29

### Changed
Expand Down
18 changes: 16 additions & 2 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,20 @@ type ExtraSourcesAfterKeyword<S extends string, Keyword extends string> = [
? ParseFromClause<AfterClause>
: [];

// The FROM/USING clause text of a write, for the same JOIN ... ON check a
// SELECT gets. Both branches hardcoded an empty string, so a mistyped column
// in `update users set ... from orders o join refunds r on r.nope = o.id` was
// accepted silently even though the joined sources themselves were registered
// (issue #281). Mirrors ParseSelectBody, which keeps the clause as raw text
// and lets ExtractJoinOnText scan it only in strict mode.
type ExtraFromTextAfterKeyword<S extends string, Keyword extends string> = [
SplitAtTopLevelKeyword<S, Keyword>,
] extends [never]
? ''
: SplitAtTopLevelKeyword<S, Keyword> extends { after: infer AfterClause extends string }
? TakeFromClause<AfterClause>
: '';

export interface ParsedStatement {
columns: string;
sources: Source[];
Expand Down Expand Up @@ -346,7 +360,7 @@ type ParseStatementNormalized<S extends string> = Trim<S> extends `(${infer Afte
...ExtraSourcesAfterKeyword<S, 'from'>,
];
whereText: ExtractUpdateDeleteWhereText<S>;
fromText: '';
fromText: ExtraFromTextAfterKeyword<S, 'from'>;
}
: IsKeyword<Keyword, 'delete'> extends true
? {
Expand All @@ -356,7 +370,7 @@ type ParseStatementNormalized<S extends string> = Trim<S> extends `(${infer Afte
...ExtraSourcesAfterKeyword<S, 'using'>,
];
whereText: ExtractUpdateDeleteWhereText<S>;
fromText: '';
fromText: ExtraFromTextAfterKeyword<S, 'using'>;
}
: IsKeyword<Keyword, 'merge'> extends true
? {
Expand Down
104 changes: 104 additions & 0 deletions tests/dml-join-on-strict.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { QueryTypeError, Row, StrictRow } 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 };
orders: { id: number; user_id: number; total: number };
refunds: { id: number; order_id: number };
}

type EmptyRow = Record<string, never>;

// The joined sources were registered - returning `r.id` worked - but the ON
// expressions were never seen, because both write branches passed an empty
// FROM text to the check (issue #281). The README says strict mode checks
// JOIN ... ON conditions and carves out nothing for writes.
type UpdateFromJoinOnIsChecked = Expect<
Equal<
StrictRow<
DB,
'update users set name = $1 from orders o join refunds r on r.nope = o.id where users.id = o.user_id'
>,
QueryTypeError<'unknown column: nope'>
>
>;

type DeleteUsingJoinOnIsChecked = Expect<
Equal<
StrictRow<
DB,
'delete from users using orders o join refunds r on r.nope = o.id where users.id = o.user_id'
>,
QueryTypeError<'unknown column: nope'>
>
>;

type UpdateFromJoinOnUnknownAliasIsChecked = Expect<
Equal<
StrictRow<
DB,
'update users set name = $1 from orders o join refunds r on q.order_id = o.id where users.id = o.user_id'
>,
QueryTypeError<'unknown alias: q'>
>
>;

// Controls: the valid versions of the same statements still pass.
type ValidUpdateFromJoinOn = Expect<
Equal<
StrictRow<
DB,
'update users set name = $1 from orders o join refunds r on r.order_id = o.id where users.id = o.user_id'
>,
EmptyRow
>
>;

type ValidDeleteUsingJoinOn = Expect<
Equal<
StrictRow<
DB,
'delete from users using orders o join refunds r on r.order_id = o.id where users.id = o.user_id'
>,
EmptyRow
>
>;

type UpdateWithoutFromIsUnaffected = Expect<
Equal<StrictRow<DB, 'update users set name = $1 where id = 1'>, EmptyRow>
>;

type UpdateFromWithReturningIsUnaffected = Expect<
Equal<
StrictRow<
DB,
'update users set name = $1 from orders o where users.id = o.user_id returning users.id'
>,
{ id: number }
>
>;

type LooseModeIsUnchanged = Expect<
Equal<
Row<
DB,
'update users set name = $1 from orders o join refunds r on r.nope = o.id where users.id = o.user_id'
>,
EmptyRow
>
>;

export type DmlJoinOnStrictLock = [
UpdateFromJoinOnIsChecked,
DeleteUsingJoinOnIsChecked,
UpdateFromJoinOnUnknownAliasIsChecked,
ValidUpdateFromJoinOn,
ValidDeleteUsingJoinOn,
UpdateWithoutFromIsUnaffected,
UpdateFromWithReturningIsUnaffected,
LooseModeIsUnchanged,
];