fix: compose empty and null-bearing operand sets correctly by WikiRik · Pull Request #18324 · sequelize/sequelize · GitHub
Skip to content

fix: compose empty and null-bearing operand sets correctly - #18324

Open
WikiRik wants to merge 1 commit into
mainfrom
fix/where-empty-and-null-operand-sets
Open

fix: compose empty and null-bearing operand sets correctly#18324
WikiRik wants to merge 1 commit into
mainfrom
fix/where-empty-and-null-operand-sets

Conversation

@WikiRik

@WikiRik WikiRik commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pull Request Checklist

  • Have you added new tests to prevent regressions?
  • If a documentation update is necessary, have you opened a PR to the documentation repository?
  • Did you update the typescript typings accordingly (if applicable)?
  • Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
  • Does the name of your PR follow our conventions?

Description of Changes

Closes #18306
Closes #18307
Closes #18286

A condition whose operand set turns out to be empty, or to contain NULL, compiled to SQL
that did not mean what the condition meant. The mismatch was invisible on its own and only
surfaced once the condition was composed — which is why it kept being fixed one operator at a
time (#4859, #18248 / #18250, #18286, #18306) without the class ever closing.

1. An empty Op.in / Op.notIn compiles to a truth value

{ [Op.in]: [] } emitted IN (NULL), which is UNKNOWN rather than FALSE. UNKNOWN is
indistinguishable from FALSE at the top of a WHERE, so the bare case looked right — but
NOT (UNKNOWN) is still UNKNOWN, so negating it matched no rows where it should match
every row.

This is not a judgement call: SQL defines x IN (…) as x = ANY (…), and an existential
quantifier over an empty set is FALSE for every x, NULL included. You can observe it
directly, because SQL does have a syntax for an empty right-hand side:

SELECT NULL::int IN     (SELECT 1 WHERE false);  -- f    <- FALSE, not NULL
SELECT NULL::int NOT IN (SELECT 1 WHERE false);  -- t
SELECT NULL::int IN     (NULL);                  -- NULL <- what Sequelize emitted

Identical on MySQL. Every ORM I checked emits a constant here and none throws: ActiveRecord
1=0/1=1 (arel/visitors/to_sql.rb), Knex 1 = 0/1 = 1, TypeORM 0=1, SQLAlchemy
(col IN (NULL)) AND (1 != 1), Django raises EmptyResultSet internally and converts it to
a full result set under exclude().

2. A NULL inside a list is compared separately

x NOT IN (1, NULL) is UNKNOWN for every row, so it silently matched nothing on every
dialect, and x IN (1, NULL) never matched a NULL x. Sequelize already rewrites
{ x: null } to x IS NULL rather than emitting x = NULL; the same now happens inside a
list:

[Op.in]:    [1, null] -> x IN (1) OR x IS NULL
[Op.notIn]: [1, null] -> x NOT IN (1) AND x IS NOT NULL
[Op.in]:    [null]    -> x IS NULL

Django strips None for the same reason ("NULL is never equal to anything"); ActiveRecord
ORs in attribute.eq(nil), which is the behaviour chosen here.

The typings accept null for nullable attributes. That needed WhereAttributeHashValue's
conditional to become non-distributive — for number | null it otherwise instantiates the
operator table once for number and once for null, and neither arm accepts a list
containing both.

3. Truth values are tracked instead of overloading the empty string

joinWithLogicalOperator and wrapWithNot dropped '' unconditionally — including from an
OR, whose identity is FALSE rather than TRUE, and from a NOT, where dropping it is never
right. That single behaviour is the shared cause of #18248, #18306 and #18286.

Fragments now carry NO_CONDITION / ALWAYS_TRUE / ALWAYS_FALSE, and AND, OR and NOT do
boolean algebra over them.

where before after
or([]), or({}) (no condition — matched every row) 0 = 1
{[Op.not]: {}}, {[Op.not]: []} (no condition) 0 = 1
{attr: {[Op.not]: {}}} (no condition) 0 = 1
{[Op.and]: [{status:'active'}, {[Op.or]: []}]} status = 'active' — the OR vanished 0 = 1
{[Op.and]: []}, and({}) (no condition) unchanged, deliberately

An Op.or arm that carries no condition contributes nothing rather than satisfying the
whole disjunction, so { [Op.or]: [maybeFilter, cond] } still means cond when the first arm
is absent. That distinction — an OR with an empty arm versus an OR with no arms — is what
keeps the common optional-filter idiom working.

This supersedes #18286, which fixes the same cases by throwing. The literal is chosen here
because the empty set has a defined meaning in SQL (§1) and because Op.notIn: [] was already
settled that way in #18250. The Op.and asymmetry that PR calls out is preserved and pinned
by tests.

4. Statements that modify rows refuse a where that imposes no restriction

Model.destroy, Model.update and Model.increment / Model.decrement only checked that a
where was passed, not what it meant — so { id: { [Op.notIn]: [] } } compiled to
WHERE 1 = 1 and rewrote the whole table. This is what @SippieCup asked for in #18307.

They now check the caller's where on its own, immediately before a scope or a paranoid
clause is merged into it — the last point at which options.where is purely what the caller
asked for.

Model.destroy({ where: { id: { [Op.notIn]: [] } } })          // throws
Model.destroy({ where: { [Op.not]: { id: { [Op.in]: [] } } } }) // throws
Model.destroy({ where: { tenantId: 5, id: { [Op.notIn]: [] } } }) // runs — the tenant filter is real
Model.destroy({ where: sql`1 = 1` })                          // runs — the documented escape hatch
Model.destroy({ where: {} })                                  // runs — unchanged
Model.findAll({ where: { id: { [Op.notIn]: [] } } })          // reads are untouched

Judging the caller's conditions rather than the compiled statement is what keeps the third
line working while still catching the first on a paranoid or scoped model — where the
injected deletedAt IS NULL would otherwise absorb the always-true condition and hide it.
QueryInterface#bulkDelete, #bulkUpdate and the increment path additionally reject an
always-true where for callers that use them directly.

That this check has to sit in a window rather than being a property of the compiled
statement is a symptom of scope and paranoid conditions being merged into the caller's
where at all. Filed as #18323 for a later re-architecture.

5. Where fragments are bracketed where they are concatenated by hand

Four sites build SQL by appending ` AND ${fragment}` instead of going through
joinWithLogicalOperator, so a fragment containing OR rebound:

ON "User"."id" = ""."userId" AND "p"."tag" IN ('x') OR "p"."tag" IS NULL

which pulled unrelated rows into a belongsToMany include, and made Oracle's limited DELETE
ignore its own rownum limit. The bug predates this PR — an Op.or in an include.where hit
it too — but a null-bearing Op.in now produces an OR, which widens it a long way.

Verification

  • Unit suite on all nine dialects: 2241–2867 passing, 0 failing
  • PostgreSQL integration suite against a live server: 2103 passing, 0 failing
  • Behaviour confirmed against live PostgreSQL 17, MySQL 8.4 and SQL Server 2025
  • tsc --noEmit and tsc -b test/tsconfig.json clean

List of Breaking Changes

  • or([]), or({}), { [Op.not]: {} } and { [Op.not]: [] } produce 0 = 1 instead of no
    condition at all. { [Op.and]: [] } and and({}) still produce no condition.
  • Model.update, Model.destroy and Model.increment / Model.decrement throw when the
    caller's where compiles to a condition that is true for every row, such as
    { [Op.notIn]: [] }. Pass sql`1 = 1` to affect every row deliberately.
  • A null inside an Op.in / Op.notIn list now means "or is null" instead of being passed
    through to SQL as a list element.
  • A bare { [Op.in]: [] } emits 0 = 1 instead of IN (NULL). Equivalent inside a top-level
    WHERE, but code asserting on the exact generated SQL will see a difference.

Credit

§1 is @lazerg's fix from #18307, and the analysis it rests on is theirs — the diagnosis in
#18306 identified IN (NULL) as UNKNOWN rather than FALSE, corrected the assumption #18248
had been written on, and spotted that the Op.in describe block was missing the composition
cases that would have caught it. The write-path discussion in §4 follows their reasoning in
the #18307 thread, including the point that a guard belongs at the layer that already checks
for a missing where.


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

Summary by CodeRabbit

  • Bug Fixes
    • Prevented accidental unrestricted UPDATE, DELETE, and increment/decrement operations when conditions match every row.
    • Corrected empty IN/NOT IN behavior, including handling of NULL values.
    • Improved grouping of conditions in generated JOIN and Oracle queries.
  • Developer Experience
    • Added clearer errors when destructive operations would affect every row.
    • Improved TypeScript support for nullable IN values and query-safety options.
    • Preserved explicitly supplied always-true SQL conditions when intentionally requested.

@WikiRik
WikiRik requested a review from a team as a code owner August 31, 2026 11:56
@WikiRik
WikiRik requested review from SippieCup and sdepold August 31, 2026 11:56
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c386669-cdd1-4921-9510-fd8cb091fc60

📥 Commits

Reviewing files that changed from the base of the PR and between 162f585 and 1e20bab.

📒 Files selected for processing (1)
  • packages/core/test/unit/sql/where.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The WHERE builder now tracks absent, always-true, and always-false conditions. Empty and nullable IN predicates use explicit SQL semantics. DELETE, UPDATE, increment, and decrement paths reject derived unrestricted conditions.

Changes

WHERE semantics and write protection

Layer / File(s) Summary
WHERE fragments and operator semantics
packages/core/src/abstract-dialect/where-sql-builder.ts, packages/core/src/abstract-dialect/where-sql-builder-types.ts, packages/core/src/model.d.ts
The builder folds truth-value fragments through AND, OR, and NOT. Empty IN and NOT IN lists produce explicit false and true fragments. Nullable lists generate separate IS NULL or IS NOT NULL predicates.
Query generation and option propagation
packages/core/src/abstract-dialect/query-generator-typescript.ts, packages/core/src/abstract-dialect/query-generator.d.ts, packages/core/src/abstract-dialect/query-generator.types.ts, packages/core/src/abstract-dialect/query-generator.js, packages/core/src/abstract-dialect/query-interface-typescript.ts, packages/core/src/abstract-dialect/query-interface.js, packages/oracle/src/...
Query generators reject derived always-true DELETE and UPDATE predicates. Query interfaces remove the internal option before calling queryRaw. JOIN and limited Oracle DELETE predicates are parenthesized.
Model write safeguards
packages/core/src/model.js
destroy, update, and increment validate defined WHERE conditions before executing row-modifying queries.
SQL and query-generator validation
packages/core/test/unit/sql/where.test.ts, packages/core/test/unit/query-generator/*
Tests cover truth-value folding, nullable lists, write-query rejection, bind preservation, JOIN rendering, and Oracle DELETE SQL.
Model and query-interface validation
packages/core/test/integration/model/*, packages/core/test/unit/query-interface/*
Tests verify rejected unrestricted writes, accepted explicit SQL truth predicates, unchanged rows, and removal of the internal option before execution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 1e20b

The PR makes empty and NULL-bearing operand conditions compile with their intended meaning and prevents unrestricted row modifications from ambiguous filters; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Model
  participant QueryInterface
  participant QueryGenerator
  participant WhereSqlBuilder
  participant Database
  Model->>QueryGenerator: validate model WHERE
  QueryGenerator->>WhereSqlBuilder: formatWhereOptionsFragment
  WhereSqlBuilder-->>QueryGenerator: truth-value fragment
  QueryGenerator-->>Model: reject or continue
  QueryInterface->>QueryGenerator: generate write SQL
  QueryGenerator->>Database: execute generated SQL
Loading

Suggested reviewers: sdepold, sippiecup

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR satisfies #18306 and #18307 by compiling empty Op.in conditions as false and composing them correctly. It does not satisfy #18286 because empty Op.or and Op.not conditions compile to 0 = 1 inst… Implement the required errors for empty Op.or and Op.not, including attribute-level and nested forms. Update the related SQL and integration tests to expect the errors while preserving empty Op.and as no condition.
Out of Scope Changes check ⚠️ Warning The PR includes changes beyond the linked issue requirements, including NULL-bearing Op.in/Op.notIn semantics, generalized unrestricted-write protection, and JOIN/Oracle SQL parenthesization changes. Remove these unrelated changes or link them to separate issues. Keep only the changes required for empty Op.in composition and empty Op.or/Op.not handling, unless the additional objectives are explicitly added to the linked issues.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 24 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: correct composition of empty and NULL-bearing operand sets.
Full details: Linked Issues check

Explanation

The PR satisfies #18306 and #18307 by compiling empty Op.in conditions as false and composing them correctly. It does not satisfy #18286 because empty Op.or and Op.not conditions compile to 0 = 1 instead of throwing the required validation error.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/where-empty-and-null-operand-sets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

WikiRik pushed a commit to sequelize/website that referenced this pull request Aug 31, 2026
Documents sequelize/sequelize#18324.

Rewrites the "Changes to empty OR & NOT operators" section: an empty Op.or or
Op.not now produces a false condition rather than being ignored, and an Op.or
with an empty *member* is distinguished from one that is empty. Notes that
Sequelize 6 produced `0 = 1` for the top-level forms but dropped the
attribute-level one, so this is not purely a return to v6 behaviour.

Adds three sections the guide never covered, since Op.in and Op.notIn were not
mentioned in it at all:

- empty IN / NOT IN, which produced `IN (NULL)` in v6 and every v7 alpha
- a null inside an IN / NOT IN list, now compared separately
- Model.update / destroy / increment / decrement rejecting a `where` that is
  true for every row on its own

Also replaces an example that used `not({})`, which is not exported in v7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hNgQwGLn8qFiAmn3ZNVLW
A condition whose operand set turns out to be empty, or to contain NULL,
compiled to SQL that did not mean what the condition meant. The mismatch was
invisible on its own and only surfaced once the condition was composed, which
is why it had been fixed one operator at a time without the class ever closing.

1. An empty Op.in / Op.notIn compiles to a truth value

`{ [Op.in]: [] }` emitted `IN (NULL)`, which is UNKNOWN rather than FALSE.
UNKNOWN is indistinguishable from FALSE at the top of a WHERE, so the bare case
looked right, but `NOT (UNKNOWN)` is still UNKNOWN and the negation matched no
rows where it should have matched every row. Membership of the empty set is
false for every value, including NULL, so its negation is true for every value.

The left operand is still compiled, with bind collection disabled, so an
operand that cannot be escaped keeps throwing when the list happens to be empty.

2. A NULL inside a list is compared separately

`x NOT IN (1, NULL)` is UNKNOWN for every row, so it silently matched nothing on
every dialect, and `x IN (1, NULL)` never matched a NULL x. Sequelize already
rewrites `{ x: null }` to `x IS NULL` rather than emitting `x = NULL`; the same
now happens inside a list:

    [Op.in]:    [1, null] -> x IN (1) OR x IS NULL
    [Op.notIn]: [1, null] -> x NOT IN (1) AND x IS NOT NULL
    [Op.in]:    [null]    -> x IS NULL

The typings accept null for nullable attributes. That needed
WhereAttributeHashValue's conditional to become non-distributive: for
`number | null` it otherwise instantiates the operator table once for `number`
and once for `null`, and neither arm accepts a list containing both.

3. Truth values are tracked instead of overloading the empty string

joinWithLogicalOperator and wrapWithNot dropped '' unconditionally, including
from an OR - whose identity is FALSE, not TRUE - and from a NOT, where dropping
it is never right. An empty Op.or therefore compiled to no condition at all and
matched every row, and `Op.not` around an empty group silently lost the NOT.

Fragments now carry NO_CONDITION / ALWAYS_TRUE / ALWAYS_FALSE and AND, OR and
NOT do boolean algebra over them. An empty conjunction stays "no condition at
all", which is deliberate and is pinned by tests; an empty disjunction is false.
An OR *arm* that carries no condition contributes nothing rather than
satisfying the whole disjunction, so `{ [Op.or]: [maybeFilter, cond] }` still
means `cond` when the first arm is absent.

4. Statements that modify rows refuse a where that imposes no restriction

Model.destroy, Model.update and Model.increment / Model.decrement only checked
that a `where` was passed, not what it meant, so `{ id: { [Op.notIn]: [] } }`
compiled to `WHERE 1 = 1` and rewrote the whole table.

They now check the caller's `where` on its own, immediately before a scope or a
paranoid clause is merged into it - the last point at which `options.where` is
purely what the caller asked for. Judging the caller's conditions rather than
the compiled statement is what keeps `{ tenantId: 5, id: { [Op.notIn]: [] } }`
working, while still catching the same empty list on a paranoid or scoped model.
QueryInterface#bulkDelete, #bulkUpdate and the increment path additionally
reject an always-true `where` for callers that use them directly.

A condition the caller wrote (sql`1 = 1`) is a plain SQL fragment rather than a
derived truth value, so it is still accepted, as is an empty `where`. Reads are
untouched. See #18323 for why the check has to sit where it does.

5. Where fragments are bracketed where they are concatenated by hand

Four sites build SQL by appending ` AND ${fragment}` instead of going through
joinWithLogicalOperator, so a fragment containing OR rebound and pulled
unrelated rows into a belongsToMany include, and made Oracle's limited DELETE
ignore its own rownum limit. The bug predates this change - an `Op.or` in an
include.where hit it too - but a null-bearing `Op.in` now produces an OR, which
widens it a long way.

Verified against live PostgreSQL 17, MySQL 8.4 and SQL Server 2025, the unit
matrix on all nine dialects, and the PostgreSQL integration suite.

BREAKING CHANGE: `or([])`, `or({})`, `{ [Op.not]: {} }` and `{ [Op.not]: [] }`
produce `0 = 1` instead of no condition at all. `{ [Op.and]: [] }` and `and({})`
still produce no condition.

BREAKING CHANGE: `Model.update`, `Model.destroy` and `Model.increment` /
`Model.decrement` throw when the caller's `where` compiles to a condition that
is true for every row, such as `{ [Op.notIn]: [] }` or `Op.not` around an
always-false condition. Pass sql`1 = 1` to affect every row deliberately.

BREAKING CHANGE: a `null` inside an `Op.in` / `Op.notIn` list now means "or is
null" instead of being passed through to SQL as a list element.

BREAKING CHANGE: a bare `{ [Op.in]: [] }` emits `0 = 1` instead of `IN (NULL)`.
The two are equivalent inside a top-level WHERE, but code asserting on the exact
generated SQL will see a difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hNgQwGLn8qFiAmn3ZNVLW
@WikiRik
WikiRik force-pushed the fix/where-empty-and-null-operand-sets branch from 162f585 to 1e20bab Compare August 31, 2026 12:09
WikiRik pushed a commit that referenced this pull request Sep 4, 2026
The Oracle healthcheck ran sqlplus without `-L`, so a rejected logon
still exited 0 as soon as the listener was up. The container was
reported healthy before the XEPDB1 pluggable database was registered
with the listener, which the 30s sleep in start.sh papered over.

start.sh then ran sqlplus with a TTY and without `-L`. When the logon
failed with ORA-12514, sqlplus dropped into its interactive logon
prompt and waited for input forever, hanging the CI job until the
6 hour job limit (see the oracle latest Node 22 job on #18324).

- healthcheck: pipe a query into `sqlplus -L -S` so it only passes
  when a real logon to XEPDB1 through the listener succeeds; add a
  start_period so boot-time probe failures are not reported as
  unhealthy
- start.sh: drop the TTY, add `-L` so a failed logon exits non-zero
  immediately, and remove the blind 30s sleep
- privileges.sql: exit non-zero on the first SQL/OS error

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

Op.in: [] emits IN (NULL) (SQL UNKNOWN, not FALSE), so Op.not around it returns zero rows instead of all rows

1 participant