Perform a logical exclusive OR on a Django Q object
Asked Answered
S

2

12

I would like to perform a logical exclusive OR (XOR) on django.db.models.Q objects, using operator module to limit the choices of a model field to a subset of foreignkey. I am doing this in Django 1.4.3 along with Python 2.7.2. I had something like this:

import operator

from django.conf import settings
from django.db import models
from django.db.models import Q
from django.contrib.auth.models import User, Group

def query_group_lkup(group_name):
    return Q(user__user__groups__name__exact=group_name)

class Book(models.Model):
    author = models.ForeignKey(
                 User,
                 verbose_name=_("Author"),
                 null=False,
                 default='',
                 related_name="%(app_label)s_%(class)s_author",
                 # This would have provide an exclusive OR on the selected group name for User
                 limit_choices_to=reduce(
                     operator.xor,
                     map(query_group_lkup, getattr(settings, 'AUTHORIZED_AUTHORS', ''))
                 )

AUTHORIZED_AUTHORS is a list of existing group names.

But this did not work, because Q objects do not support ^ operator (only | and & operators from the docs). The message from the stacktrace was (partly) the following:

File "/home/moi/.virtualenvs/venv/lib/python2.7/site-packages/django/db/models/loading.py", line 64, in _populate
    self.load_app(app_name, True)
  File "/home/moi/.virtualenvs/venv/lib/python2.7/site-packages/django/db/models/loading.py", line 88, in load_app
    models = import_module('.models', app_name)
  File "/home/moi/.virtualenvs/venv/lib/python2.7/site-packages/django/utils/importlib.py", line 35, in import_module
    __import__(name)
  File "/opt/dvpt/toto/apps/book/models.py", line 42, in <module>
    class Book(models.Model):
  File "/opt/dvpt/toto/apps/book/models.py", line 100, in Book
    map(query_group_lkup, getattr(settings, 'AUTHORIZED_AUTHORS', ''))
TypeError: unsupported operand type(s) for ^: 'Q' and 'Q'

Therefore, inspired by this answer I attempted to implement an XOR for my specific lookup. It is not really flexible as the lookup is hardcoded (I would need to use kwargs in the arguments of query_xor for example...). I ended up doing something like this:

from django.conf import settings
from django.db import models
from django.db.models import Q
from django.db.models.query import EmptyQuerySet
from django.contrib.auth.models import User, Group

def query_xor_group(names_group):
    """Get a XOR of the queries that match the group names in names_group."""

    if not len(names_group):
        return EmptyQuerySet()
    elif len(names_group) == 1:
        return Q(user__user__groups__name__exact=names_group[0])

    q_chain_or = Q(user__user__groups__name__exact=names_group[0])
    q_chain_and = Q(user__user__groups__name__exact=names_group[0])

    for name in names_group[1:]:
        query = Q(user__user__groups__name__exact=name)
        q_chain_or |= query
        q_chain_and &= query

    return q_chain_or & ~q_chain_and

class Book(models.Model):
    author = models.ForeignKey(
                 User,
                 verbose_name=_("author"),
                 null=False,
                 default='',
                 related_name="%(app_label)s_%(class)s_author",
                 # This provides an exclusive OR on the SELECT group name for User
                 limit_choices_to=query_xor_group(getattr(settings, 'AUTHORIZED_AUTHORS', ''))
                 )

It works as I want but I seems to me rather not pythonic (especially the query_xor_group method). Would there be a better (more direct way) of doing this?

Basically, my question can be stripped of the limit_choices_to part and be summarized as:

How can I make a bitwise exclusive OR on a set of django.db.models.Q objects in a Djangonic way?

Scuppernong answered 5/2, 2013 at 15:38 Comment(1)
For future readers: It looks like XOR is going to be implemented in Django 4.1: docs.djangoproject.com/en/4.1/ref/models/querysets/#xor and the code: github.com/django/django/commit/…Slant
L
14

You could add an __xor__() method to Q that uses and/or/not to do the XOR logic.

from django.db.models import Q

class QQ:
    def __xor__(self, other):    
        not_self = self.clone()
        not_other = other.clone()
        not_self.negate()
        not_other.negate()

        x = self & not_other
        y = not_self & other

        return x | y

Q.__bases__ += (QQ, )

After doing this I was able to Q(...) ^ Q(...) in a filter() call.

Foobar.objects.filter(Q(blah=1) ^ Q(bar=2)) 

Which means the original attempt no longer throws an unsupported operand exception.

limit_choices_to=reduce(
                     operator.xor,
                     map(query_group_lkup, getattr(settings, 'AUTHORIZED_AUTHORS', ''))
                 )

Tested in Django 1.6.1 on Python 2.7.5

Ligni answered 19/1, 2014 at 18:19 Comment(5)
@Dan R: Could you please elaborate on your solution with full example?Lindblad
Adding that class just makes the askers first attempt work but I also included how you'd use it in any filter call.Ligni
While this certainly works, I think it's best to avoid monkeypatching like this in python, since it is now quite hard to track when this patch is applied (although, I suppose you could just see if QQ is in Q.mro()). IMO, it would be better to subclass Q with QQ and use that instead; you can use the subclass globally if that makes things more consistent.Alphonsa
Also, if you haven't already, you should definitely initiate a pull request for this into django.Alphonsa
@Alphonsa this has been included in Django recentlyIngres
E
14

Django 4.1 added support for XOR:

Q objects and querysets can now be combined using ^ as the exclusive or (XOR) operator. XOR is natively supported on MariaDB and MySQL. For databases that do not support XOR, the query will be converted to an equivalent using AND, OR, and NOT.

It means you can now write Foobar.objects.filter(Q(blah=1) ^ Q(bar=2)) without monkey patching.
It was worth waiting nine years, wasn't it?

Embrasure answered 26/10, 2022 at 15:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.