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

- The editor plugin stops reporting `ambiguous column` on the branches of a set operation. It pooled every FROM/JOIN in the statement into one scope, so the columns a `union` shares - which is all of them, since branches have to be column-compatible - looked like they came from two tables at once, putting a squiggle on nearly every UNION. Diagnostics now cover the first branch, which is also the branch the core takes the row shape from ([#294](https://github.com/tiagolauer/OwlSQL/issues/294)).

## [0.2.0] - 2026-07-29

### Changed
Expand Down
38 changes: 37 additions & 1 deletion ts-plugin/src/diagnostics.cts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,40 @@ function columnTokenFromEntry(entry: ColumnEntry): { token: string; offset: numb
return { token, offset: leadingWhitespace };
}

// Only the first branch of a set operation is diagnosed. The scanner pools
// every FROM/JOIN in the statement into one scope, so both branches of a
// `union` shared a source list and any column name they had in common - which
// is every column, since branches have to be compatible - came back as
// `ambiguous column` (issue #294). The core types a set operation by its first
// branch, and truncating here matches that: later branches are not reported
// on, instead of being reported wrongly. Truncation keeps every offset before
// the cut, so spans stay aligned with the source.
const SET_OPERATOR = /\b(?:union|intersect|except)\b/gi;

function parenDepthAt(text: string, index: number): number {
let depth = 0;
for (let i = 0; i < index; i += 1) {
const char = text[i];
if (char === '(') {
depth += 1;
} else if (char === ')') {
depth -= 1;
}
}
return depth;
}

function firstBranchEnd(stripped: string): number {
SET_OPERATOR.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = SET_OPERATOR.exec(stripped)) !== null) {
if (parenDepthAt(stripped, match.index) === 0) {
return match.index;
}
}
return stripped.length;
}

function getQueryDiagnostics(
typescript: typeof ts,
checker: ts.TypeChecker,
Expand All @@ -298,7 +332,9 @@ function getQueryDiagnostics(
// computed against it drift out of alignment with literalStart (a raw
// source position) by one character per preceding line break on a CRLF
// file. Mirrors the same fix already applied to hover in index.cts.
const text = sourceFile.text.slice(literalStart, literal.getEnd() - 1);
const fullText = sourceFile.text.slice(literalStart, literal.getEnd() - 1);
const branchEnd = firstBranchEnd(stripStringLiterals(fullText).stripped);
const text = fullText.slice(0, branchEnd);
const { stripped } = stripStringLiterals(text);
// A CTE query's outer statement doesn't start at offset 0 - skip the
// WITH-clause prefix (mirroring ParseWithClause in src/cte.ts) so the
Expand Down
33 changes: 33 additions & 0 deletions ts-plugin/tests/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,37 @@ describe('ts-plugin diagnostics: getQueryDiagnostics', () => {
diagnosticsFor('select posts.titel from users join posts on users.id = posts.user_id'),
).toEqual([{ message: 'unknown column: titel', text: 'titel' }]);
});

// Regression for #294: the scanner pooled both branches of a set operation
// into one scope, so a column name they share - which is every column, since
// branches have to be compatible - came back as ambiguous.
describe('set operations', () => {
it('does not report a shared column name across UNION branches', () => {
expect(diagnosticsFor('select id from users union select id from posts')).toEqual([]);
});

it('does not report across UNION ALL, INTERSECT or EXCEPT either', () => {
expect(diagnosticsFor('select id from users union all select id from posts')).toEqual([]);
expect(diagnosticsFor('select id from users intersect select id from posts')).toEqual([]);
expect(diagnosticsFor('select id from users except select id from posts')).toEqual([]);
});

it('still reports a typo in the first branch', () => {
expect(diagnosticsFor('select nope from users union select id from posts')).toEqual([
{ message: 'unknown column: nope', text: 'nope' },
]);
});

it('still reports an ambiguous column inside the first branch itself', () => {
expect(
diagnosticsFor('select id from users join posts on users.id = posts.user_id union select id from posts'),
).toEqual([{ message: 'ambiguous column: id', text: 'id' }]);
});

it('does not cut the statement at a union inside a subquery', () => {
expect(
diagnosticsFor('select nope from users where id in (select id from posts union select id from posts)'),
).toEqual([{ message: 'unknown column: nope', text: 'nope' }]);
});
});
});