You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
WhereSqlBuilder[Op.in] special-cases an empty array by emitting <left> IN (NULL). The comment above that branch states the intended contract (where-sql-builder.ts#L369-L371):
// NOT IN () does not exist in SQL, so we need to return a condition that is:// - always false if the operator is IN// - always true if the operator is NOT IN
IN (NULL) does not satisfy "always false". In SQL three-valued logic x IN (NULL) is equivalent to x = NULL, which is UNKNOWN, not FALSE. UNKNOWN is indistinguishable from FALSE at the top level of a WHERE clause, so the bare case looks correct — but NOT (UNKNOWN) is still UNKNOWN, so negating an empty Op.in produces a predicate that can never be satisfied.
num IN [] is unsatisfiable, so its negation is a tautology and must match every row. Instead it matches none, and no error is raised.
This is the mirror image of #18248, fixed by #18250 (merged 2026-07-13), which replaced the empty Op.notIn fragment with an explicit 1 = 1. That change deliberately left the Op.in branch alone, because #18248's description stated that Op.in: [] already emitted "IN (NULL), the correct always-false identity for OR" — which is the assumption this report corrects.
It is reachable without ever typing Op.in, since an array value is inferred as Op.in: { [Op.not]: { num: someEmptyArray } } is the natural shape of a dynamically-built exclusion filter that happens to be empty. The same predicate inside Model.update() / Model.destroy() silently becomes a no-op instead of affecting every row.
Reproducible Example
Here is the link to the SSCCE for this issue: not applicable — the SSCCE is pasted inline below, per the template's note that pasted source is accepted. It is fully self-contained, uses SQLite only, contacts no external service, and needs no TypeScript or decorator configuration.
Install and run:
npm i @sequelize/core @sequelize/sqlite3
node sscce-empty-in.mjs
import{DataTypes,Op,Sequelize}from'@sequelize/core';import{SqliteDialect}from'@sequelize/sqlite3';constsequelize=newSequelize({dialect: SqliteDialect,storage: ':memory:',pool: {idle: Number.POSITIVE_INFINITY,max: 1},logging: false,});constM=sequelize.define('M',{name: DataTypes.STRING,num: DataTypes.INTEGER,// nullable on purpose, to cover three-valued logic});awaitsequelize.sync({force: true});awaitM.bulkCreate([{name: 'a',num: 1},{name: 'b',num: 2},{name: 'c',num: null}]);constemptyList=[];// e.g. a dynamically-built filter that happens to be emptyconstqg=sequelize.queryGenerator;constnames=asyncwhere=>(awaitM.findAll({ where })).map(r=>r.name).join(',')||'(none)';constcases=[['in [] ',{num: {[Op.in]: emptyList}},'(none)'],['NOT(in []) ',{[Op.not]: {num: {[Op.in]: emptyList}}},'a,b,c'],['notIn [] ',{num: {[Op.notIn]: emptyList}},'a,b,c'],['NOT(notIn []) ',{[Op.not]: {num: {[Op.notIn]: emptyList}}},'(none)'],// reachable without ever typing Op.in — an array value is inferred as Op.in['NOT({num: []})',{[Op.not]: {num: emptyList}},'a,b,c'],];for(const[label,where,expected]ofcases){constgot=awaitnames(where);constsql=qg.whereQuery(where);console.log(`${label} | ${sql.padEnd(34)} | got ${got.padEnd(7)} | expected ${expected.padEnd(7)} | ${got===expected ? 'ok' : '<-- WRONG'}`,);}awaitsequelize.close();
What do you expect to happen?
num IN [] is unsatisfiable, so its negation is a tautology: all rows should be returned. Symmetrically with the already-fixed empty Op.notIn (which emits 1 = 1, so NOT (1 = 1) correctly returns nothing), the empty Op.in fragment should be an unambiguously false expression such as 0 = 1, so that:
{ num: { [Op.in]: [] } } keeps matching no rows, and
Note that the already-merged Op.notIn: [] → 1 = 1 behaviour returns the num IS NULL row too, so returning all rows here (including NULLs) is consistent with the semantics already accepted in #18250 rather than a new position.
What is actually happening?
Two of the five cases are wrong. Verbatim output of the SSCCE above, run against main at e8b702733 (@sequelize/core@7.0.0-alpha.48):
in [] | WHERE `num` IN (NULL) | got (none) | expected (none) | ok
NOT(in []) | WHERE NOT (`num` IN (NULL)) | got (none) | expected a,b,c | <-- WRONG
notIn [] | WHERE 1 = 1 | got a,b,c | expected a,b,c | ok
NOT(notIn []) | WHERE NOT (1 = 1) | got (none) | expected (none) | ok
NOT({num: []}) | WHERE NOT (`num` IN (NULL)) | got (none) | expected a,b,c | <-- WRONG
No exception or stack trace is produced — the query succeeds and silently returns the wrong rows. The attribute-level form { num: { [Op.not]: { [Op.in]: [] } } } is wrong in the same way.
For completeness, Op.or / Op.and composition of a bare empty Op.in is not affected — num IN (NULL) OR name = 'a' returns the correct row — because UNKNOWN and FALSE are indistinguishable at the top level of a WHERE. Only negation breaks.
Root cause.packages/core/src/abstract-dialect/where-sql-builder.ts#L368-L376, inside WhereSqlBuilder[Op.in] (which [Op.notIn] delegates to). The right.length === 0 branch returns '1 = 1' early only when operator === Op.notIn, and otherwise falls through to rightSql = '(NULL)':
if(right.length===0){// NOT IN () does not exist in SQL, so we need to return a condition that is:// - always false if the operator is IN// - always true if the operator is NOT INif(operator===Op.notIn){return'1 = 1';}rightSql='(NULL)';// <-- UNKNOWN, not FALSE}
wrapWithNot then wraps UNKNOWN in NOT (...), which stays UNKNOWN, so the predicate is unsatisfiable.
Suggested fix. Return an explicit always-false fragment for the empty Op.in case, mirroring the 1 = 1 that #18250 introduced for empty Op.notIn:
- rightSql = '(NULL)';+ return '0 = 1';
I applied exactly this one-line change locally, rebuilt @sequelize/core, and re-ran the SSCCE: all five cases become correct (in [] → WHERE 0 = 1 → no rows; NOT(in []) → WHERE NOT (0 = 1) → a,b,c). 0 = 1 is as portable across dialects as the 1 = 1 already emitted for Op.notIn (which #18250 asserts with a single default: expectation, i.e. identical SQL on every dialect), and is more optimiser-friendly than IN (NULL). It is also the fragment v6 used for this class of always-false condition.
I want to flag one open design question rather than assume the answer. #18286 proposes throwing on empty Op.or / Op.not instead of emitting a truth value, on the reasoning that a silent truth value is surprising in either direction. If that philosophy should extend here, the alternative is to throw on Op.in: [] as well. My reason for suggesting the literal instead, in this specific case:
an empty Op.in has an unambiguous meaning (nothing is a member of the empty set), unlike an empty logical group, which is arguably a programming mistake with no clear intent;
{ [Op.in]: [] } matching no rows is legitimate, widely-relied-upon behaviour today, so throwing would be a breaking change, whereas 0 = 1 preserves it and only changes the emitted SQL text.
Happy to implement whichever direction a maintainer prefers.
On the test side, exactly one existing assertion pins the current SQL and would need updating — where.test.ts#L1630-L1637:
describeInSuite(Op.in,'IN',()=>{testSql({intAttr1: {[Op.in]: []}},{default: '[intAttr1] IN (NULL)'});});
Likely why this survived #18250: the Op.notIn suite immediately below has four cases (bare, Op.or, Op.not, Op.and), while the Op.in suite above has only the bare one. I would add the three missing mirror cases for Op.in alongside the fix.
Environment
Sequelize version: @sequelize/core@7.0.0-alpha.48 (current npm latest; also reproduced on main at e8b702733)
Node.js version: v22.22.2
If TypeScript related: TypeScript version: 5.8.3 (not TypeScript-specific — the SSCCE above is plain JS)
Database & Version: SQLite 3.52.0. Dialect-independent: the fragment is built in the abstract dialect, so all dialects are affected
Connector library & Version: @sequelize/sqlite3@7.0.0-alpha.48 with sqlite3@6.0.1
Would you be willing to resolve this issue by submitting a Pull Request?
No, I don't have the time, and I understand that I will need to wait until someone from the community or maintainers is interested in resolving my issue.
Indicate your interest in the resolution of this issue by adding the 👍 reaction. Comments such as "+1" will be removed.
Issue Creation Checklist
Bug Description
WhereSqlBuilder[Op.in]special-cases an empty array by emitting<left> IN (NULL). The comment above that branch states the intended contract (where-sql-builder.ts#L369-L371):IN (NULL)does not satisfy "always false". In SQL three-valued logicx IN (NULL)is equivalent tox = NULL, which is UNKNOWN, not FALSE. UNKNOWN is indistinguishable from FALSE at the top level of aWHEREclause, so the bare case looks correct — butNOT (UNKNOWN)is still UNKNOWN, so negating an emptyOp.inproduces a predicate that can never be satisfied.num IN []is unsatisfiable, so its negation is a tautology and must match every row. Instead it matches none, and no error is raised.This is the mirror image of #18248, fixed by #18250 (merged 2026-07-13), which replaced the empty
Op.notInfragment with an explicit1 = 1. That change deliberately left theOp.inbranch alone, because #18248's description stated thatOp.in: []already emitted "IN (NULL), the correct always-false identity for OR" — which is the assumption this report corrects.It is reachable without ever typing
Op.in, since an array value is inferred asOp.in:{ [Op.not]: { num: someEmptyArray } }is the natural shape of a dynamically-built exclusion filter that happens to be empty. The same predicate insideModel.update()/Model.destroy()silently becomes a no-op instead of affecting every row.Reproducible Example
Here is the link to the SSCCE for this issue: not applicable — the SSCCE is pasted inline below, per the template's note that pasted source is accepted. It is fully self-contained, uses SQLite only, contacts no external service, and needs no TypeScript or decorator configuration.
Install and run:
What do you expect to happen?
num IN []is unsatisfiable, so its negation is a tautology: all rows should be returned. Symmetrically with the already-fixed emptyOp.notIn(which emits1 = 1, soNOT (1 = 1)correctly returns nothing), the emptyOp.infragment should be an unambiguously false expression such as0 = 1, so that:{ num: { [Op.in]: [] } }keeps matching no rows, and{ [Op.not]: { num: { [Op.in]: [] } } }matches all rows.Note that the already-merged
Op.notIn: []→1 = 1behaviour returns thenum IS NULLrow too, so returning all rows here (including NULLs) is consistent with the semantics already accepted in #18250 rather than a new position.What is actually happening?
Two of the five cases are wrong. Verbatim output of the SSCCE above, run against
mainate8b702733(@sequelize/core@7.0.0-alpha.48):No exception or stack trace is produced — the query succeeds and silently returns the wrong rows. The attribute-level form
{ num: { [Op.not]: { [Op.in]: [] } } }is wrong in the same way.For completeness,
Op.or/Op.andcomposition of a bare emptyOp.inis not affected —num IN (NULL) OR name = 'a'returns the correct row — because UNKNOWN and FALSE are indistinguishable at the top level of aWHERE. Only negation breaks.Root cause.
packages/core/src/abstract-dialect/where-sql-builder.ts#L368-L376, insideWhereSqlBuilder[Op.in](which[Op.notIn]delegates to). Theright.length === 0branch returns'1 = 1'early only whenoperator === Op.notIn, and otherwise falls through torightSql = '(NULL)':wrapWithNotthen wraps UNKNOWN inNOT (...), which stays UNKNOWN, so the predicate is unsatisfiable.Suggested fix. Return an explicit always-false fragment for the empty
Op.incase, mirroring the1 = 1that #18250 introduced for emptyOp.notIn:I applied exactly this one-line change locally, rebuilt
@sequelize/core, and re-ran the SSCCE: all five cases become correct (in []→WHERE 0 = 1→ no rows;NOT(in [])→WHERE NOT (0 = 1)→a,b,c).0 = 1is as portable across dialects as the1 = 1already emitted forOp.notIn(which #18250 asserts with a singledefault:expectation, i.e. identical SQL on every dialect), and is more optimiser-friendly thanIN (NULL). It is also the fragment v6 used for this class of always-false condition.I want to flag one open design question rather than assume the answer. #18286 proposes throwing on empty
Op.or/Op.notinstead of emitting a truth value, on the reasoning that a silent truth value is surprising in either direction. If that philosophy should extend here, the alternative is to throw onOp.in: []as well. My reason for suggesting the literal instead, in this specific case:Op.inhas an unambiguous meaning (nothing is a member of the empty set), unlike an empty logical group, which is arguably a programming mistake with no clear intent;Op.notIn: []was just settled the other way in fix(core): make empty Op.notIn compose correctly under Op.or and Op.not #18250 (merged, maintainer co-authored) — throwing onOp.in: []would be asymmetric with its sibling;{ [Op.in]: [] }matching no rows is legitimate, widely-relied-upon behaviour today, so throwing would be a breaking change, whereas0 = 1preserves it and only changes the emitted SQL text.Happy to implement whichever direction a maintainer prefers.
On the test side, exactly one existing assertion pins the current SQL and would need updating —
where.test.ts#L1630-L1637:Likely why this survived #18250: the
Op.notInsuite immediately below has four cases (bare,Op.or,Op.not,Op.and), while theOp.insuite above has only the bare one. I would add the three missing mirror cases forOp.inalongside the fix.Environment
@sequelize/core@7.0.0-alpha.48(current npmlatest; also reproduced onmainate8b702733)v22.22.25.8.3(not TypeScript-specific — the SSCCE above is plain JS)3.52.0. Dialect-independent: the fragment is built in the abstract dialect, so all dialects are affected@sequelize/sqlite3@7.0.0-alpha.48withsqlite3@6.0.1Would you be willing to resolve this issue by submitting a Pull Request?
Indicate your interest in the resolution of this issue by adding the 👍 reaction. Comments such as "+1" will be removed.