Skip to content

Commit

Permalink
add support for 'startswith' and 'endswith' string comparison, fixes #47
Browse files Browse the repository at this point in the history
  • Loading branch information
stebunovd committed Nov 20, 2021
1 parent 04a6677 commit d052abd
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 10 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
0.16.0
------

* added support for new string-specific comparison operators: ``startswith``,
``not startswith``, ``endswith``, ``not endswith``;

0.15.4
------

Expand Down
10 changes: 8 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,14 @@ parenthesis. DjangoQL is case-sensitive.
``published > False`` will cause an error;
- logical operators: ``and``, ``or``;
- comparison operators: ``=``, ``!=``, ``<``, ``<=``, ``>``, ``>=``
- work as you expect. ``~`` and ``!~`` - test whether or not a string contains
a substring (translated into ``__icontains``);
- work as you expect;
- string-specific comparison operators: ``startswith``, ``not startswith``,
``endswith``, ``not endswith`` - work as you expect. Test whether or not a
string contains a substring: ``~`` and ``!~`` (translated into
``__icontains`` under the hood).
Example: ``name endswith "peace" or author.last_name ~ "tolstoy"``;
- date-specific comparison operators, compare by date part: ``~`` and ``!~``.
Example: ``date_published ~ "2021-11"`` - find books published in Nov, 2021;
- test a value vs. list: ``in``, ``not in``. Example:
``pk in (2, 3)``.

Expand Down
10 changes: 10 additions & 0 deletions djangoql/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def find_column(self, t):
'LESS_EQUAL',
'CONTAINS',
'NOT_CONTAINS',
'STARTSWITH',
'ENDSWITH',
]

t_COMMA = ','
Expand Down Expand Up @@ -137,6 +139,14 @@ def t_NOT(self, t):
def t_IN(self, t):
return t

@TOKEN('startswith' + not_followed_by_name)
def t_STARTSWITH(self, t):
return t

@TOKEN('endswith' + not_followed_by_name)
def t_ENDSWITH(self, t):
return t

@TOKEN('True' + not_followed_by_name)
def t_TRUE(self, t):
return t
Expand Down
17 changes: 12 additions & 5 deletions djangoql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def p_comparison_string(self, p):
"""
comparison_string : comparison_equality
| comparison_greater_less
| comparison_contains
| comparison_string_specific
"""
p[0] = p[1]

Expand All @@ -112,12 +112,19 @@ def p_comparison_greater_less(self, p):
"""
p[0] = Comparison(operator=p[1])

def p_comparison_contains(self, p):
def p_comparison_string_specific(self, p):
"""
comparison_contains : CONTAINS
| NOT_CONTAINS
comparison_string_specific : CONTAINS
| NOT_CONTAINS
| STARTSWITH
| NOT STARTSWITH
| ENDSWITH
| NOT ENDSWITH
"""
p[0] = Comparison(operator=p[1])
if len(p) == 2:
p[0] = Comparison(operator=p[1])
else:
p[0] = Comparison(operator='%s %s' % (p[1], p[2]))

def p_comparison_in_list(self, p):
"""
Expand Down
4 changes: 4 additions & 0 deletions djangoql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,17 @@ def get_operator(self, operator):
'<=': '__lte',
'~': '__icontains',
'in': '__in',
'startswith': '__istartswith',
'endswith': '__iendswith',
}.get(operator)
if op is not None:
return op, False
op = {
'!=': '',
'!~': '__icontains',
'not in': '__in',
'not startswith': '__istartswith',
'not endswith': '__iendswith',
}[operator]
return op, True

Expand Down
25 changes: 25 additions & 0 deletions djangoql/templates/djangoql/syntax_help.html
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,26 @@ <h2 id="comparison-operators">Comparison operators</h2>
<td>does not contain a substring</td>
<td>username !~ "test"</td>
</tr>
<tr>
<td>startswith</td>
<td>starts with a substring</td>
<td>last_name startswith "do"</td>
</tr>
<tr>
<td>not startswith</td>
<td>does not start with a substring</td>
<td>last_name not startswith "do"</td>
</tr>
<tr>
<td>endswith</td>
<td>ends with a substring</td>
<td>last_name endswith "oe"</td>
</tr>
<tr>
<td>not endswith</td>
<td>does not end with a substring</td>
<td>last_name not endswith "oe"</td>
</tr>
<tr>
<td>&gt;</td>
<td>greater</td>
Expand Down Expand Up @@ -282,6 +302,11 @@ <h2 id="comparison-operators">Comparison operators</h2>
string and date/datetime fields. A date/datetime field will be handled
as a string one (ex., <code>payment_date ~ "2020-12-01"</code>)
</li>
<li>
<code>startswith</code>, <code>not startswith</code>,
<code>endswith</code>, and <code>not endswith</code> can be applied
to string fields only;
</li>
<li>
<code>True</code>, <code>False</code> and <code>None</code> values can
be combined only with <code>=</code> and <code>!=</code>;
Expand Down
5 changes: 3 additions & 2 deletions test_project/core/tests/test_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ def test_entity_props(self):
pass

def test_reserved_words(self):
reserved = ('True', 'False', 'None', 'or', 'and', 'in')
reserved = ('True', 'False', 'None', 'or', 'and', 'in', 'not',
'startswith', 'endswith')
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
for word in ('True_story', 'not_None', 'inspect'):
for word in ('True_story', 'not_None', 'inspect', 'startswith_in'):
self.assert_output(self.lexer.input(word), [('NAME', word)])

def test_int(self):
Expand Down
38 changes: 37 additions & 1 deletion test_project/core/tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ def test_comparisons(self):
self.parser.parse('job.best.title > "none"'),
)

def test_string_comparisons(self):
self.assertEqual(
Expression(Name('name'), Comparison('~'), Const('gav')),
self.parser.parse('name ~ "gav"'),
)
self.assertEqual(
Expression(Name('name'), Comparison('!~'), Const('gav')),
self.parser.parse('name !~ "gav"'),
)
self.assertEqual(
Expression(Name('name'), Comparison('startswith'), Const('gav')),
self.parser.parse('name startswith "gav"'),
)
self.assertEqual(
Expression(Name('name'), Comparison('not startswith'), Const('rr')),
self.parser.parse('name not startswith "rr"'),
)
self.assertEqual(
Expression(Name('name'), Comparison('endswith'), Const('gav')),
self.parser.parse('name endswith "gav"'),
)
self.assertEqual(
Expression(Name('name'), Comparison('not endswith'), Const('gav')),
self.parser.parse('name not endswith "gav"'),
)

def test_escaped_chars(self):
self.assertEqual(
Expression(Name('name'), Comparison('~'),
Expand Down Expand Up @@ -91,7 +117,17 @@ def test_logical(self):
)

def test_invalid_comparison(self):
for expr in ('foo > None', 'b <= True', 'c in False', '1 = 1', 'a > b'):
invalid_comparisons = (
'foo > None',
'b <= True',
'c in False',
'1 = 1',
'a > b',
'lol ~ None',
'gav endswith 1',
'nor not startswith False',
)
for expr in invalid_comparisons:
self.assertRaises(DjangoQLParserError, self.parser.parse, expr)

def test_entity_props(self):
Expand Down
20 changes: 20 additions & 0 deletions test_project/core/tests/test_queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ def test_datetime_like_query(self):
where_clause,
)

def test_advanced_string_comparison(self):
qs = Book.objects.djangoql('name ~ "war"')
where_clause = str(qs.query).split('WHERE')[1].strip()
self.assertEqual(
'"core_book"."name" LIKE %war% ESCAPE \'\\\'',
where_clause,
)
qs = Book.objects.djangoql('name startswith "war"')
where_clause = str(qs.query).split('WHERE')[1].strip()
self.assertEqual(
'"core_book"."name" LIKE war% ESCAPE \'\\\'',
where_clause,
)
qs = Book.objects.djangoql('name not endswith "peace"')
where_clause = str(qs.query).split('WHERE')[1].strip()
self.assertEqual(
'NOT ("core_book"."name" LIKE %peace ESCAPE \'\\\')',
where_clause,
)

def test_apply_search(self):
qs = User.objects.all()
try:
Expand Down

0 comments on commit d052abd

Please sign in to comment.