Search code examples
pythondjangodjango-q

Building Django Q() objects from other Q() objects, but with relation crossing context


I commonly find myself writing the same criteria in my Django application(s) more than once. I'll usually encapsulate it in a function that returns a Django Q() object, so that I can maintain the criteria in just one place.

I will do something like this in my code:

def CurrentAgentAgreementCriteria(useraccountid):
    '''Returns Q that finds agent agreements that gives the useraccountid account current delegated permissions.'''
    AgentAccountMatch = Q(agent__account__id=useraccountid)
    StartBeforeNow = Q(start__lte=timezone.now())
    EndAfterNow = Q(end__gte=timezone.now())
    NoEnd = Q(end=None)
    # Now put the criteria together
    AgentAgreementCriteria = AgentAccountMatch & StartBeforeNow & (NoEnd | EndAfterNow)
    return AgentAgreementCriteria

This makes it so that I don't have to think through the DB model more than once, and I can combine the return values from these functions to build more complex criterion. That works well so far, and has saved me time already when the DB model changes.

Something I have realized as I start to combine the criterion from these functions that is that a Q() object is inherently tied to the type of object .filter() is being called on. That is what I would expect.

I occasionally find myself wanting to use a Q() object from one of my functions to construct another Q object that is designed to filter a different, but related, model's instances.

Let's use a simple/contrived example to show what I mean. (It's simple enough that normally this would not be worth the overhead, but remember that I'm using a simple example here to illustrate what is more complicated in my app.)

Say I have a function that returns a Q() object that finds all Django users, whose username starts with an 'a':

def UsernameStartsWithAaccount():
    return Q(username__startswith='a')

Say that I have a related model that is a user profile with settings including whether they want emails from us:

class UserProfile(models.Model):
    account = models.OneToOneField(User, unique=True, related_name='azendalesappprofile')
    emailMe = models.BooleanField(default=False)

Say I want to find all UserProfiles which have a username starting with 'a' AND want use to send them some email newsletter. I can easily write a Q() object for the latter:

wantsEmails = Q(emailMe=True)

but find myself wanting to something to do something like this for the former:

startsWithA = Q(account=UsernameStartsWithAaccount())
# And then
UserProfile.objects.filter(startsWithA & wantsEmails)

Unfortunately, that doesn't work (it generates invalid PSQL syntax when I tried it).

To put it another way, I'm looking for a syntax along the lines of Q(account=Q(id=9)) that would return the same results as Q(account__id=9).

So, a few questions arise from this:

  1. Is there a syntax with Django Q() objects that allows you to add "context" to them to allow them to cross relational boundaries from the model you are running .filter() on?
  2. If not, is this logically possible? (Since I can write Q(account__id=9) when I want to do something like Q(account=Q(id=9)) it seems like it would).

Solution

  • Maybe someone suggests something better, but I ended up passing the context manually to such functions. I don't think there is an easy solution, as you might need to call a whole chain of related tables to get to your field, like table1__table2__table3__profile__user__username, how would you guess that? User table could be linked to table2 too, but you don't need it in this case, so I think you can't avoid setting the path manually.

    Also you can pass a dictionary to Q() and a list or a dictionary to filter() functions which is much easier to work with than using keyword parameters and applying &.

    def UsernameStartsWithAaccount(context=''):
        field = 'username__startswith'
        if context:
            field = context + '__' + field
        return Q(**{field: 'a'})
    

    Then if you simply need to AND your conditions you can combine them into a list and pass to filter:

    UserProfile.objects.filter(*[startsWithA, wantsEmails])