Fix: SQL Server max_rows not enforced across UNION/INTERSECT/EXCEPT - #388
Merged
Conversation
TOP was only injected into the query's first SELECT, so it capped that branch's rows instead of the combined set-operator output. Wrap the whole statement in a subquery and apply TOP to the outer result set when a UNION/INTERSECT/EXCEPT is present. Closes #387
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes SQL Server max_rows enforcement for set-operator queries by avoiding TOP injection into only the first SELECT branch and instead applying a cap to the combined result set.
Changes:
- Add set-operator detection (
UNION [ALL],INTERSECT,EXCEPT) and wrap these statements soTOPapplies to the overall output. - Add unit tests covering the UNION ALL repro from #387 and additional set-operator/semicolon/string-literal cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/utils/sql-row-limiter.ts |
Adds set-operator detection and changes SQL Server TOP application strategy to cap combined set-operator results. |
src/utils/__tests__/sql-row-limiter.test.ts |
Expands unit tests to cover SQL Server set-operator cases and edge conditions. |
…rator wraps Addresses Copilot review feedback on #388: - A top-level trailing ORDER BY was left inside the wrapped subquery, which T-SQL rejects unless the subquery itself has TOP/OFFSET/FOR XML. It's now hoisted outside the wrap. - A TOP present only on one branch of a UNION/INTERSECT/EXCEPT (e.g. `SELECT TOP 50 ... UNION ALL SELECT ...`) was mistaken for a genuine outer TOP and just tightened, leaving the combined result uncapped. Set-operator detection now runs first and always wraps, regardless of any branch-level TOP. Both checks are now parenthesis-depth aware (via a new length-preserving blankCommentsAndStrings helper in sql-parser.ts) so a UNION or ORDER BY nested inside a subquery or a window function's OVER (...) clause is correctly treated as non-top-level.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/utils/sql-row-limiter.ts:124
hasSetOperatorcan false-positive on identifiers like SQL Server variables (e.g.@union) because\bunion\bmatches when preceded by@. That would incorrectly wrap non-set-operator SELECTs in a derived table and can change behavior or break otherwise-valid statements. Consider requiring a valid operator boundary (start/whitespace/)) before treating UNION/INTERSECT/EXCEPT as set operators.
const blankedSQL = blankCommentsAndStrings(sql, "sqlserver");
let found = false;
this.scanTopLevel(blankedSQL, /\(|\)|\bunion\b|\bintersect\b|\bexcept\b/gi, () => {
found = true;
});
src/utils/sql-parser.ts:257
blankCommentsAndStringsbuilds the output via repeatedresult += ...concatenations, which can become O(n^2) for long SQL strings. SinceapplyTopToQuerynow calls this on every SQL Server SELECT, switching to an array builder (likestripCommentsAndStrings) avoids quadratic behavior.
export function blankCommentsAndStrings(sql: string, dialect?: ConnectorType): string {
const scanToken = getScanner(dialect);
let result = "";
let i = 0;
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SQLRowLimiter.applyTopToQueryinjectedTOPinto only the query's firstSELECT, so for set-operator queries (UNION [ALL],INTERSECT,EXCEPT) it capped that first branch's rows, not the combined output — the remaining branches' rows all passed through uncapped.TOPis applied to the outer result set instead, matching how the other connectors already enforce max_rows on wrapped queries.Changes
src/utils/sql-row-limiter.ts: addhasSetOperator, and use it inapplyTopToQueryto wrap set-operator queries (SELECT TOP N * FROM (...) AS subq) instead of injectingTOPinto the firstSELECT.src/utils/__tests__/sql-row-limiter.test.ts: add coverage for the UNION ALL repro from the issue, plain UNION, INTERSECT/EXCEPT, semicolon preservation, and a false-positive check forunionappearing inside a string literal.Closes #387
Test plan
pnpm test:unit— all 932 tests pass