{{ message }}
fix(core): throw on empty Op.or and Op.not instead of matching all rows - #18286
Closed
WikiRik wants to merge 2 commits into
Closed
fix(core): throw on empty Op.or and Op.not instead of matching all rows#18286WikiRik wants to merge 2 commits into
WikiRik wants to merge 2 commits into
Conversation
An empty `Op.or` compiled to an empty string, i.e. "no condition", which is
the exact opposite of what an empty disjunction means. A restrictive filter
therefore silently turned into no filter at all:
{ [Op.and]: [{ status: 'active' }, { [Op.or]: [] }] } => WHERE status = 'active'
Model.update({ ... }, { where: { [Op.or]: [] } }) => UPDATE with no WHERE
Model.destroy({ where: { [Op.or]: [] } }) => DELETE with no WHERE
`Op.not` had the same problem through a different path: its contents are
joined with `Op.and` semantics, so an empty `Op.not` produced '' before
`wrapWithNot` ever saw an operator.
Both now throw a validation error naming the alternatives, rather than
picking one of the two surprising meanings silently. `Op.and: []` keeps
returning no condition - an empty conjunction really is vacuously true.
BREAKING CHANGE: `{ [Op.or]: [] }`, `{ [Op.or]: {} }`, `{ [Op.not]: [] }` and
`{ [Op.not]: {} }` now throw instead of producing an empty condition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Member
Author
|
@coderabbitai review |
Contributor
✅ Action performedReview finished.
|
WikiRik
commented
Jul 29, 2026
Co-authored-by: Rik Smale <13023439+WikiRik@users.noreply.github.com>
WikiRik
marked this pull request as ready for review
July 29, 2026 11:56
WikiRik
marked this pull request as draft
July 29, 2026 11:56
6 tasks
5 tasks
SippieCup
approved these changes
Aug 29, 2026
5 tasks
Member
Author
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.

Problem
joinWithLogicalOperatorreturns''when every part of a logical group is empty, regardless of the operator. ForOp.andthat is correct — an empty conjunction is vacuously true, so "no condition" is the right SQL. ForOp.orit is exactly backwards: an empty disjunction is vacuously false, so emitting "no condition" turns a restrictive filter into no filter at all.The write paths are the strongest argument here:
Model.destroyhas an explicit safeguard (packages/core/src/model.js:2730) that refuses to run without awhere, precisely to prevent unbounded deletes. An emptyOp.orsatisfies that check and then compiles away to nothing, defeating it. This is easy to hit by accident —{ [Op.or]: tenantIds.map(...) }where the list turned out empty is the classic shape.Op.notis broken by the same root cause but reaches it by a different path: the contents of anOp.notare joined withOp.andsemantics, so an emptyOp.notproduces''before any operator check could fire, andwrapWithNot('')propagates the empty string. A fix that only guards theOp.orbranch ofjoinWithLogicalOperatordoes not fixOp.not.The change
Rather than restoring v6's
0 = 1, both now throw. v6 silently matched nothing, v7 silently matches everything; both are surprising, and a validation error makes it impossible to get wrong quietly while surfacing every affected call site at once. The messages name the alternatives explicitly:Covered forms:
{ [Op.or]: [] },{ [Op.or]: {} },{ [Op.not]: [] },{ [Op.not]: {} },or([]),or({}), the attribute-level equivalents ({ attr: { [Op.or]: [] } }), and nested cases such as{ [Op.or]: [{ [Op.or]: [] }] }and{ [Op.not]: { [Op.and]: [] } }.Op.and: []deliberately still returns no condition. The asymmetry is called out in a code comment so it does not get "fixed" for consistency later, and there are now tests pinning it.The
Op.notguard lives inwrapWithNotrather than at its call site, so it covers both the top-level path ({ [Op.not]: {} }) and the attribute-level path ({ attr: { [Op.not]: {} } }), which had the identical bug.Breaking change
This reverses an intentional decision. 50898ca (#15598) introduced the current behaviour as a documented breaking change — its message lists
or([]) & or({}) produce '' instead of '0=1'. Six unit assertions pinned it; they are updated (not deleted) to expect the throw.Only v7 alphas are affected, from
7.0.0-alpha.24onwards. v6 is not affected — it emits0 = 1there.Verification
packages/coreunit suite,DIALECT=sqlite3/postgres/mssql: all green (2247 / 2810 / 2234 passing, 0 failing).tsc --noEmitonpackages/coreclean;tsc -b test/tsconfig.jsonreports no errors in the files touched (the errors it does report intest/types/hooks.tsare pre-existing and unrelated).eslintandprettier --checkclean on both changed files.Also checked, deliberately not changed
packages/core/src/abstract-dialect/query-generator.js:2147callsjoinWithLogicalOperator([joinOn, joinWhere], include.or ? Op.or : Op.and)forincludeJOINs. I confirmed the new throw is unreachable there: the call is guarded byif (joinWhere), so the array always contains at least one non-empty element and the empty branch is never taken. No change needed, and the generateJoin/select unit tests pass unchanged.Reviewer decisions
0 = 1— throwing is the more explicit option but it is a hard break for anyone currently relying on the empty output;0 = 1would be a silent semantic change instead. Happy to switch if you prefer.{ attr: { [Op.or]: [] } },{ attr: { [Op.not]: {} } }) should be in scope. They had the same bug so I included them, which is slightly wider than a minimal fix.Created by Opus 5 with Claude Code, supervised by @WikiRik.
@coderabbitai review
Summary by CodeRabbit
ORconditions now fail with a clear “ambiguous empty disjunction” error instead of being silently omitted.NOTconditions are now rejected with an explanatory error (including nested/embedded empty cases).ANDconditions continue to compile as “no filtering,” including when nested inside other conditions.