Skip to content

Fix: SQL Server max_rows not enforced across UNION/INTERSECT/EXCEPT - #388

Merged
tianzhou merged 2 commits into
mainfrom
fix/issue-387
Jul 31, 2026
Merged

Fix: SQL Server max_rows not enforced across UNION/INTERSECT/EXCEPT#388
tianzhou merged 2 commits into
mainfrom
fix/issue-387

Conversation

@tianzhou

Copy link
Copy Markdown
Member

Summary

  • SQLRowLimiter.applyTopToQuery injected TOP into only the query's first SELECT, 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.
  • When a set operator is detected, the whole statement is now wrapped in a subquery and TOP is 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: add hasSetOperator, and use it in applyTopToQuery to wrap set-operator queries (SELECT TOP N * FROM (...) AS subq) instead of injecting TOP into the first SELECT.
  • 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 for union appearing inside a string literal.

Closes #387

Test plan

  • pnpm test:unit — all 932 tests pass
  • New tests reproduce the issue's exact probe query and confirm the fix
  • Not re-verified against a live SQL Server container (unit coverage of the row-limiter logic was judged sufficient)

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
Copilot AI review requested due to automatic review settings July 31, 2026 08:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 so TOP applies 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.

Comment thread src/utils/sql-row-limiter.ts
Comment thread src/utils/__tests__/sql-row-limiter.test.ts
Comment thread src/utils/sql-row-limiter.ts Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • hasSetOperator can false-positive on identifiers like SQL Server variables (e.g. @union) because \bunion\b matches 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

  • blankCommentsAndStrings builds the output via repeated result += ... concatenations, which can become O(n^2) for long SQL strings. Since applyTopToQuery now calls this on every SQL Server SELECT, switching to an array builder (like stripCommentsAndStrings) avoids quadratic behavior.
export function blankCommentsAndStrings(sql: string, dialect?: ConnectorType): string {
  const scanToken = getScanner(dialect);
  let result = "";
  let i = 0;

@tianzhou
tianzhou merged commit 1e1b139 into main Jul 31, 2026
3 checks passed
@tianzhou
tianzhou deleted the fix/issue-387 branch July 31, 2026 10:40
@tianzhou tianzhou mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQL Server max_rows is not enforced across UNION ALL queries

2 participants