Skip to content

Comments

Fix XOR fallback to use parity semantics for chained Q() (odd-truth) [swev-id: django__django-16901]#529

Open
rowan-stein wants to merge 2 commits intodjango__django-16901from
fix/xor-parity-fallback
Open

Fix XOR fallback to use parity semantics for chained Q() (odd-truth) [swev-id: django__django-16901]#529
rowan-stein wants to merge 2 commits intodjango__django-16901from
fix/xor-parity-fallback

Conversation

@rowan-stein
Copy link
Collaborator

Summary

On DBs without native logical XOR (e.g., PostgreSQL), Django's WhereNode fallback for chained XOR (Q(...) ^ Q(...) ^ ...) incorrectly enforced 'exactly one' semantics. Correct XOR semantics are parity: true iff an odd number of operands are true. This PR updates the fallback to compute parity via modulo of the sum of CASE WHEN predicates.

Linked Issue: #528
Task reference: swev-id: django__django-16901

Change details

  • django/db/models/sql/where.py: Replace fallback transformation from
    (a OR b OR c ...) AND (a + b + c + ...) == 1
    to
    (a + b + c + ...) % 2 == 1
    using Django's expression system (CASE + modulo operator). This ensures associative/parity semantics for any number of XOR operands.
  • tests/xor_lookups/tests.py: Add coverage for chained XOR over duplicate conditions to ensure odd/even parity behavior: q ^ q -> empty, q ^ q ^ q -> one match, etc.

Reproduction steps

Using the existing xor_lookups test app (SQLite backend exercises the fallback path as supports_logical_xor=False for non-MySQL):

  1. Create and select a single target Number:

    target = Number.objects.create(num=3)
    q = Q(pk=target.pk)

  2. Evaluate chained XORs:

    Number.objects.filter(q).count() # 1
    Number.objects.filter(q ^ q).count() # 0
    Number.objects.filter(q ^ q ^ q).count() # Expected 1 (odd parity)
    Number.objects.filter(q ^ q ^ q ^ q).count()# Expected 0
    Number.objects.filter(q ^ q ^ q ^ q ^ q).count() # Expected 1

Observed failure (before this fix)

Running the new test against the previous fallback yields the following failure:

FAIL: test_xor_parity_multiple_arguments_with_duplicates (xor_lookups.tests.XorLookupsTests.test_xor_parity_multiple_arguments_with_duplicates)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/workspace/django/tests/xor_lookups/tests.py", line 76, in test_xor_parity_multiple_arguments_with_duplicates
    self.assertCountEqual(Number.objects.filter(q ^ q ^ q), [target])
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Element counts were not equal:
First has 0, Second has 1:  <Number: 3>

This demonstrates the incorrect 'exactly one' fallback for 3 operands; parity should return one match.

After fix (local validation)

$ python tests/runtests.py xor_lookups --parallel 1 -v 2
...
Ran 7 tests in 0.007s
OK

Notes

  • This change only affects the fallback path used when connection.features.supports_logical_xor is False.
  • MySQL (supports_logical_xor=True) continues to use native XOR handling unchanged.
  • No CI was run as per task constraints; local tests focused on the xor_lookups app.

…sum, not exactly-one. [swev-id: django__django-16901]
@rowan-stein rowan-stein requested a review from a team December 24, 2025 17:45
Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

Parity fallback looks good. Thanks for adding regression coverage.

@rowan-stein
Copy link
Collaborator Author

Validation summary:

  • Ran targeted tests using tox: tox -e py3 -- xor_lookups
  • Result: 7 tests executed; all passed (OK)
  • Scope reviewed: django/db/models/sql/where.py changes and added regression tests in tests/xor_lookups/tests.py

PR remains unmerged as requested.

@rowan-stein rowan-stein changed the base branch from django__django-16901 to main January 11, 2026 20:20
@rowan-stein rowan-stein changed the base branch from main to django__django-16901 January 12, 2026 20:13
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