Closes #1110.
returning() froze the statement text at call time and handed back an object whose filter methods were literally no-ops:
{ where: () => obj, andWhere: () => obj, orWhere: () => obj,
orderBy: () => obj, limit: () => obj, offset: () => obj }Every filter expressed after it was discarded and the unfiltered statement ran. Measured on live Postgres 17, 4 rows:
| call | returned before | effect before | after |
|---|---|---|---|
updateTable.set(v).returning('id').where({id:1}) | [1,2,3,4] | all 4 rewritten | [{id:1}], 1 row |
.returning('id').where('id','=',99).executeTakeFirst() | {id:1} | all 4 rewritten | undefined, 0 rows |
deleteFrom.returning('id').where({id:1}) | [1,2,3,4] | table emptied | [{id:1}], 1 row |
.where({id:1}).returning('id') (control) | [{id:1}] | correct | unchanged |
Three things made it worse than a dropped predicate
It reported success for work it never scoped. The second row above returned a row that cannot match id = 99, while rewriting everything. A caller reading that return value concludes one row was touched.
The types invite it. returning() is declared to return SelectQueryBuilder, which declares where, andWhere, orWhere, orderBy, limit — the broken order type-checks under --strict with no casts.
It bypassed the delete guard. The delete path skipped beforeDelete/afterDelete, so adding .returning(...) walked straight past an application-level guard — which is the usual reason to implement beforeDelete in the first place. Verified: a beforeDelete that throws now blocks the delete and leaves all 4 rows.
The fix
Render at execute time, and delegate where/andWhere/whereNull/whereNotNull to the builder that owns the statement.
Deferring also fixes the aliasing form, where the handle is taken and the parent then gains a predicate:
const handle = builder.returning('id')
builder.where({ id: 2 })
await handle.execute() // now honours the where; previously ran the frozen textreturningAll() is deferred for the same reason.
orderBy/limit/offset/orWhere cannot be expressed on these builders, so they now throw rather than being ignored — silently ignoring limit(1) is exactly what turned a one-row delete into a whole-table one. That is a behaviour change, and deliberately so: every caller relying on it today is getting an unfiltered write.
Tests
test/client.returning-filters.test.ts — 16 tests, 15 fail without the src change. The one that passes both ways is the control (.where().returning() still works), which is the point of having it.
Checks
- typecheck and pickier clean
bun test— 2020 pass, 4 skip, 1 fail (stale localbin/qbxCLI > version, pre-existing, rebuilt by CI)
Not addressed here
The delete builder's executeTakeFirst()/executeTakeFirstOrThrow() also skip beforeDelete/afterDelete — the same guard hole, reachable without returning() at all. It is not part of this bug's shape, so it wants its own change rather than being folded in.
🤖 Generated with Claude Code