Skip to content

Added ORDER BY to query language #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ also an option that controls if that checkbox is enabled by default -
If you don't want two search modes, simply remove ``search_fields`` from your
ModelAdmin class.


Language reference
------------------

Expand Down Expand Up @@ -145,6 +146,11 @@ parenthesis. DjangoQL is case-sensitive.
Example: ``date_published ~ "2021-11"`` - find books published in Nov, 2021;
- test a value vs. list: ``in``, ``not in``. Example:
``pk in (2, 3)``.
- results can be sorted with a SQL-style ``order by`` clause at the end of
the query. Sorting direction is either ``asc`` or ``desc`` (default ``asc``).
Example:
``name endswith "peace" order by name desc, author.last_name``
- an ``order by`` clause alone is also a valid query.


DjangoQL Schema
Expand Down
6 changes: 6 additions & 0 deletions djangoql/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ def get_filters_params(self, *args, **kwargs):
del params[DJANGOQL_SEARCH_MARKER]
return params

def get_ordering(self, request, queryset):
if queryset.ordered:
return queryset.query.order_by
else:
return super().get_ordering(request, queryset)


class DjangoQLSearchMixin(object):
search_fields = ('_djangoql',) # just a stub to have search input displayed
Expand Down
17 changes: 17 additions & 0 deletions djangoql/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,20 @@ class Logical(Operator):

class Comparison(Operator):
pass


class OrderingKey(Node):
def __init__(self, name, direction):
self.name = name
self.direction = direction


class Ordering(Node):
def __init__(self, keys):
self.fields = keys


class Query(Node):
def __init__(self, expression, ordering):
self.expression = expression
self.ordering = ordering
20 changes: 20 additions & 0 deletions djangoql/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ def find_column(self, t):
'NOT_CONTAINS',
'STARTSWITH',
'ENDSWITH',
'ORDER',
'BY',
'ASC',
'DESC',
]

t_COMMA = ','
Expand Down Expand Up @@ -159,6 +163,22 @@ def t_FALSE(self, t):
def t_NONE(self, t):
return t

@TOKEN('order' + not_followed_by_name)
def t_ORDER(self, t):
return t

@TOKEN('by' + not_followed_by_name)
def t_BY(self, t):
return t

@TOKEN('asc' + not_followed_by_name)
def t_ASC(self, t):
return t

@TOKEN('desc' + not_followed_by_name)
def t_DESC(self, t):
return t

def t_error(self, t):
raise DjangoQLLexerError(
message='Illegal character %s' % repr(t.value[0]),
Expand Down
54 changes: 52 additions & 2 deletions djangoql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import ply.yacc as yacc

from .ast import Comparison, Const, Expression, List, Logical, Name
from .ast import Comparison, Const, Expression, List, Logical, Name, Ordering, \
OrderingKey, Query
from .compat import binary_type, text_type
from .exceptions import DjangoQLParserError
from .lexer import DjangoQLLexer
Expand Down Expand Up @@ -44,7 +45,25 @@ def parse(self, input=None, lexer=None, **kwargs): # noqa: A002
lexer = lexer or self.default_lexer
return self.yacc.parse(input=input, lexer=lexer, **kwargs)

start = 'expression'
start = 'query'

def p_query(self, p):
"""
query : expression
"""
p[0] = Query(expression=p[1], ordering=None)

def p_query_only_ordering(self, p):
"""
query : ordering
"""
p[0] = Query(expression=None, ordering=p[1])

def p_ordered_query(self, p):
"""
query : expression ordering
"""
p[0] = Query(expression=p[1], ordering=p[2])

def p_expression_parens(self, p):
"""
Expand Down Expand Up @@ -136,6 +155,37 @@ def p_comparison_in_list(self, p):
else:
p[0] = Comparison(operator='%s %s' % (p[1], p[2]))

def p_undirected_ordering_key(self, p):
"""
ordering_key : name
"""
p[0] = OrderingKey(name=p[1], direction=None)

def p_directed_ordering_key(self, p):
"""
ordering_key : name ASC
| name DESC
"""
p[0] = OrderingKey(name=p[1], direction=p[2])

def p_ordering_key_list_single(self, p):
"""
ordering_key_list : ordering_key
"""
p[0] = [p[1]]

def p_ordering_key_list(self, p):
"""
ordering_key_list : ordering_key_list COMMA ordering_key
"""
p[0] = p[1] + [p[3]]

def p_ordering(self, p):
"""
ordering : ORDER BY ordering_key_list
"""
p[0] = Ordering(keys=p[3])

def p_const_value(self, p):
"""
const_value : number
Expand Down
100 changes: 56 additions & 44 deletions djangoql/parsetab.py

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion djangoql/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ def build_filter(expr, schema_instance):
)


def build_order_by(expr):
ordering = expr.ordering
keys = []
for field in ordering.fields if ordering else []:
key = '__'.join(field.name.parts)
if field.direction == 'desc':
key = f'-{key}'
keys.append(key)
return keys


def apply_search(queryset, search, schema=None):
"""
Applies search written in DjangoQL mini-language to given queryset
Expand All @@ -37,7 +48,11 @@ def apply_search(queryset, search, schema=None):
schema = schema or DjangoQLSchema
schema_instance = schema(queryset.model)
schema_instance.validate(ast)
return queryset.filter(build_filter(ast, schema_instance))
if ast.expression:
queryset = queryset.filter(
build_filter(ast.expression, schema_instance),
)
return queryset.order_by(*build_order_by(ast))


class DjangoQLQuerySet(QuerySet):
Expand Down
22 changes: 17 additions & 5 deletions djangoql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from django.db.models.fields.related import ForeignObjectRel
from django.utils.timezone import get_current_timezone

from .ast import Comparison, Const, List, Logical, Name, Node
from .ast import Comparison, Const, Expression, List, Logical, Name, Ordering, \
Query
from .compat import text_type
from .exceptions import DjangoQLSchemaError

Expand Down Expand Up @@ -200,7 +201,6 @@ def get_options(self, search):
lookup['%s__icontains' % self.name] = search
return self.model.objects\
.filter(**lookup)\
.order_by(self.name)\
.values_list(self.name, flat=True)\
.distinct()

Expand Down Expand Up @@ -468,10 +468,18 @@ def validate(self, node):
"""
Validate DjangoQL AST tree vs. current schema
"""
assert isinstance(node, Node)
assert isinstance(node, Query)
if node.expression:
self.validate_expression(node.expression)
if node.ordering:
self.validate_ordering(node.ordering)

def validate_expression(self, node):
assert isinstance(node, Expression)

if isinstance(node.operator, Logical):
self.validate(node.left)
self.validate(node.right)
self.validate_expression(node.left)
self.validate_expression(node.right)
return
assert isinstance(node.left, Name)
assert isinstance(node.operator, Comparison)
Expand All @@ -490,3 +498,7 @@ def validate(self, node):
values = value if isinstance(node.right, List) else [value]
for v in values:
field.validate(v)

def validate_ordering(self, ordering: Ordering):
for field in ordering.fields:
self.resolve_name(field.name)
2 changes: 1 addition & 1 deletion djangoql/static/djangoql/css/completion.css.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion djangoql/static/djangoql/js/completion.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion djangoql/static/djangoql/js/completion.js.map

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion test_project/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def get_options(self, search):
return Book.objects\
.filter(author__username__icontains=search)\
.values_list('author__username', flat=True)\
.order_by('author__username')\
.distinct()


Expand Down
2 changes: 1 addition & 1 deletion test_project/core/tests/test_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def test_entity_props(self):

def test_reserved_words(self):
reserved = ('True', 'False', 'None', 'or', 'and', 'in', 'not',
'startswith', 'endswith')
'startswith', 'endswith', 'order', 'by', 'asc', 'desc')
for word in reserved:
self.assert_output(self.lexer.input(word), [(word.upper(), word)])
# A word made of reserved words should be treated as a name
Expand Down
Loading