Saturday, July 29, 2017

Maintain SQL operator precedence when constructing Q objects in Django

Leave a Comment

I am trying to construct a complex query in Django by adding Q objects based on a list of user inputs:

from django.db.models import Q  q = Q()  expressions = [     {'operator': 'or', 'field': 'f1', 'value': 1},     {'operator': 'or', 'field': 'f2', 'value': 2},     {'operator': 'and', 'field': 'f3', 'value': 3},     {'operator': 'or', 'field': 'f4', 'value': 4}, ]  for item in expressions:     if item['operator'] == 'and':        q.add(Q(**{item['field']:item['value']}), Q.AND )      elif item['operator'] == 'or':        q.add(Q(**{item['field']:item['value']}), Q.OR ) 

Based on this I am expecting to get a query with the following where condition:

f1 = 1 or f2 = 2 and f3 = 3 or f4 = 4 

which, based on the default operator precedence will be executed as

f1 = 1 or (f2 = 2 and f3 = 3) or f4 = 4 

however, I am getting the following query:

((f1 = 1 or f2 = 2) and f3 = 3) or f4 = 4 

It looks like the Q() object forces the conditions to be evaluated in the order they were added.

Is there a way that I can keep the default SQL precedence? Basically I want to tell the ORM not to add parenthesis in my conditions.

2 Answers

Answers 1

Since SQL precedence is the same as Python precedence when it comes to AND, OR, and NOT, you should be able to achieve what you want by letting Python parse the expression.

One quick-and-dirty way to do it would be to construct the expression as a string and let Python eval() it.

from functools import reduce  ops = ["&" if item["operator"] == "and" else "|" for item in expressions] qs = [Q(**{item["field"]: item["value"]}) for item in expressions]  q_string = reduce(     lambda acc, index: acc + " {op} qs[{index}]".format(op=ops[index], index=index),     range(len(expressions)),     "Q()" ) # equals "Q() | qs[0] | qs[1] & qs[2] | qs[3]"  q_expression = eval(q_string) 

Python will parse this expression according to its own operator precedence, and the resulting SQL clause will match your expectations:

f1 = 1 or (f2 = 2 and f3 = 3) or f4 = 4 

Of course, using eval() with user-supplied strings would be a major security risk, so here I'm constructing the Q objects separately (in the same way you did) and just referring to them in the eval string. So I don't think there are any additional security implications of using eval() here.

Answers 2

Seems that you are not the only one with a similar problem. (edited due to @hynekcer 's comment)

A workaround would be to "parse" the incoming parameters into a list of Q() objects and create your query from that list:

from operator import or_ from django.db.models import Q  query_list = []  for item in expressions:     if item['operator'] == 'and' and query_list:         # query_list must have at least one item for this to work         query_list[-1] = query_list[-1] & Q(**{item['field']:item['value']})     elif item['operator'] == 'or':         query_list.append(Q(**{item['field']:item['value']}))     else:         # If you find yourself here, something went wrong... 

Now the query_list contains the individual queries as Q() or the Q() AND Q() relationships between them.
The list can be reduce()d with the or_ operator to create the remaining OR relationships and used in a filter(), get() etc. query:

MyModel.objects.filter(reduce(or_, query_list)) 

PS: Although Kevin's answer is clever, using eval() is considered a bad practice and should be used only if completely necessary.

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment