fix: compose empty and null-bearing operand sets correctly - #18324
fix: compose empty and null-bearing operand sets correctly#18324WikiRik wants to merge 1 commit into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughThe WHERE builder now tracks absent, always-true, and always-false conditions. Empty and nullable ChangesWHERE semantics and write protection
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)Full details: Linked Issues checkExplanation The PR satisfies
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
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
162f585 to
1e20bab
Compare
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>

Pull Request Checklist
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 SQLthat 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.notIncompiles to a truth value{ [Op.in]: [] }emittedIN (NULL), which is UNKNOWN rather than FALSE. UNKNOWN isindistinguishable from FALSE at the top of a
WHERE, so the bare case looked right — butNOT (UNKNOWN)is still UNKNOWN, so negating it matched no rows where it should matchevery row.
This is not a judgement call: SQL defines
x IN (…)asx = ANY (…), and an existentialquantifier over an empty set is FALSE for every
x,NULLincluded. You can observe itdirectly, because SQL does have a syntax for an empty right-hand side:
Identical on MySQL. Every ORM I checked emits a constant here and none throws: ActiveRecord
1=0/1=1(arel/visitors/to_sql.rb), Knex1 = 0/1 = 1, TypeORM0=1, SQLAlchemy(col IN (NULL)) AND (1 != 1), Django raisesEmptyResultSetinternally and converts it toa full result set under
exclude().2. A
NULLinside a list is compared separatelyx NOT IN (1, NULL)is UNKNOWN for every row, so it silently matched nothing on everydialect, and
x IN (1, NULL)never matched aNULLx. Sequelize already rewrites{ x: null }tox IS NULLrather than emittingx = NULL; the same now happens inside alist:
Django strips
Nonefor the same reason ("NULL is never equal to anything"); ActiveRecordORs in
attribute.eq(nil), which is the behaviour chosen here.The typings accept
nullfor nullable attributes. That neededWhereAttributeHashValue'sconditional to become non-distributive — for
number | nullit otherwise instantiates theoperator table once for
numberand once fornull, and neither arm accepts a listcontaining both.
3. Truth values are tracked instead of overloading the empty string
joinWithLogicalOperatorandwrapWithNotdropped''unconditionally — including from anOR, whose identity is FALSE rather than TRUE, and from aNOT, where dropping it is neverright. 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 doboolean algebra over them.
or([]),or({})0 = 1{[Op.not]: {}},{[Op.not]: []}0 = 1{attr: {[Op.not]: {}}}0 = 1{[Op.and]: [{status:'active'}, {[Op.or]: []}]}status = 'active'— the OR vanished0 = 1{[Op.and]: []},and({})An
Op.orarm that carries no condition contributes nothing rather than satisfying thewhole disjunction, so
{ [Op.or]: [maybeFilter, cond] }still meanscondwhen the first armis 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 alreadysettled that way in #18250. The
Op.andasymmetry that PR calls out is preserved and pinnedby tests.
4. Statements that modify rows refuse a
wherethat imposes no restrictionModel.destroy,Model.updateandModel.increment/Model.decrementonly checked that awherewas passed, not what it meant — so{ id: { [Op.notIn]: [] } }compiled toWHERE 1 = 1and rewrote the whole table. This is what @SippieCup asked for in #18307.They now check the caller's
whereon its own, immediately before a scope or a paranoidclause is merged into it — the last point at which
options.whereis purely what the callerasked for.
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 NULLwould otherwise absorb the always-true condition and hide it.QueryInterface#bulkDelete,#bulkUpdateand the increment path additionally reject analways-true
wherefor 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
whereat 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 throughjoinWithLogicalOperator, so a fragment containingORrebound:which pulled unrelated rows into a
belongsToManyinclude, and made Oracle's limitedDELETEignore its own
rownumlimit. The bug predates this PR — anOp.orin aninclude.wherehitit too — but a null-bearing
Op.innow produces anOR, which widens it a long way.Verification
tsc --noEmitandtsc -b test/tsconfig.jsoncleanList of Breaking Changes
or([]),or({}),{ [Op.not]: {} }and{ [Op.not]: [] }produce0 = 1instead of nocondition at all.
{ [Op.and]: [] }andand({})still produce no condition.Model.update,Model.destroyandModel.increment/Model.decrementthrow when thecaller's
wherecompiles to a condition that is true for every row, such as{ [Op.notIn]: [] }. Passsql`1 = 1`to affect every row deliberately.nullinside anOp.in/Op.notInlist now means "or is null" instead of being passedthrough to SQL as a list element.
{ [Op.in]: [] }emits0 = 1instead ofIN (NULL). Equivalent inside a top-levelWHERE, 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 #18248had been written on, and spotted that the
Op.indescribe block was missing the compositioncases 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
UPDATE,DELETE, and increment/decrement operations when conditions match every row.IN/NOT INbehavior, including handling ofNULLvalues.INvalues and query-safety options.