Skip to content

Commit 7b66c9a

Browse files
author
Bodyhealer
committed
add full support for regular expressions
1 parent 6233b13 commit 7b66c9a

File tree

9 files changed

+41
-12
lines changed

9 files changed

+41
-12
lines changed

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ parenthesis. DjangoQL is case-sensitive.
133133
- comparison operators: ``=``, ``!=``, ``<``, ``<=``, ``>``, ``>=``
134134
- work as you expect. ``~`` and ``!~`` - test whether or not a string contains
135135
a substring (translated into ``__icontains``);
136+
- ``regex`` - find based on regex (translated into ``__iregex``);
136137
- test a value vs. list: ``in``, ``not in``. Example:
137138
``pk in (2, 3)``.
138139

djangoql/lexer.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def find_column(self, t):
5353

5454
re_escaped_char = r'\\[\"\\/bfnrt]'
5555
re_escaped_unicode = r'\\u[0-9A-Fa-f]{4}'
56-
re_string_char = r'[^\"\\' + re_line_terminators + u']'
56+
re_string_char = r'[^\"' + re_line_terminators + u']'
5757

5858
re_int_value = r'(-?0|-?[1-9][0-9]*)'
5959
re_fraction_part = r'\.[0-9]+'
@@ -82,6 +82,7 @@ def find_column(self, t):
8282
'LESS_EQUAL',
8383
'CONTAINS',
8484
'NOT_CONTAINS',
85+
'REGEX',
8586
]
8687

8788
t_COMMA = ','
@@ -135,6 +136,10 @@ def t_NOT(self, t):
135136
def t_IN(self, t):
136137
return t
137138

139+
@TOKEN('regex' + not_followed_by_name)
140+
def t_REGEX(self, t):
141+
return t
142+
138143
@TOKEN('True' + not_followed_by_name)
139144
def t_TRUE(self, t):
140145
return t

djangoql/parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def p_comparison_string(self, p):
9393
comparison_string : comparison_equality
9494
| comparison_greater_less
9595
| comparison_contains
96+
| comparison_regex
9697
"""
9798
p[0] = p[1]
9899

@@ -119,6 +120,12 @@ def p_comparison_contains(self, p):
119120
"""
120121
p[0] = Comparison(operator=p[1])
121122

123+
def p_comparison_regex(self, p):
124+
"""
125+
comparison_regex : REGEX
126+
"""
127+
p[0] = Comparison(operator=p[1])
128+
122129
def p_comparison_in_list(self, p):
123130
"""
124131
comparison_in_list : IN

djangoql/schema.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def get_operator(self, operator):
101101
'<': '__lt',
102102
'<=': '__lte',
103103
'~': '__icontains',
104+
'regex': '__iregex',
104105
'in': '__in',
105106
}.get(operator)
106107
if op is not None:
@@ -124,11 +125,12 @@ def get_lookup(self, path, operator, value):
124125
be ['author', 'groups']. 'name' is not included, because it's the
125126
current field instance itself.
126127
:param operator: a string with comparison operator. It could be one of
127-
the following: '=', '!=', '>', '>=', '<', '<=', '~', '!~', 'in',
128-
'not in'. Depending on the field type, some operators may be
129-
excluded. '~' and '!~' can be applied to StrField only and aren't
130-
allowed for any other fields. BoolField can't be used with less or
131-
greater operators, '>', '>=', '<' and '<=' are excluded for it.
128+
the following: '=', '!=', '>', '>=', '<', '<=', '~', '!~', 'regex',
129+
'in', 'not in'. Depending on the field type, some operators
130+
may be excluded. '~' and '!~' can be applied to StrField only and
131+
aren't allowed for any other fields. BoolField can't be used with
132+
less or greater operators, '>', '>=', '<' and '<=' are excluded
133+
for it.
132134
:param value: value passed for comparison
133135
:return: Q-object
134136
"""
@@ -277,7 +279,7 @@ def get_lookup(self, path, operator, value):
277279
# and resulting comparison would look like
278280
# 'created LIKE %2017-01-30 00:00:00%'
279281
# which is not what we want for this case.
280-
val = value if operator in ('~', '!~') else self.get_lookup_value(value)
282+
val = value if operator in ('~', '!~', 'regex') else self.get_lookup_value(value)
281283

282284
q = models.Q(**{'%s%s' % (search, op): val})
283285
return ~q if invert else q

djangoql/static/djangoql/js/completion.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
lexer.addRule(new RegExp('in' + reNotFollowedByName), function (l) {
8888
return token('IN', l);
8989
});
90+
lexer.addRule(new RegExp('regex' + reNotFollowedByName), function (l) {
91+
return token('REGEX', l);
92+
});
9093
lexer.addRule(new RegExp('True' + reNotFollowedByName), function (l) {
9194
return token('TRUE', l);
9295
});
@@ -871,8 +874,9 @@
871874
}
872875
} else if (lastToken && whitespace &&
873876
nextToLastToken && nextToLastToken.name === 'NAME' &&
874-
['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'NOT_CONTAINS', 'GREATER_EQUAL',
875-
'GREATER', 'LESS_EQUAL', 'LESS'].indexOf(lastToken.name) >= 0) {
877+
['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'NOT_CONTAINS', 'REGEX',
878+
'GREATER_EQUAL', 'GREATER', 'LESS_EQUAL', 'LESS'
879+
].indexOf(lastToken.name) >= 0) {
876880
resolvedName = this.resolveName(nextToLastToken.value);
877881
if (resolvedName.model) {
878882
scope = 'value';
@@ -1090,6 +1094,7 @@
10901094
if (['str', 'date', 'datetime'].indexOf(field.type) >= 0) {
10911095
suggestions.push(['~', 'contains']);
10921096
suggestions.push(['!~', 'does not contain']);
1097+
suggestions.push(['regex', 'regex']);
10931098
snippetAfter = ' "|"';
10941099
} else if (field.options) {
10951100
snippetAfter = ' "|"';

djangoql/templates/djangoql/syntax_help.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ <h2 id="comparison-operators">Comparison operators</h2>
242242
<td>does not contain a substring</td>
243243
<td>username !~ "test"</td>
244244
</tr>
245+
<tr>
246+
<td>regex</td>
247+
<td>find based on regex</td>
248+
<td>email regex "\w+@\w+\.com"</td>
249+
</tr>
245250
<tr>
246251
<td>&gt;</td>
247252
<td>greater</td>

js_tests/tests.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ describe('DjangoQL completion', function () {
137137
token('LESS', '<'),
138138
token('LESS_EQUAL', '<='),
139139
token('CONTAINS', '~'),
140-
token('NOT_CONTAINS', '!~')
140+
token('NOT_CONTAINS', '!~'),
141+
token('REGEX', 'regex')
141142
];
142143
djangoQL.lexer.setInput('() ., = != >\t >= < <= ~ !~');
143144
tokens.forEach(function (t) {
@@ -155,7 +156,7 @@ describe('DjangoQL completion', function () {
155156
});
156157

157158
it('should recognize reserved words', function () {
158-
var words = ['True', 'False', 'None', 'or', 'and', 'in'];
159+
var words = ['True', 'False', 'None', 'or', 'and', 'in', 'regex'];
159160
djangoQL.lexer.setInput(words.join(' '));
160161
words.forEach(function (word) {
161162
expect(djangoQL.lexer.lex()).to.eql(token(word.toUpperCase(), word));

test_project/core/tests/test_lexer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def test_entity_props(self):
5454
pass
5555

5656
def test_reserved_words(self):
57-
reserved = ('True', 'False', 'None', 'or', 'and', 'in')
57+
reserved = ('True', 'False', 'None', 'or', 'and', 'in', 'regex')
5858
for word in reserved:
5959
self.assert_output(self.lexer.input(word), [(word.upper(), word)])
6060
# A word made of reserved words should be treated as a name

test_project/core/tests/test_schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ def test_invalid_config(self):
142142
def test_validation_pass(self):
143143
samples = [
144144
'first_name = "Lolita"',
145+
'first_name regex "^Lol"',
146+
'first_name regex "ita$"',
147+
r'first_name regex "\w+i\w+a"',
145148
'groups.id < 42',
146149
'groups = None', # that's ok to compare a model to None
147150
'groups != None',

0 commit comments

Comments
 (0)