Skip to content

fix(expressions): make ~~Q cancel out instead of staying negated - #2291

Merged
waketzheng merged 1 commit into
tortoise:developfrom
breken-ai:fix/q-double-negation
Oct 1, 2026
Merged

waketzheng merged 1 commit into
tortoise:developfrom
breken-ai:fix/q-double-negation

Conversation

@breken-ai

Copy link
Copy Markdown
Contributor

Description

Inverting a Q that is already negated did not cancel the negation. Q.__invert__ built a new Q from the children and filters, then called negate() on it. The new object always started with _is_negated = False, so the flag of the Q being inverted was lost and ~~q came out the same as ~q:

Tournament.filter(~~Q(name="1"))        # before: WHERE NOT "name"='1'   after: WHERE "name"='1'
Tournament.exclude(~Q(name="1"))        # before: WHERE NOT "name"='1'   after: WHERE "name"='1'

The fix sets the new flag to not self._is_negated.

Motivation and Context

exclude() inverts each Q it receives, so exclude(~Q(...)) and any code that inverts a stored condition (cond = ~Q(deleted=True), then later ~cond, or When(~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_negation checks that ~~q is not negated, ~~~q is negated, and the original q is not changed.
  • tests/test_filtering.py::test_filter_double_negation runs filter(~~Q(...)) and exclude(~Q(...)) against the database.
  • On develop (1979f27) both fail: assert True is False, and the DB test returns ['0'] instead of ['1']. With the fix both pass.
  • Full SQLite suite (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 --check and ruff check are clean. mypy tortoise tests reports only the unused type: ignore in tortoise/fields/data.py:558, which is also on develop.
  • Tested on Python 3.13, macOS, SQLite.

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added the changelog accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

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.

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>
@codspeed

codspeed Bot commented Oct 1, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks


Comparing breken-ai:fix/q-double-negation (f8a0b95) with develop (1979f27)

Open in CodSpeed

@waketzheng
waketzheng merged commit 960b585 into tortoise:develop Oct 1, 2026
25 checks passed
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.

2 participants