fix(core): throw on empty Op.or and Op.not instead of matching all rows by WikiRik · Pull Request #18286 · sequelize/sequelize · GitHub
Skip to content

fix(core): throw on empty Op.or and Op.not instead of matching all rows - #18286

Closed
WikiRik wants to merge 2 commits into
mainfrom
fix/empty-op-or-not-throws
Closed

fix(core): throw on empty Op.or and Op.not instead of matching all rows#18286
WikiRik wants to merge 2 commits into
mainfrom
fix/empty-op-or-not-throws

Conversation

@WikiRik

@WikiRik WikiRik commented Jul 29, 2026

Copy link
Copy Markdown
Member

Problem

joinWithLogicalOperator returns '' when every part of a logical group is empty, regardless of the operator. For Op.and that is correct — an empty conjunction is vacuously true, so "no condition" is the right SQL. For Op.or it is exactly backwards: an empty disjunction is vacuously false, so emitting "no condition" turns a restrictive filter into no filter at all.

Model.findAll({ where: { [Op.and]: [{ status: 'active' }, { [Op.or]: [] }] } });
// SELECT ... WHERE "status" = 'active'      <- the Op.or silently vanished

Model.update({ ... }, { where: { [Op.or]: [] } });
// UPDATE "Ts" SET ...                       <- no WHERE, every row

Model.destroy({ where: { [Op.or]: [] } });
// DELETE FROM "Ts"                          <- no WHERE, every row

The write paths are the strongest argument here: Model.destroy has an explicit safeguard (packages/core/src/model.js:2730) that refuses to run without a where, precisely to prevent unbounded deletes. An empty Op.or satisfies 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.not is broken by the same root cause but reaches it by a different path: the contents of an Op.not are joined with Op.and semantics, so an empty Op.not produces '' before any operator check could fire, and wrapWithNot('') propagates the empty string. A fix that only guards the Op.or branch of joinWithLogicalOperator does not fix Op.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:

Invalid Query: an empty Op.or is ambiguous and cannot be compiled to SQL.
An empty disjunction matches no rows, but omitting the condition would match every row.
If you want to match no rows, use sql`1 = 0` (or literal('1 = 0')).
If you want no condition at all, omit the Op.or entirely.
This most often happens when building Op.or from a list that turned out to be empty - check that list before building the where clause.

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.not guard lives in wrapWithNot rather 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.24 onwards. v6 is not affected — it emits 0 = 1 there.

Verification

  • packages/core unit suite, DIALECT=sqlite3 / postgres / mssql: all green (2247 / 2810 / 2234 passing, 0 failing).
  • tsc --noEmit on packages/core clean; tsc -b test/tsconfig.json reports no errors in the files touched (the errors it does report in test/types/hooks.ts are pre-existing and unrelated).
  • eslint and prettier --check clean on both changed files.
  • Integration suites were not run locally; CI covers the matrix.

Also checked, deliberately not changed

packages/core/src/abstract-dialect/query-generator.js:2147 calls joinWithLogicalOperator([joinOn, joinWhere], include.or ? Op.or : Op.and) for include JOINs. I confirmed the new throw is unreachable there: the call is guarded by if (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

  1. Throw vs. restoring 0 = 1 — throwing is the more explicit option but it is a hard break for anyone currently relying on the empty output; 0 = 1 would be a silent semantic change instead. Happy to switch if you prefer.
  2. Whether the attribute-level forms ({ 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.
  3. Whether this warrants a note in the v7 upgrade docs (they live outside this repo, so nothing was added here).

Created by Opus 5 with Claude Code, supervised by @WikiRik.

@coderabbitai review

Summary by CodeRabbit

  • Bug Fixes
    • Empty OR conditions now fail with a clear “ambiguous empty disjunction” error instead of being silently omitted.
    • Empty NOT conditions are now rejected with an explanatory error (including nested/embedded empty cases).
    • Empty AND conditions continue to compile as “no filtering,” including when nested inside other conditions.
  • Tests
    • Updated and centralized assertions to consistently verify the new error behavior for ambiguous empty logical conditions.

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>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@WikiRik

WikiRik commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread packages/core/src/abstract-dialect/where-sql-builder.ts Outdated
Comment thread packages/core/src/abstract-dialect/where-sql-builder.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Comment thread packages/core/test/unit/sql/where.test.ts Outdated
Co-authored-by: Rik Smale <13023439+WikiRik@users.noreply.github.com>
@WikiRik

WikiRik commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@WikiRik WikiRik closed this Aug 31, 2026
@WikiRik
WikiRik deleted the fix/empty-op-or-not-throws branch August 31, 2026 13:25
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.

2 participants