fix(core): make empty Op.in compose correctly under Op.not - #18307
fix(core): make empty Op.in compose correctly under Op.not#18307lazerg wants to merge 3 commits into
Conversation
|
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. |
|
I have no issues with @lazerg raising a PR. |
|
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 (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughEmpty ChangesEmpty
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to 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: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Linked Issues checkExplanation 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 Full details: Docstring CoverageExplanation 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)
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 |
|
THis probably needs the one gaurd against user footguns. I would say you should update this to throw when that happens. |
|
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
left a comment
There was a problem hiding this comment.
I suggest having it throw when empty on bulk updates
| // 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
I looked at the write paths specifically, since that is the part you flagged.
What this PR changes for writes:
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 There is already a write-only guard here: 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 |
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. |
|
I'm working on a broader PR to fix this and similar issues. Should be opened later today |
|
I ran the empty Built It is older than #18250 too. On 7.0.0-alpha.48, which predates that PR, the empty For an empty @WikiRik does your broader PR cover empty |
|
Broader PR is #18324 (with #18323 as follow up as broader refactor later). Feel free to review, alongside the corresponding website PR |
|
Sorry you missed the credit on it, you were the one that brought it up. Thanks for the contribution and discussion 🥇 |

Pull Request Checklist
Description of Changes
Closes #18306
An empty
Op.inwas emitted asIN (NULL), which SQL evaluates to UNKNOWN rather than FALSE. At the top level of aWHEREthat is indistinguishable from FALSE, so the bare case looked fine, butNOT (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 asOp.in.The empty
Op.infragment is now0 = 1, mirroring the1 = 1that #18250 introduced for emptyOp.notIn, so negation behaves as expected. TheOp.or/Op.not/Op.andcomposition cases already covered forOp.notInare now covered forOp.inas well.Note: the emitted SQL for a bare
{ [Op.in]: [] }changes fromIN (NULL)to0 = 1. The two are equivalent inside a top-levelWHERE, 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.notrather than emitting a truth value: I went with the literal here because an emptyOp.inhas one unambiguous meaning, because #18250 settled the siblingOp.notIncase 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
INcondition to correctly return no matches instead of relying on SQLNULLbehavior.INconditions work correctly when combined withOR,AND, andNOTfilters.