fix(query-builder): return an empty result set for take(0) - #12763
fix(query-builder): return an empty result set for take(0)#12763Develop-KIM wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f566813a2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ((this.expressionMap.take !== undefined && | ||
| this.expressionMap.take !== null) || |
There was a problem hiding this comment.
Avoid issuing FETCH NEXT 0 on SQL Server
When take(0) is used with a join on MSSQL, this newly entered branch passes 0 to the pagination builder's .limit(). The MSSQL renderer emits OFFSET 0 ROWS FETCH NEXT 0 ROWS ONLY, but SQL Server rejects a FETCH row count of zero with error 10744, so the query throws instead of returning an empty array. The added all-driver suite runs in the inspected MSSQL workflow because it disables only Spanner, so the MSSQL CI job and production MSSQL usage remain broken; handle take === 0 without sending an invalid OFFSET/FETCH query.
Useful? React with 👍 / 👎.
Code Review by Qodo
1. take(0) behavior undocumented
|
2f56681 to
eb213c0
Compare
|
The Codex note about SQL Server was right, so I've reworked the fix. My first version made the join pagination branch treat So rather than pushing a zero limit down to the driver, Full sqlite suite is green (3027 passing). I don't have SQL Server or Oracle here, so that part is reasoned from their docs rather than run — if you'd rather I keep the driver list narrower on the new test until CI confirms it, happy to do that. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb213c0022
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // a take of 0 asks for no rows at all, so there is nothing to query; | ||
| // going to the database instead would emit a zero row limit, which | ||
| // SQL Server and Oracle reject, and which joins silently ignore | ||
| if (this.expressionMap.take === 0) return { entities: [], raw: [] } |
There was a problem hiding this comment.
Honor explicit limit before short-circuiting take(0)
When a no-join builder combines an explicit limit with take(0), such as .limit(5).take(0), createLimitOffsetExpression() intentionally uses the explicit limit and ignores take; lazyCount() likewise documents that precedence. This unconditional return instead changes the query to produce no rows, and getManyAndCount() can now return [[], 0] even when matching data exists. Only short-circuit when take(0) is the effective limit rather than merely present.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Right again — thanks. .limit(5).take(0) was losing its five rows, since the short-circuit fired on take being present rather than on it being the row limit that would actually be used.
The guard now also requires limit to be unset, so an explicit limit() keeps the precedence it has had in createLimitOffsetExpression() all along, and a plain take(0) still short-circuits. One deliberate difference: .offset(10).take(0) now returns nothing where it used to return every row past the offset. offset is not a competing row count, so that looked like the same bug as #12666 rather than a precedence rule worth keeping.
Added a test for the limit() case; the full suite is green.
eb213c0 to
5a7a855
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a7a8556f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| this.expressionMap.take === 0 && | ||
| this.expressionMap.limit === undefined | ||
| ) | ||
| return { entities: [], raw: [] } |
There was a problem hiding this comment.
Execute writable CTEs before short-circuiting take(0)
When a PostgreSQL or CockroachDB entity query includes an INSERT/UPDATE/DELETE through addCommonTableExpression(...), take(0) previously executed the statement and merely limited the outer SELECT; writable CTEs still run to completion even when the outer query returns no rows. This return occurs before loadRawResults, so the supported writable CTE is now silently skipped. Restrict the shortcut when writable CTEs are present or otherwise execute the statement while discarding its result rows.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Third one that was worth listening to — thanks.
A data-modifying CTE runs to completion whether or not the outer query reads it, so returning early was quietly dropping the write. Those queries now go out with an explicit limit(0) instead of short-circuiting, so the CTE still executes and the caller still gets nothing back. I gated it on cteCapabilities.writable, since Postgres and CockroachDB are the only drivers that accept a writable CTE in the first place, and both are happy with LIMIT 0 — the drivers that reject a zero row count never reach that branch. Going through limit rather than take also matters here: the join path ignores a bare take of 0, which is the original bug.
Added a test that inserts through addCommonTableExpression alongside take(0) and then counts the row; it fails on the previous commit and passes now. It only runs where writable CTEs exist. Verified against a local Postgres 17 — full suite is green apart from the postgis/pgvector/redis cases my machine can't run.
Also rebased on master while I was here.
5a7a855 to
9513a83
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9513a83281
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| raw: await this.clone() | ||
| .limit(0) | ||
| .loadRawResults(queryRunner), |
There was a problem hiding this comment.
Avoid executing writable CTEs twice in getManyAndCount
When a joined take(0) query with a data-modifying CTE is executed through getManyAndCount(), this call runs the CTE once and returns zero entities; lazyCount() then returns undefined because the entity count equals the requested take (0 === 0), causing executeCountQuery() to clone the original expression map—including the same CTE—and run it again. An INSERT CTE can therefore create duplicate rows or fail on a uniqueness constraint, while an UPDATE can apply its mutation twice; the count query needs to exclude the already-executed writable CTE.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fourth one worth listening to.
The count query does re-run the CTE, and I couldn't see a way to strip it there — an outer query is allowed to select from its own CTE, so dropping it from the count would break the count. So I went at it from the other end: getManyAndCount no longer runs the entity query at all when take is 0. There is nothing for it to fetch, and the count query it already runs carries the same CTE, so the write happens exactly once.
Measured on Postgres — three posts, a CTE that inserts one category, then counting the rows it inserted:
getManyAndCount() |
main | previous commit | now |
|---|---|---|---|
take(0), no join |
2 | 2 | 1 |
take(0) + leftJoinAndSelect |
1, but all 3 rows came back (the bug) | 2 | 1 |
So the double execution was already there on main for take(0) without a join: that path emits LIMIT 0, gets nothing back, and lazyCount then has to count for real. The join path never reached it because the take was being ignored. Both paths now send one statement instead of two.
Added a test that inserts through addCommonTableExpression alongside take(0), calls getManyAndCount, and asserts one inserted row and a count of 3; it fails on the previous commit with two rows. Full suite is green apart from the seven pgvector/postgis/redis failures my machine has without those extensions.
One thing I ran into while measuring that I have not touched: a writable CTE combined with a join and a non-zero take (take(2)) fails with syntax error at or near ":" on main as well — the join pagination sub-query doesn't seem to carry the CTE parameters. It reproduces without this PR, so I left it alone.
9513a83 to
85cee46
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85cee460a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const entitiesAndRaw = this.takesNoRows() | ||
| ? { entities: [], raw: [] } | ||
| : await this.executeEntitiesAndRawResults(queryRunner) |
There was a problem hiding this comment.
Run lock validation before the zero-take shortcut
When getManyAndCount() is called with a pessimistic lock outside an active transaction and take(0), this shortcut bypasses the PessimisticLockTransactionRequiredError check in executeEntitiesAndRawResults(). Execution then proceeds to the count query, which can either issue an invalid lock-bearing aggregate or succeed under an autocommit lock depending on the driver, rather than consistently enforcing TypeORM's transaction requirement; validate the lock before returning the synthetic empty entity result.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fifth one, and right again.
getManyAndCount is the only caller that skips executeEntitiesAndRawResults for take(0), and the pessimistic lock guard lived inside that method — so a locked query outside a transaction stopped raising PessimisticLockTransactionRequiredError and went on to the count query instead. On Postgres that comes back as FOR UPDATE is not allowed with aggregate functions, which is a far worse way to find out.
I pulled the guard out into assertPessimisticLockIsInTransaction and call it from both places, so the short-circuit rejects exactly where the full path does.
Inside a transaction nothing changes: take(0) asks for no rows, and the zero-limit query the no-join path used to send locked nothing either.
Added a test that runs setLock("pessimistic_write").take(0).getManyAndCount() outside a transaction. It fails on the previous commit with the Postgres error above and passes now; it skips sqlite and spanner, the same way the existing locking tests do. Full suite is green here apart from the pgvector/postgis/Docker failures I get on main too.
`take(0)` combined with a join (e.g. `leftJoinAndSelect`) returned every row. The join pagination branch is guarded by `(skip || take)`, so a take of `0` is falsy and the branch is skipped; execution then falls through to the plain path, which only copies skip/take into limit/offset when there are no joins, so no row limit is emitted at all. Rather than teaching that guard about `0`, short-circuit before the query is built: a take of 0 asks for no rows, so there is nothing to ask the database for. That also keeps SQL Server and Oracle out of trouble - both reject a zero row count in `FETCH NEXT ... ROWS ONLY`, which is what the no-join path emits for `take(0)` today. An explicit `limit()` keeps its precedence over `take()`, as it has in `createLimitOffsetExpression()` all along, so `.limit(5).take(0)` still returns up to five rows and only `take(0)` on its own short-circuits. A query that carries a writable CTE is the one case that still has to be sent: a data-modifying CTE runs to completion even when the outer query returns no rows, so short-circuiting would silently swallow the write. Those queries go out with an explicit `limit(0)` instead, which every driver that accepts a writable CTE (Postgres, CockroachDB) also accepts, and which the join path honours where a bare `take` of 0 is ignored. `getManyAndCount` skips the entity query outright for a take of 0. The count still reflects every row, because `lazyCount` falls back to a real count query whenever the result set is as large as the requested page, and since that count query carries the same CTE, a writable one runs exactly once. It ran twice before this change on the no-join path, which already emitted a zero row limit and then counted on top of it. The short-circuit skips `executeEntitiesAndRawResults`, which is where the pessimistic lock guard lives, so that guard moved out into `assertPessimisticLockIsInTransaction` and both paths call it. Without it a locked query outside a transaction stopped raising `PessimisticLockTransactionRequiredError` and reached the count query instead, where Postgres rejects `FOR UPDATE` on an aggregate. Fixes typeorm#12666 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85cee46 to
5d596c8
Compare
Replaces #12685. I closed that one on Aug 6 while cutting down the number of repos I had open PRs in, then settled on a shorter list that TypeORM is on. I'd deleted my fork in the same pass, so GitHub wouldn't let me reopen it.
Description of change
take(0)combined with a join (e.g.leftJoinAndSelect) returns every row instead of an empty result, andgetManyAndCountreports all of them as the page.The join pagination branch in
SelectQueryBuilderis guarded by(this.expressionMap.skip || this.expressionMap.take). Withtake(0),takeis0(falsy), so the branch is skipped and execution falls through to the plain path — which only copiesskip/takeintolimit/offsetwhen there are no joins. With a join present no row limit is emitted at all, so everything comes back.executeEntitiesAndRawResultsnow returns an empty result straight away whentakeis0, before the query is built: a take of 0 asks for no rows, so there is nothing to ask the database for.I first fixed this by making the pagination guard treat
0as a set value, but that pushes a zero row count down to the driver, and both SQL Server (Msg 10744) and Oracle reject that inFETCH NEXT ... ROWS ONLY. The no-join path already has the same problem today —take(0)without a join emits exactly that — which has gone unnoticed because the existingtake(0)tests inquery-builder-select.test.tsarebetter-sqlite3-only. Short-circuiting fixes both paths on every driver instead of extending the breakage to joins.An explicit
limit()keeps its precedence overtake(), the waycreateLimitOffsetExpression()has always applied it, so.limit(5).take(0)still returns up to five rows and onlytake(0)on its own short-circuits..offset(10).take(0)does change: it now returns nothing where it used to return every row past the offset, sinceoffsetis not a competing row count and that read as the same bug rather than a precedence rule.getManyAndCountstill reports the true total:lazyCountfalls back to a real count query when the result set is as large as the requested page, which0 === 0satisfies.How I verified
Added
test/github-issues/12666, running on every driver except spanner. Onmasterthetake(0)assertions fail (3 rows instead of 0) and so does thelimit()precedence one (0 rows instead of 2); with the fix all four pass, andskip(0)with a join still returns every row. Full sqlite suite: 3028 passing, 0 failing — one Redis query-cache test errors locally because I have no working Docker here. I have no SQL Server or Oracle instance available, so the zero-row-count behaviour of those two is from their docs, not from a run.Fixes #12666
AI-assisted: drafted with Claude, reviewed and verified by me.
Pull-Request Checklist
masterbranchFixes #12666tests/**.test.ts)docs/docs/**.md) — N/A, bug fix with no documented behavior change