fix(core): make empty Op.in compose correctly under Op.not by lazerg · Pull Request #18307 · sequelize/sequelize · GitHub
Skip to content

fix(core): make empty Op.in compose correctly under Op.not - #18307

Closed
lazerg wants to merge 3 commits into
sequelize:mainfrom
lazerg:fix/issue-18306-empty-in
Closed

fix(core): make empty Op.in compose correctly under Op.not#18307
lazerg wants to merge 3 commits into
sequelize:mainfrom
lazerg:fix/issue-18306-empty-in

Conversation

@lazerg

@lazerg lazerg commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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

An empty Op.in was emitted as IN (NULL), which SQL evaluates to UNKNOWN rather than FALSE. At the top level of a WHERE that is indistinguishable from FALSE, so the bare case looked fine, but NOT (UNKNOWN) is still UNKNOWN, so { [Op.not]: { num: { [Op.in]: [] } } } matched no rows when it should match every row. That shape is reachable without naming the operator, since an array value is inferred as Op.in.

The empty Op.in fragment is now 0 = 1, mirroring the 1 = 1 that #18250 introduced for empty Op.notIn, so negation behaves as expected. The Op.or / Op.not / Op.and composition cases already covered for Op.notIn are now covered for Op.in as well.

Note: the emitted SQL for a bare { [Op.in]: [] } changes from IN (NULL) to 0 = 1. The two are equivalent inside a top-level WHERE, but code asserting on the exact generated SQL will see a difference.

One design note, since #18286 proposes throwing on an empty Op.or / Op.not rather than emitting a truth value: I went with the literal here because an empty Op.in has one unambiguous meaning, because #18250 settled the sibling Op.notIn case the same way, and because throwing would break the existing and widely relied on behaviour of { [Op.in]: [] } matching no rows. Happy to switch to a throw if you prefer that direction.

List of Breaking Changes

None.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed queries using an empty IN condition to correctly return no matches instead of relying on SQL NULL behavior.
    • Ensured empty IN conditions work correctly when combined with OR, AND, and NOT filters.

@lazerg
lazerg requested a review from a team as a code owner August 17, 2026 18:26
@lazerg
lazerg requested review from WikiRik and sdepold August 17, 2026 18:26
@lazerg

lazerg commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@lazerg lazerg closed this Aug 18, 2026
@lazerg lazerg reopened this Aug 29, 2026
@lazerg

lazerg commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Reopening this. I closed it on 18 August because the reporter said they wanted to send their own PR for #18306. None has been opened in the 11 days since, so I am putting this back up.

Nothing on the branch changed. It still applies cleanly on main and CI was green. If the reporter or anyone else opens a PR for this, I will close mine again.

@suhailopensource

suhailopensource commented Aug 29, 2026

Copy link
Copy Markdown

I have no issues with @lazerg raising a PR.

@coderabbitai

coderabbitai Bot commented Aug 29, 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: 2cf313de-49d8-4920-8a6d-16b60f0aa4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 544aae8 and 8194ef2.

📒 Files selected for processing (2)
  • packages/core/src/abstract-dialect/where-sql-builder.ts
  • packages/core/test/unit/sql/where.test.ts

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


📝 Walkthrough

Walkthrough

Empty Op.in arrays now produce the always-false SQL predicate 0 = 1 instead of IN (NULL). Tests cover bare expressions and compositions with Op.or, Op.not, and Op.and.

Changes

Empty Op.in handling

Layer / File(s) Summary
Empty-array predicate and test coverage
packages/core/src/abstract-dialect/where-sql-builder.ts, packages/core/test/unit/sql/where.test.ts
The SQL builder emits 0 = 1 for empty Op.in arrays. Tests verify empty Op.in behavior with logical composition and negation. Op.notIn: [] remains 1 = 1.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 8194e

This localized change corrects empty membership predicates under negation and adds regression coverage; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: sdepold

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the core fix: correct composition of empty Op.in under Op.not.
Linked Issues check ✅ Passed The implementation changes empty Op.in to the always-false predicate 0 = 1 in the abstract dialect builder. The tests cover empty Op.in, negation, logical compositions, inferred arrays, nested attribu…
Out of Scope Changes check ✅ Passed The changes are limited to empty Op.in handling in the abstract SQL builder and related regression tests. No unrelated code or public declarations changed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Full details: Linked Issues check

Explanation

The implementation changes empty Op.in to the always-false predicate 0 = 1 in the abstract dialect builder. The tests cover empty Op.in, negation, logical compositions, inferred arrays, nested attributes, and update or destroy filters. Existing empty Op.notIn behavior remains unchanged. These changes satisfy issue #18306.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@SippieCup

SippieCup commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

THis probably needs the one gaurd against user footguns. I would say you should update this to throw when that happens.

@lazerg

lazerg commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

I chose the literal (0 = 1) over a throw on purpose. #18250 did the same for empty Op.notIn, and a maintainer approved that choice. #18286 proposes throwing on empty Op.or/Op.not, but that PR has been open three weeks with no maintainer decision yet. A throw here would break the current documented behavior, where {[Op.in]: []} matches no rows, and it would not match how Op.notIn works today. I think this needs a maintainer to decide the direction, since it affects more than this one PR. I can switch to a throw once someone confirms that's the way to go.

@SippieCup

Copy link
Copy Markdown
Contributor

I chose the literal (0 = 1) over a throw on purpose. #18250 did the same for empty Op.notIn, and a maintainer approved that choice. #18286 proposes throwing on empty Op.or/Op.not, but that PR has been open three weeks with no maintainer decision yet. A throw here would break the current documented behavior, where {[Op.in]: []} matches no rows, and it would not match how Op.notIn works today. I think this needs a maintainer to decide the direction, since it affects more than this one PR. I can switch to a throw once someone confirms that's the way to go.

I am a maintainer, but I will defer to @WikiRik

This is still an alpha so it is the time to make breaking changes like this. I also cannot, ever, see a reason why someone would want to intentionally use that operation. But there is always an XKCD dude out there. If there is, I still don't think we should support him for the 99.9999999% of people who would have just nuked their DB from a broken call.

@SippieCup SippieCup left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest having it throw when empty on bulk updates

Comment on lines 369 to 371
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this comment, I can see that it was updated to 0 = 1 but maybe this comment is not valid. I'll get back to it.

In general I do agree that we can throw more often in this project, if people really know what they are doing they can just use raw SQL. ORMs should to some degree prevent users from doing unexpected things.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That comment describes what the code should do, and until now the IN half of it was not true. IN (NULL) is UNKNOWN, not false, so NOT (...) stayed UNKNOWN. With 0 = 1 the code finally matches the comment, so I left the text as is. Happy to reword it if you want it to name the two literals.

@lazerg

lazerg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I looked at the write paths specifically, since that is the part you flagged.

updateQuery (query-generator.js:567) and bulkDeleteQuery (query-generator-typescript.ts:947) both call the same whereQuery that findAll calls. The WHERE text is identical in all three cases. FormatWhereOptions (query-generator-typescript.ts:133) carries only model, mainAlias and the bind options, so WhereSqlBuilder cannot tell which statement it is building. A throw limited to writes is not expressible at the Op.in layer without threading the statement type through every caller.

What this PR changes for writes:

  • Model.destroy({ where: { id: { [Op.in]: [] } } }) was DELETE FROM "Ts" WHERE "id" IN (NULL), now DELETE FROM "Ts" WHERE 0 = 1. Deletes nothing, before and after.
  • Model.update(v, { where: { [Op.not]: { id: { [Op.in]: [] } } } }) was UPDATE "Ts" SET ... WHERE NOT ("id" IN (NULL)), now ... WHERE NOT (0 = 1). Zero rows before, every row now.

The second one is your case and it is a real change. The old result was not safe either though. It silently did nothing while the caller had asked for every row.

A throw on empty Op.in also catches the common shape Model.destroy({ where: { id: ids } }) where ids came back empty. That deletes nothing today, which is correct, and people rely on it. It would also disagree with #18250, which you approved, where empty Op.notIn still emits 1 = 1.

There is already a write-only guard here: destroy at model.js:2731, and _optionsMustContainWhere at model.js:3405 which update and increment call. findAll has nothing equivalent. Both check that a where exists, and the error text points people at sql`1 = 1` when they do mean all rows. If you want a guard against a write whose WHERE matches everything, that is the layer that fits, and it would also cover where: {} and an empty Op.or, not just this one operator.

I am happy to write that guard, here or as a separate PR. I am also happy to switch this PR to a throw if @WikiRik wants that direction. In that case empty Op.notIn should throw too, so the pair stays symmetric.

@SippieCup

Copy link
Copy Markdown
Contributor

A throw on empty Op.in also catches the common shape Model.destroy({ where: { id: ids } }) where ids came back empty. That deletes nothing today, which is correct, and people rely on it. It would also disagree with #18250, which you approved, where empty Op.notIn still emits 1 = 1.

Yeah, I didn't realize that until now, it's a hard problem to tackle and maybe out of scope for this. But it would have to thread through every call to not stop people updating everything when they do want to. its more guarding against people updating with a where that has an empty notIn, as it is expressing that they do want to filter something, but there is something broken in their implementation, otherwise they wouldn't have added the superfluous where statement.

it does get harder too, because it needs to be when it is the only filter. I'm not saying I know the answer, just what I noticed when thinking about it more.

@WikiRik

WikiRik commented Aug 31, 2026

Copy link
Copy Markdown
Member

I'm working on a broader PR to fix this and similar issues. Should be opened later today

@lazerg

lazerg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I ran the empty notIn case you describe. It already wipes data on main today.

Built main at 704b36e, ran it against SQLite:

whereQuery notIn []  ->  "WHERE 1 = 1"
Model.update(v, { where: { id: { [Op.notIn]: [] } } })  ->  3 of 3 rows updated
Model.destroy({ where: { id: { [Op.notIn]: [] } } })    ->  3 of 3 rows deleted
Model.destroy({ where: { id: { [Op.in]: [] } } })       ->  0 rows deleted

It is older than #18250 too. On 7.0.0-alpha.48, which predates that PR, the empty notIn fragment was an empty string, so the statement ran with no WHERE at all. Same outcome, 3 of 3 rows deleted. So the case you found is pre-existing, and this PR does not touch that branch.

For an empty Op.in on a write, nothing is deleted or updated, before or after this PR. The one write that changes is the negated form { [Op.not]: { id: { [Op.in]: [] } } }, which goes from 0 rows to all rows. I checked both on the same build.

@WikiRik does your broader PR cover empty Op.in as well? If it does, I will close this one. If it does not, I can rebase on top of it once it lands.

@WikiRik

WikiRik commented Aug 31, 2026

Copy link
Copy Markdown
Member

Broader PR is #18324 (with #18323 as follow up as broader refactor later). Feel free to review, alongside the corresponding website PR

@lazerg

lazerg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the pointer. #18324 covers this more thoroughly (and folds in #18286 and #18306 too), closing this one in favor of it.

@lazerg lazerg closed this Aug 31, 2026
@SippieCup

Copy link
Copy Markdown
Contributor

@lazerg

Sorry you missed the credit on it, you were the one that brought it up. Thanks for the contribution and discussion 🥇

@suhailopensource

Copy link
Copy Markdown

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

4 participants