fix(expressions): make ~~Q cancel out instead of staying negated - #2291
Merged
Merged
Conversation
Q.__invert__ built a fresh Q and toggled its negation flag, so the flag of the Q being inverted was lost: ~~Q(name="x") resolved to NOT name='x', and .exclude(~Q(name="x")) returned every row except the ones the caller asked to keep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
waketzheng
approved these changes
Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Inverting a
Qthat is already negated did not cancel the negation.Q.__invert__built a newQfrom the children and filters, then callednegate()on it. The new object always started with_is_negated = False, so the flag of theQbeing inverted was lost and~~qcame out the same as~q:The fix sets the new flag to
not self._is_negated.Motivation and Context
exclude()inverts eachQit receives, soexclude(~Q(...))and any code that inverts a stored condition (cond = ~Q(deleted=True), then later~cond, orWhen(~Q(...), negate=True)) hit this. Those queries returned exactly the rows the caller meant to drop and left out the ones they asked for, with no error. With tournaments"0"and"1",Tournament.exclude(~Q(name="1"))returned["0"]when it should have returned["1"].I did not find an existing issue or PR for this.
How Has This Been Tested?
tests/test_q.py::test_q_double_negationchecks that~~qis not negated,~~~qis negated, and the originalqis not changed.tests/test_filtering.py::test_filter_double_negationrunsfilter(~~Q(...))andexclude(~Q(...))against the database.develop(1979f27) both fail:assert True is False, and the DB test returns['0']instead of['1']. With the fix both pass.TORTOISE_TEST_DB=sqlite://:memory: PYTHONDEVMODE=1 pytest -m "not benchmark" tests): 1958 passed, 148 skipped, 2 xfailed. Base is 1956 passed, and the difference is the two new tests.ruff format --checkandruff checkare clean.mypy tortoise testsreports only the unusedtype: ignoreintortoise/fields/data.py:558, which is also ondevelop.Checklist:
This bug was found and the fix and tests were written by an AI agent (breken-ai) and checked against the test suite before opening this PR.