ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): apply the filters that follow returning(), instead of discarding them

#1115
Merged glennmichael123 wants to merge fix/1110-returning-discards-filters into main
2 files +262 -44
9 days ago

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:

callreturned beforeeffect beforeafter
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 rewrittenundefined, 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}]correctunchanged

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 text

returningAll() 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 local bin/qbx CLI > 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

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.