Coverage for gws-app/gws/lib/cql/parser.py: 97%
404 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""CQL2-Text parser."""
3import re
4import datetime
7def parse(s: str):
8 """Parse a CQL2-Text expression.
10 Args:
11 s: CQL2-Text expression.
13 Returns:
14 A parse tree of nested lists, see the package documentation.
16 Raises:
17 ParseError: If the expression is invalid.
18 """
20 parser = _Parser()
21 return parser.parse(s)
24class ParseError(Exception):
25 """A CQL2-Text expression is invalid. The message contains the error position."""
27 pass
30class Node:
31 """Node types of the parse tree."""
33 AND = 'AND'
34 ARRAY = 'ARRAY'
35 BETWEEN = 'BETWEEN'
36 BOOL = 'BOOL'
37 DATE = 'DATE'
38 FLOAT = 'FLOAT'
39 FUNCTION = 'FUNCTION'
40 IN = 'IN'
41 INT = 'INT'
42 IS_NULL = 'IS_NULL'
43 LIKE = 'LIKE'
44 NAME = 'NAME'
45 NOT = 'NOT'
46 NOT_BETWEEN = 'NOT_BETWEEN'
47 NOT_IN = 'NOT_IN'
48 NOT_LIKE = 'NOT_LIKE'
49 NOT_NULL = 'NOT_NULL'
50 OR = 'OR'
51 STRING = 'STRING'
52 TIMESTAMP = 'TIMESTAMP'
53 USER_FUNCTION = 'USER_FUNCTION'
54 WKT = 'WKT'
57class C:
58 """Operator, keyword and function sets of the parser."""
60 LITERALS = {
61 Node.ARRAY,
62 Node.BOOL,
63 Node.DATE,
64 Node.FLOAT,
65 Node.INT,
66 Node.STRING,
67 Node.TIMESTAMP,
68 Node.WKT,
69 }
70 """Literal node types."""
72 COMPARISON_OPERATORS = {'=', '<>', '!=', '<', '<=', '>', '>='}
73 """Comparison operators."""
74 NOT_EQUAL_OPERATORS = {'<>', '!='}
75 """Not-equal operators, emitted as ``<>``."""
76 ADDITIVE_OPERATORS = {'+', '-'}
77 """Additive operators, also used as unary signs."""
78 MULTIPLICATIVE_OPERATORS = {'*', '/', '%'}
79 """Multiplicative operators."""
80 POWER_OPERATORS = {'^'}
81 """Power operators."""
83 OPERATORS = COMPARISON_OPERATORS | ADDITIVE_OPERATORS | MULTIPLICATIVE_OPERATORS | POWER_OPERATORS
84 """All operators."""
86 KEYWORDS = {
87 'and',
88 'or',
89 'not',
90 'is',
91 'like',
92 'between',
93 'in',
94 }
95 """Reserved keywords."""
97 PREDICATE_KEYWORDS = {
98 'not',
99 'is',
100 'like',
101 'between',
102 'in',
103 }
104 """Keywords that can follow an operand in a predicate."""
106 WKT_KEYWORDS = {
107 'point',
108 'linestring',
109 'polygon',
110 'multipoint',
111 'multilinestring',
112 'multipolygon',
113 'geometrycollection',
114 }
115 """Geometry types that start a WKT literal."""
117 FUNCTIONS = {
118 's_intersects': 2,
119 's_contains': 2,
120 's_crosses': 2,
121 's_disjoint': 2,
122 's_equals': 2,
123 's_overlaps': 2,
124 's_touches': 2,
125 's_within': 2,
126 't_after': 2,
127 't_before': 2,
128 't_contains': 2,
129 't_disjoint': 2,
130 't_during': 2,
131 't_equals': 2,
132 't_finishedby': 2,
133 't_finishes': 2,
134 't_intersects': 2,
135 't_meets': 2,
136 't_metby': 2,
137 't_overlappedby': 2,
138 't_overlaps': 2,
139 't_startedby': 2,
140 't_starts': 2,
141 'a_contains': 2,
142 'a_containedby': 2,
143 'a_equals': 2,
144 'a_overlaps': 2,
145 'bbox': 4,
146 'date': 1,
147 'timestamp': 1,
148 'interval': 2,
149 'casei': 1,
150 'accenti': 1,
151 }
152 """Standard functions and their number of arguments."""
154 ARRAY_FUNCTIONS = {
155 'a_contains',
156 'a_containedby',
157 'a_equals',
158 'a_overlaps',
159 }
160 """Functions whose parenthesized arguments are array literals."""
162 PATTERN_FUNCTIONS = {
163 'casei',
164 'accenti',
165 }
166 """Functions allowed as a ``LIKE`` pattern."""
168##
171class _Token:
172 """A token with its type, value and position in the input."""
174 def __init__(self, type: str, value, pos: int):
175 self.type = type
176 self.value = value
177 self.index = pos
179 def __repr__(self):
180 return f'_Token({self.type}, {self.value!r}, {self.index})'
183_TOKENS = [
184 ('WHITESPACE', r'\s+'),
185 ('TIMESTAMP', r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?'),
186 ('DATE', r'\d{4}-\d{2}-\d{2}'),
187 ('NUMBER', r'-?\d+\.?\d*(?:[eE][+-]?\d+)?'),
188 ('STRING', r"'(?:[^'\\]|\\.|'')*'"),
189 ('QUOTED', r'"(?:[^"\\]|\\.|"")*"'),
190 ('IDENT', r'[a-zA-Z_][a-zA-Z0-9_]*'),
191 ('', r'<=|>=|<>|!=|[()[\],.+\-*/%^<>=]'),
192]
195class _Parser:
196 """Recursive descent parser for CQL2-Text."""
198 def __init__(self):
199 self.tokens = []
200 self.index = 0
202 def parse(self, s: str):
203 """Parse an expression and check that all input is consumed."""
204 self.tokens = list(self.tokenize(s))
205 self.tokens.append(_Token('EOF', None, len(s)))
206 self.index = 0
208 e = self.parse_boolean_expression()
209 if self.tok().type != 'EOF':
210 raise self.error(f'unexpected token')
211 return e
213 def error(self, message: str, pos=None):
214 if pos is None:
215 pos = self.tok().index
216 return ParseError(f'Parse error: {message} ({pos})')
218 def tokenize(self, s):
219 """Yield the tokens of the input, without whitespace."""
220 pos = 0
221 while pos < len(s):
222 tok = None
223 for typ, pattern in _TOKENS:
224 r = re.compile(pattern)
225 m = r.match(s, pos=pos)
226 if m:
227 v = m.group(0)
228 tok = _Token(typ or v, v, pos)
229 pos = m.end()
230 break
231 if tok is None:
232 raise self.error(f'unexpected character', pos)
233 if tok.type != 'WHITESPACE':
234 yield tok
236 def node(self, type: str, *args):
237 return {'type': type, 'args': list(args)}
239 ##
241 def tok(self) -> _Token:
242 if self.index < len(self.tokens):
243 return self.tokens[self.index]
244 return self.tokens[-1]
246 def peek(self, n=1) -> _Token:
247 if self.index + n < len(self.tokens):
248 return self.tokens[self.index + n]
249 return self.tokens[-1]
251 def pop(self) -> _Token:
252 tok = self.tok()
253 self.index += 1
254 return tok
256 def expect(self, token_type: str) -> _Token:
257 tok = self.tok()
258 if tok.type != token_type:
259 raise self.error(f'expected {token_type}, got {tok.type}')
260 return self.pop()
262 def is_a(self, token_type: str) -> bool:
263 tok = self.tok()
264 return tok.type == token_type
266 def is_ident(self, value: str) -> bool:
267 tok = self.tok()
268 return tok.type == 'IDENT' and tok.value.upper() == value.upper()
270 def expect_ident(self, value: str) -> _Token:
271 tok = self.tok()
272 if tok.type != 'IDENT' or tok.value.upper() != value.upper():
273 raise self.error(f'expected {value}, got {tok}')
274 return self.pop()
276 ##
278 def parse_boolean_expression(self):
279 return self.parse_or_expression()
281 def parse_or_expression(self):
282 args = [self.parse_and_expression()]
283 while self.is_ident('OR'):
284 self.pop()
285 args.append(self.parse_and_expression())
286 return [Node.OR, *args] if len(args) > 1 else args[0]
288 def parse_and_expression(self):
289 args = [self.parse_not_expression()]
290 while self.is_ident('AND'):
291 self.pop()
292 args.append(self.parse_not_expression())
293 return [Node.AND, *args] if len(args) > 1 else args[0]
295 def parse_not_expression(self):
296 if self.is_ident('NOT'):
297 self.pop()
298 e = self.parse_not_expression()
299 return [Node.NOT, e]
300 return self.parse_primary_expression()
302 def parse_primary_expression(self):
303 if self.is_a('('):
304 # a parenthesized group is a boolean expression, unless it turns out
305 # to be an operand, like in "(a + b) * c = 1"
306 index = self.index
307 self.pop()
308 e = self.parse_boolean_expression()
309 self.expect(')')
310 if not self.is_operand_follower():
311 return e
312 self.index = index
313 return self.parse_predicate()
315 def is_operand_follower(self):
316 """Check if the current token can follow an operand, i.e. is an operator or a predicate keyword."""
317 tok = self.tok()
318 if tok.type in C.OPERATORS:
319 return True
320 return tok.type == 'IDENT' and tok.value.lower() in C.PREDICATE_KEYWORDS
322 def parse_predicate(self):
323 tok = self.tok()
324 if tok.type == 'EOF':
325 raise self.error('unexpected end of expression')
327 e = self.parse_expression()
329 if self.is_ident('NOT'):
330 self.pop()
331 if self.is_ident('LIKE'):
332 return self.parse_like_predicate(e, True)
333 if self.is_ident('BETWEEN'):
334 return self.parse_between_predicate(e, True)
335 if self.is_ident('IN'):
336 return self.parse_in_predicate(e, True)
337 raise self.error(f'unexpected {self.tok().type!r}')
339 if self.is_ident('IS'):
340 return self.parse_is_null_predicate(e)
341 if self.is_ident('LIKE'):
342 return self.parse_like_predicate(e, False)
343 if self.is_ident('BETWEEN'):
344 return self.parse_between_predicate(e, False)
345 if self.is_ident('IN'):
346 return self.parse_in_predicate(e, False)
348 return self.parse_comparison_predicate(e)
350 def parse_comparison_predicate(self, e):
351 tok = self.tok()
352 if tok.type in C.COMPARISON_OPERATORS:
353 self.pop()
354 b = self.parse_expression()
355 op = '<>' if tok.type in C.NOT_EQUAL_OPERATORS else tok.type
356 return [op, e, b]
357 return e
359 def parse_like_predicate(self, e, is_not):
360 self.expect_ident('LIKE')
361 pattern = self.parse_pattern_expression()
362 return [Node.NOT_LIKE if is_not else Node.LIKE, e, pattern]
364 def parse_pattern_expression(self):
365 if self.is_a('IDENT') and self.tok().value.lower() in C.PATTERN_FUNCTIONS and self.peek().type == '(':
366 return self.parse_postfix_expression()
367 return self.parse_string_literal()
369 def parse_between_predicate(self, e, is_not):
370 self.expect_ident('BETWEEN')
371 a = self.parse_expression()
372 self.expect_ident('AND')
373 b = self.parse_expression()
374 return [Node.NOT_BETWEEN if is_not else Node.BETWEEN, e, a, b]
376 def parse_in_predicate(self, e, is_not):
377 self.expect_ident('IN')
378 if self.is_a('('):
379 self.pop()
380 a = self.parse_list(')')
381 elif self.is_a('['):
382 self.pop()
383 a = self.parse_list(']')
384 else:
385 raise self.error('expected ( or [ after IN')
386 return [Node.NOT_IN if is_not else Node.IN, e, *a]
388 def parse_is_null_predicate(self, e):
389 self.expect_ident('IS')
390 is_not = False
391 if self.is_ident('NOT'):
392 self.pop()
393 is_not = True
394 self.expect_ident('NULL')
395 return [Node.NOT_NULL if is_not else Node.IS_NULL, e]
397 def parse_expression(self):
398 return self.parse_additive_expression()
400 def parse_additive_expression(self):
401 a = self.parse_multiplicative_expression()
402 while self.tok().type in C.ADDITIVE_OPERATORS:
403 op = self.pop().value
404 b = self.parse_multiplicative_expression()
405 a = [op, a, b]
406 return a
408 def parse_multiplicative_expression(self):
409 a = self.parse_power_expression()
410 while self.tok().type in C.MULTIPLICATIVE_OPERATORS:
411 op = self.pop().value
412 b = self.parse_power_expression()
413 a = [op, a, b]
414 return a
416 def parse_power_expression(self):
417 a = self.parse_unary_expression()
418 while self.tok().type in C.POWER_OPERATORS:
419 op = self.pop().value
420 b = self.parse_unary_expression()
421 a = [op, a, b]
422 return a
424 def parse_unary_expression(self):
425 if self.tok().type in C.ADDITIVE_OPERATORS:
426 op = self.pop().value
427 e = self.parse_unary_expression()
428 if op == '-':
429 return ['-', e]
430 return e
431 return self.parse_postfix_expression()
433 def parse_postfix_expression(self):
434 pos = self.tok().index
435 e = self.parse_atom()
436 if self.is_a('('):
437 self.pop()
438 return self.parse_call(e, pos)
439 return e
441 def parse_call(self, head, pos):
442 if head[0] != Node.NAME:
443 raise self.error('invalid function name', pos)
445 name = '.'.join(head[1:])
446 key = name.lower()
448 if key in C.ARRAY_FUNCTIONS:
449 args = self.parse_array_argument_list()
450 else:
451 args = self.parse_list(')')
453 if key not in C.FUNCTIONS:
454 return [Node.USER_FUNCTION, name, *args]
456 if len(args) != C.FUNCTIONS[key]:
457 raise self.error(f'invalid number of arguments for {name!r}', pos)
458 return [Node.FUNCTION, key, *args]
460 def parse_atom(self):
461 tok = self.tok()
463 if tok.type == 'EOF':
464 raise self.error('unexpected EOF')
466 if self.is_a('('):
467 self.pop()
468 expr = self.parse_boolean_expression()
469 self.expect(')')
470 return expr
472 if self.is_a('['):
473 self.pop()
474 return [Node.ARRAY, *self.parse_list(']')]
475 if self.is_a('NUMBER'):
476 return self.parse_number_literal()
477 if self.is_a('STRING'):
478 return self.parse_string_literal()
479 if self.is_a('TIMESTAMP'):
480 return self.parse_timestamp_literal()
481 if self.is_a('DATE'):
482 return self.parse_date_literal()
483 if self.is_ident('TRUE') or self.is_ident('FALSE'):
484 return [Node.BOOL, self.pop().value.upper() == 'TRUE']
485 if self.is_a('IDENT') and self.tok().value.lower() in C.WKT_KEYWORDS:
486 p = self.peek()
487 if p.type == '(' or (p.type == 'IDENT' and p.value.upper() == 'Z'):
488 return self.parse_geometry_literal()
489 if self.is_a('IDENT') or self.is_a('QUOTED'):
490 return self.parse_name()
492 raise self.error(f'unexpected token: {tok.type}')
494 def parse_name(self):
495 parts = []
497 while True:
498 tok = self.tok()
499 if tok.type == 'QUOTED':
500 parts.append(self.unquote(self.pop().value))
501 elif tok.type == 'IDENT':
502 parts.append(self.pop().value)
503 else:
504 raise self.error(f'expected identifier')
505 if self.is_a('.'):
506 self.pop()
507 continue
508 break
510 return [Node.NAME, *parts]
512 def parse_number(self):
513 val = self.expect('NUMBER').value
514 if '.' in val or 'e' in val.lower():
515 return float(val)
516 return int(val)
518 def parse_number_literal(self):
519 val = self.parse_number()
520 return [Node.FLOAT if isinstance(val, float) else Node.INT, val]
522 def parse_string_literal(self):
523 val = self.expect('STRING').value
524 return [Node.STRING, self.unquote(val)]
526 def parse_timestamp_literal(self):
527 val = self.pop().value
528 if val.endswith('Z'):
529 val = val[:-1] + '+00:00'
530 try:
531 return [Node.TIMESTAMP, datetime.datetime.fromisoformat(val)]
532 except ValueError:
533 raise self.error(f'invalid timestamp')
535 def parse_date_literal(self):
536 val = self.pop().value
537 try:
538 year, month, day = val.split('-')
539 return [Node.DATE, datetime.date(int(year), int(month), int(day))]
540 except ValueError:
541 raise self.error(f'invalid date')
543 def parse_geometry_literal(self):
544 """Collect the tokens of a WKT literal into a normalized WKT string."""
545 parts = []
546 parens = 0
547 has_word = False
549 while True:
550 if self.is_a('IDENT'):
551 if has_word:
552 parts.append(' ')
553 parts.append(self.pop().value.upper())
554 has_word = True
555 elif self.is_a('NUMBER'):
556 if has_word:
557 parts.append(' ')
558 parts.append(str(self.parse_number()))
559 has_word = True
560 elif self.is_a(','):
561 self.pop()
562 parts.append(', ')
563 has_word = False
564 elif self.is_a('('):
565 self.pop()
566 parts.append('(')
567 has_word = False
568 parens += 1
569 elif self.is_a(')'):
570 self.pop()
571 parts.append(')')
572 has_word = False
573 parens -= 1
574 if parens == 0:
575 break
576 else:
577 break
579 return [Node.WKT, ''.join(parts)]
581 def parse_array_argument_list(self):
582 elements = []
583 if not self.is_a(')'):
584 elements.append(self.parse_array_argument())
585 while self.is_a(','):
586 self.pop()
587 elements.append(self.parse_array_argument())
588 self.expect(')')
589 return elements
591 def parse_array_argument(self):
592 """Parse an argument of an array function, a parenthesized list being an array."""
593 if self.is_a('('):
594 self.pop()
595 return [Node.ARRAY, *self.parse_list(')')]
596 return self.parse_expression()
598 def parse_list(self, end):
599 elements = []
600 if not self.is_a(end):
601 elements.append(self.parse_expression())
602 while self.is_a(','):
603 self.pop()
604 elements.append(self.parse_expression())
605 self.expect(end)
606 return elements
608 def unquote(self, s: str):
609 """Remove the quotes of a quoted string and unescape doubled and backslash-escaped quotes."""
610 quote = s[0]
611 return s[1:-1].replace('\\' + quote, quote).replace(quote + quote, quote)