Skip to content

Commit 0bf2c6a

Browse files
committed
Merge upstream/main into fix/cql2-json-bbox-axis-order
Resolves the conflict in tests/parsers/cql2_text/test_parser.py (upstream's NOT/paren precedence tests vs this stack's single-letter attribute tests); both sets are kept. No change to the bbox axis-order fix itself.
2 parents 59ea0ec + 847b770 commit 0bf2c6a

3 files changed

Lines changed: 106 additions & 8 deletions

File tree

‎pygeofilter/parsers/cql2_text/grammar.lark‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@
2727

2828
?start: condition
2929

30-
?condition: condition_1
31-
| condition "AND"i condition_1 -> and_
32-
| condition "OR"i condition_1 -> or_
30+
// AND binds tighter than OR, and NOT tighter than both, as in booleanExpression of the CQL2 BNF
31+
?condition: condition_and
32+
| condition "OR"i condition_and -> or_
3333

34-
?condition_1: predicate
35-
| "NOT"i predicate -> not_
36-
| "NOT"i "(" predicate ")" -> not_
34+
?condition_and: condition_1
35+
| condition_and "AND"i condition_1 -> and_
36+
37+
?condition_1: condition_2
38+
| "NOT"i condition_2 -> not_
39+
40+
?condition_2: predicate
3741
| "(" condition ")"
3842

3943
?predicate: expression "=" expression -> eq
@@ -135,7 +139,8 @@ bbox: "BBOX"i "(" full_number "," full_number "," full_number "," full_number ")
135139
BOOLEAN.2: ( "TRUE"i | "FALSE"i)
136140

137141
DOUBLE_QUOTED: "\"" /.*?/ "\""
138-
SINGLE_QUOTED: "'" /.*?/ "'"
142+
// CQL2 escapes a quote as '' or \'
143+
SINGLE_QUOTED: /'(?:[^'\\]|\\.|'')*'/
139144

140145
DATE: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9]/
141146
DATETIME: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9][T ][0-2][0-9]:?[0-5][0-9]:?[0-5][0-9](\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})?/

‎pygeofilter/parsers/cql2_text/parser.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import logging
2929
import os.path
30+
import re
3031

3132
from lark import Lark, logger, v_args
3233

@@ -185,7 +186,13 @@ def DOUBLE_QUOTED(self, token):
185186
return token[1:-1]
186187

187188
def SINGLE_QUOTED(self, token):
188-
return token[1:-1]
189+
# '' and \' stand for a quote, the other backslashes are kept for LIKE
190+
return re.sub(
191+
r"''|\\(.)",
192+
lambda m: "'" if m[0] == "''" or m[1] == "'" else m[0],
193+
token[1:-1],
194+
flags=re.S,
195+
)
189196

190197
def geometry(self, value):
191198
return values.Geometry(value)

‎tests/parsers/cql2_text/test_parser.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,36 @@ def test_string_not_like():
175175
)
176176

177177

178+
def test_string_doubled_quote():
179+
result = parse("attr = 'it''s'")
180+
assert result == ast.Equal(ast.Attribute("attr"), "it's")
181+
182+
183+
def test_string_backslash_quote():
184+
result = parse(r"attr = 'it\'s'")
185+
assert result == ast.Equal(ast.Attribute("attr"), "it's")
186+
187+
188+
def test_string_only_quotes():
189+
assert parse("attr = ''") == ast.Equal(ast.Attribute("attr"), "")
190+
assert parse("attr = ''''") == ast.Equal(ast.Attribute("attr"), "'")
191+
192+
193+
def test_strings_end_at_their_quote():
194+
result = parse("attr = 'a' OR attr = 'b'")
195+
assert result == ast.Or(
196+
ast.Equal(ast.Attribute("attr"), "a"),
197+
ast.Equal(ast.Attribute("attr"), "b"),
198+
)
199+
200+
201+
def test_string_like_keeps_backslashes():
202+
result = parse(r"attr LIKE '100\%'")
203+
assert result.pattern == r"100\%"
204+
result = parse(r"attr LIKE 'a\\''%'")
205+
assert result.pattern == r"a\\'%"
206+
207+
178208
def test_attribute_in_list():
179209
result = parse("attr IN (1, 2, 3, 4)")
180210
assert result == ast.In(
@@ -491,6 +521,62 @@ def test_not_eq():
491521
assert result == ast.Not(ast.Equal(ast.Attribute("attr"), 2))
492522

493523

524+
def test_not_double_parens():
525+
result = parse("NOT ((attr = 2))")
526+
assert result == ast.Not(ast.Equal(ast.Attribute("attr"), 2))
527+
528+
529+
def test_not_or():
530+
result = parse("NOT (attr = 1 OR attr = 2)")
531+
assert result == ast.Not(
532+
ast.Or(
533+
ast.Equal(ast.Attribute("attr"), 1),
534+
ast.Equal(ast.Attribute("attr"), 2),
535+
)
536+
)
537+
538+
539+
def test_not_binds_tighter_than_and():
540+
result = parse("NOT attr = 1 AND attr = 2")
541+
assert result == ast.And(
542+
ast.Not(ast.Equal(ast.Attribute("attr"), 1)),
543+
ast.Equal(ast.Attribute("attr"), 2),
544+
)
545+
546+
547+
def test_and_binds_tighter_than_or():
548+
result = parse("attr = 1 OR attr = 2 AND attr = 3")
549+
assert result == ast.Or(
550+
ast.Equal(ast.Attribute("attr"), 1),
551+
ast.And(
552+
ast.Equal(ast.Attribute("attr"), 2),
553+
ast.Equal(ast.Attribute("attr"), 3),
554+
),
555+
)
556+
557+
558+
def test_and_then_or():
559+
result = parse("attr = 1 AND attr = 2 OR attr = 3")
560+
assert result == ast.Or(
561+
ast.And(
562+
ast.Equal(ast.Attribute("attr"), 1),
563+
ast.Equal(ast.Attribute("attr"), 2),
564+
),
565+
ast.Equal(ast.Attribute("attr"), 3),
566+
)
567+
568+
569+
def test_parens_override_precedence():
570+
result = parse("(attr = 1 OR attr = 2) AND attr = 3")
571+
assert result == ast.And(
572+
ast.Or(
573+
ast.Equal(ast.Attribute("attr"), 1),
574+
ast.Equal(ast.Attribute("attr"), 2),
575+
),
576+
ast.Equal(ast.Attribute("attr"), 3),
577+
)
578+
579+
494580
def test_single_letter_attribute():
495581
# https://github.com/geopython/pygeofilter/issues/165
496582
# single-letter property names must parse

0 commit comments

Comments
 (0)