lexcql-parser 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexcql-parser
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: LexCQL Query Grammar and Parser
5
5
  Keywords: LexCQL,FCS,CQL,Query Parser
6
6
  Author: Erik Körner
@@ -141,6 +141,7 @@ assert error.message == "Unknown index 'post'!"
141
141
  assert error.fragment == "post = NOUN"
142
142
  assert error.position.start == 0
143
143
  assert error.position.stop == 11
144
+ assert error.type == "validation-error"
144
145
  ```
145
146
 
146
147
  ## Development
@@ -101,6 +101,7 @@ assert error.message == "Unknown index 'post'!"
101
101
  assert error.fragment == "post = NOUN"
102
102
  assert error.position.start == 0
103
103
  assert error.position.stop == 11
104
+ assert error.type == "validation-error"
104
105
  ```
105
106
 
106
107
  ## Development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lexcql-parser"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "LexCQL Query Grammar and Parser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -32,6 +32,18 @@ class ExceptionThrowingErrorListener(ErrorListener):
32
32
 
33
33
 
34
34
  def antlr_parse(input: str) -> LexParser.QueryContext:
35
+ """Run the low-level ANTLR4 tokenization and parsing. This returns the parsing
36
+ context ANTLR4 uses instead of the simplified LexCQL query node types.
37
+
38
+ Args:
39
+ input: raw query string
40
+
41
+ Returns:
42
+ LexParser.QueryContext: the ANTLR4 root parsing context (query)
43
+
44
+ Throws:
45
+ SyntaxError: if an error occurred while parsing
46
+ """
35
47
  input_stream = InputStream(input)
36
48
  lexer = LexLexer(input_stream)
37
49
  stream = CommonTokenStream(lexer)
@@ -78,12 +90,17 @@ def can_parse(input: str):
78
90
  return False
79
91
 
80
92
 
93
+ # ---------------------------------------------------------------------------
94
+
95
+
81
96
  @overload
82
97
  def validate(
83
98
  input: str,
84
99
  *,
85
100
  version: str = DEFAULT_VALIDATOR_SPECIFICATION_VERSION,
86
101
  return_errors: Literal[False] = False,
102
+ case_insensitive: bool = True,
103
+ warnings_as_errors: bool = False,
87
104
  ) -> bool: ...
88
105
 
89
106
 
@@ -93,6 +110,8 @@ def validate(
93
110
  *,
94
111
  version: str = DEFAULT_VALIDATOR_SPECIFICATION_VERSION,
95
112
  return_errors: Literal[True] = True,
113
+ case_insensitive: bool = True,
114
+ warnings_as_errors: bool = False,
96
115
  ) -> List[ErrorDetail]: ...
97
116
 
98
117
 
@@ -101,7 +120,30 @@ def validate(
101
120
  *,
102
121
  version: str = DEFAULT_VALIDATOR_SPECIFICATION_VERSION,
103
122
  return_errors: bool = False,
123
+ case_insensitive: bool = True,
124
+ warnings_as_errors: bool = False,
104
125
  ):
126
+ """Validate input query string by trying to parse it and if successful run a LexCQL
127
+ specification validation. Collect errors/warnings.
128
+
129
+ Args:
130
+ input: the raw query input string
131
+ version: the specification version to validate against.
132
+ Defaults to DEFAULT_VALIDATOR_SPECIFICATION_VERSION ("0.3").
133
+ return_errors: whether to return simply a boolean if valid or a list of errors.
134
+ Defaults to False.
135
+ case_insensitive: how to handle keywords/indexes in LexCQL. Defaults to True.
136
+ warnings_as_errors: handle warnings as errors. Defaults to False.
137
+
138
+ Raises:
139
+ ValueError: raised if ``version`` argument specifies an unknown LexCQL specification
140
+ or no ``Validator`` can be found for this version.
141
+
142
+ Returns:
143
+ bool: if ``return_errors`` is ``False`` only return a boolean.
144
+ Returns ``True`` if parsing and validation is without issues, ``False`` otherwise.
145
+ List[ErrorDetail]: if ``return_errors`` is ``True`` return a list of errors AND warnings.
146
+ """
105
147
  # "check" params
106
148
  validator_cls = VALIDATORS.get(version, None)
107
149
  if validator_cls is None:
@@ -109,7 +151,12 @@ def validate(
109
151
 
110
152
  # create parser/validator
111
153
  parser = QueryParser(enableSourceLocations=True)
112
- validator = validator_cls(query=input, raise_at_first_violation=not return_errors)
154
+ validator = validator_cls(
155
+ query=input,
156
+ case_insensitive=case_insensitive,
157
+ raise_at_first_violation=not return_errors,
158
+ warnings_as_errors=warnings_as_errors,
159
+ )
113
160
 
114
161
  # try to parse the input query string
115
162
  try:
@@ -128,14 +175,15 @@ def validate(
128
175
  try:
129
176
  validator.validate(qn)
130
177
  except SpecificationValidationError:
178
+ # not will raise if raise_at_first_violation enabled
131
179
  return False
132
180
 
133
- if validator.errors:
134
- if not return_errors:
135
- return False
136
- return list(validator.errors)
181
+ errors = list()
182
+ errors.extend(validator.errors)
183
+ if warnings_as_errors:
184
+ errors.extend(validator.warnings)
137
185
 
138
- return [] if return_errors else True
186
+ return errors if return_errors else not bool(errors)
139
187
 
140
188
 
141
189
  # ---------------------------------------------------------------------------
@@ -32,7 +32,9 @@ from lexcql.LexParserVisitor import LexParserVisitor
32
32
  LOGGER = logging.getLogger(__name__)
33
33
 
34
34
  _T = TypeVar("_T", bound="QueryNode")
35
+ """Type of ``QueryNode``."""
35
36
  _R = TypeVar("_R")
37
+ """Result type for ``QueryVisitor``."""
36
38
 
37
39
  # ---------------------------------------------------------------------------
38
40
 
@@ -640,10 +642,17 @@ class Subquery(QueryNode):
640
642
 
641
643
  @dataclass(frozen=True)
642
644
  class ErrorDetail:
645
+ """Wrapper for error or warnings messages to include the type of issue
646
+ with optional position and query string fragment."""
647
+
643
648
  message: str
644
- type: Optional[Union[Literal["syntax-error", "validation-error"], str]] = None
649
+ """the error or warning message"""
650
+ type: Optional[Union[Literal["syntax-error", "validation-error", "validation-warning"], str]] = None
651
+ """the type of error or warning"""
645
652
  position: Optional[Union[int, SourceLocation]] = None
653
+ """optional position information in the raw query string"""
646
654
  fragment: Optional[str] = None
655
+ """optional query string fragment, may be a substring to quickly locate the issue"""
647
656
 
648
657
 
649
658
  class ErrorListener(antlr4.error.ErrorListener.ErrorListener):
@@ -4,6 +4,9 @@ from typing import Deque
4
4
  from typing import Dict
5
5
  from typing import List
6
6
  from typing import Optional
7
+ from typing import Set
8
+ from typing import Type
9
+ from typing import TypeVar
7
10
 
8
11
  from lexcql.parser import ErrorDetail
9
12
  from lexcql.parser import Modifier
@@ -11,6 +14,12 @@ from lexcql.parser import QueryNode
11
14
  from lexcql.parser import QueryVisitorAdapter
12
15
  from lexcql.parser import Relation
13
16
  from lexcql.parser import SearchClause
17
+ from lexcql.parser import SearchClauseGroup
18
+ from lexcql.parser import Subquery
19
+
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _R = TypeVar("_R")
14
23
 
15
24
  # ---------------------------------------------------------------------------
16
25
 
@@ -25,12 +34,13 @@ class SpecificationValidationError(Exception):
25
34
  # TODO: create custom error classes for each error type? or add some type id?
26
35
 
27
36
 
28
- class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
37
+ class Validator(QueryVisitorAdapter[_R], metaclass=ABCMeta):
29
38
  """An abstract base Validator for LexCQL queries.
30
39
 
31
40
  Subclasses should override the ``visit_`` QueryNode methods to
32
41
  implement the validation logic and use the ``.validation_error()``
33
- to raise/track validation errors.
42
+ to raise/track validation errors and ``.validation_warning()`` for
43
+ warnings (may also raise errors depending on configuration).
34
44
  """
35
45
 
36
46
  def __init__(
@@ -39,6 +49,7 @@ class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
39
49
  query: Optional[str] = None,
40
50
  case_insensitive: bool = True,
41
51
  raise_at_first_violation: bool = True,
52
+ warnings_as_errors: bool = False,
42
53
  ):
43
54
  """Creates a LexCQL Validator that checks that only known indexes are used
44
55
  and that relations and relation modifiers are valid.
@@ -52,9 +63,13 @@ class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
52
63
  conformance validation or try to gather as many infos
53
64
  as possible. Will be available in ``.errors`` attribute.
54
65
  Defaults to True.
66
+ warnings_as_errors: Handle warnings the same as errors. May raise ``SpecificationValidationError``.
55
67
  """
56
68
  super().__init__()
57
69
 
70
+ self.stack: Deque[QueryNode] = deque()
71
+ """(internal) Query node stack to keep track of query node parents."""
72
+
58
73
  self.query = query
59
74
  """query string to add context to error messages for better error locations"""
60
75
 
@@ -63,8 +78,13 @@ class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
63
78
 
64
79
  self.raise_at_first_violation = raise_at_first_violation
65
80
  """Whether to raise at first violation."""
81
+ self.warnings_as_errors = warnings_as_errors
82
+ """Whether to handle warnings the same as errors."""
66
83
  self.errors: List[ErrorDetail] = list()
67
84
  """List of specification validation errors if ``.raise_at_first_violation`` is ``False``"""
85
+ self.warnings: List[ErrorDetail] = list()
86
+ """List of warnings about not-quite-violations of the specification but bad practice or
87
+ what can be unexpected results."""
68
88
 
69
89
  def validate(self, node: QueryNode, *, query: Optional[str] = None):
70
90
  """Validate parse query node tree.
@@ -112,12 +132,12 @@ class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
112
132
  except SpecificationValidationError:
113
133
  return False
114
134
 
115
- def validation_error(self, node: QueryNode, error: str):
116
- """(Internal) Raises or tracks a new validation error.
135
+ def validation_error(self, node: QueryNode, message: str):
136
+ """(Internal) Raises or tracks a new validation error.
117
137
 
118
138
  Args:
119
139
  node: the query node where the validation error was caused
120
- error: the error message
140
+ message: the error message
121
141
 
122
142
  Raises:
123
143
  SpecificationValidationError: the raised error if ``.raise_at_first_violation``
@@ -128,22 +148,98 @@ class Validator(QueryVisitorAdapter[None], metaclass=ABCMeta):
128
148
  fragment = self.query[node.location.start : node.location.stop] # noqa: E203
129
149
 
130
150
  if self.raise_at_first_violation:
131
- raise SpecificationValidationError(error, node, fragment)
151
+ raise SpecificationValidationError(message, node, fragment)
132
152
 
133
153
  self.errors.append(
134
154
  ErrorDetail(
135
- message=error,
155
+ message=message,
136
156
  type="validation-error",
137
157
  position=node.location,
138
158
  fragment=fragment,
139
159
  )
140
160
  )
141
161
 
162
+ def validation_warning(self, node: QueryNode, message: str):
163
+ """(Internal) Tracks validation warnings.
164
+
165
+ If ``.warnings_as_errors`` is ``True`` and ``.raise_at_first_violation``
166
+ is ``True``, it will raise ``SpecificationValidationError``.
167
+
168
+ Args:
169
+ node: the query node where the validation error was caused
170
+ message: the error message
171
+
172
+ Raises:
173
+ SpecificationValidationError: the raised error if ``.raise_at_first_violation``
174
+ and ``.warnings_as_errors`` are set to ``True``
175
+ """
176
+ fragment = None
177
+ if self.query and node.location:
178
+ fragment = self.query[node.location.start : node.location.stop] # noqa: E203
179
+
180
+ if self.warnings_as_errors:
181
+ if self.raise_at_first_violation:
182
+ raise SpecificationValidationError(message, node, fragment)
183
+
184
+ self.warnings.append(
185
+ ErrorDetail(
186
+ message=message,
187
+ type="validation-warning",
188
+ position=node.location,
189
+ fragment=fragment,
190
+ )
191
+ )
192
+
193
+ # ----------------------------------------------------
194
+
195
+ @property
196
+ def parent_node(self) -> Optional[QueryNode]:
197
+ """Get the current parent ``QueryNode`` or ``None`` if unavailable.
198
+
199
+ Intended to be used in the ``visit_`` ``QueryNode`` handlers.
200
+
201
+ Returns:
202
+ Optional[QueryNode]: the parent query node or ``None``
203
+ """
204
+ if self.stack:
205
+ return self.stack[-1]
206
+ return None
207
+
208
+ def visit_Subquery(self, node: Subquery) -> _R:
209
+ self.stack.append(node)
210
+ result = super().visit_Subquery(node)
211
+ self.stack.pop()
212
+ return result
213
+
214
+ def visit_SearchClauseGroup(self, node: SearchClauseGroup) -> _R:
215
+ self.stack.append(node)
216
+ result = super().visit_SearchClauseGroup(node)
217
+ self.stack.pop()
218
+ return result
219
+
220
+ def visit_SearchClause(self, node: SearchClause) -> _R:
221
+ self.stack.append(node)
222
+ result = super().visit_SearchClause(node)
223
+ self.stack.pop()
224
+ return result
225
+
226
+ def visit_Relation(self, node: Relation) -> _R:
227
+ self.stack.append(node)
228
+ result = super().visit_Relation(node)
229
+ self.stack.pop()
230
+ return result
231
+
232
+ def visit_Modifier(self, node: Modifier) -> _R:
233
+ self.stack.append(node)
234
+ result = super().visit_Modifier(node)
235
+ self.stack.pop()
236
+ return result
237
+
142
238
 
143
239
  # ---------------------------------------------------------------------------
144
240
 
145
241
 
146
- class LexCQLValidatorV0_3(Validator):
242
+ class LexCQLValidatorV0_3(Validator[None]):
147
243
  """LexCQL Query Validator for LexCQL Spec v0.3."""
148
244
 
149
245
  SPECIFICATION_VERSION = "0.3"
@@ -194,14 +290,24 @@ class LexCQLValidatorV0_3(Validator):
194
290
  "fullMatch",
195
291
  ]
196
292
  """List of LexCQL relation modifiers"""
293
+ MUTUALLY_EXCLUSIVE_MODIFIERS = [
294
+ # TODO: masked/unmasked/regex ?
295
+ {"masked", "unmasked"},
296
+ {"ignoreCase", "respectCase"},
297
+ {"ignoreAccents", "respectAccents"},
298
+ {"partialMatch", "fullMatch"},
299
+ ]
300
+ """List of mutually exclusive relation modifiers. They should not appear together."""
197
301
 
198
302
  def __init__(
199
303
  self,
200
304
  *,
201
305
  allowed_indexes: Optional[List[str]] = None,
306
+ allowed_modifiers: Optional[List[str]] = None,
202
307
  query: Optional[str] = None,
203
308
  case_insensitive: bool = True,
204
309
  raise_at_first_violation: bool = True,
310
+ warnings_as_errors: bool = False,
205
311
  ):
206
312
  """Creates a LexCQL v0.3 Validator that checks that only known indexes are used
207
313
  and that relations and relation modifiers are valid.
@@ -209,6 +315,8 @@ class LexCQLValidatorV0_3(Validator):
209
315
  Args:
210
316
  allowed_indexes: Override the default ``KNOWN_INDEXES`` list of LexCQL field names.
211
317
  Defaults to None.
318
+ allowed_modifiers: Override the default ``KNOWN_MODIFIERS`` list of LexCQL
319
+ relation modifiers. Defaults to None.
212
320
  query: the original query string used for constructing the query node tree.
213
321
  Used to provide more context for validation errors. Defaults to None.
214
322
  case_insensitive: Whether indexes (field names) are check case-insensitively.
@@ -217,27 +325,33 @@ class LexCQLValidatorV0_3(Validator):
217
325
  conformance validation or try to gather as many infos
218
326
  as possible. Will be available in ``.errors`` attribute.
219
327
  Defaults to True.
328
+ warnings_as_errors: Handle warnings the same as errors. May raise ``SpecificationValidationError``.
220
329
  """
221
330
 
222
331
  super().__init__(
223
332
  query=query,
224
333
  case_insensitive=case_insensitive,
225
334
  raise_at_first_violation=raise_at_first_violation,
335
+ warnings_as_errors=warnings_as_errors,
226
336
  )
227
337
 
228
- self.stack: Deque[QueryNode] = deque()
229
- """(internal) Query node stack to keep track of parents."""
230
-
231
338
  self.allowed_indexes = allowed_indexes
232
339
  """User-defined list of known indexes (LexCQL field names)"""
340
+ self.allowed_modifiers = allowed_modifiers
341
+ """User-defined list of known modifiers (LexCQL relation modifiers)"""
233
342
 
234
343
  if self.case_insensitive:
235
344
  # NOTE: overrides on instance (not on class level)
236
345
  self.KNOWN_INDEXES = list(map(str.lower, self.KNOWN_INDEXES))
346
+ self.KNOWN_RELATIONS = list(map(str.lower, self.KNOWN_RELATIONS))
237
347
  self.KNOWN_MODIFIERS = list(map(str.lower, self.KNOWN_MODIFIERS))
348
+ self.MUTUALLY_EXCLUSIVE_MODIFIERS = [set(map(str.lower, ms)) for ms in self.MUTUALLY_EXCLUSIVE_MODIFIERS]
238
349
 
239
350
  if self.allowed_indexes:
240
351
  self.allowed_indexes = list(map(str.lower, self.allowed_indexes))
352
+ if self.allowed_modifiers:
353
+ self.allowed_modifiers = list(map(str.lower, self.allowed_modifiers))
354
+ # TODO: update self.MUTUALLY_EXCLUSIVE_MODIFIERS?
241
355
 
242
356
  # ----------------------------------------------------
243
357
 
@@ -257,29 +371,62 @@ class LexCQLValidatorV0_3(Validator):
257
371
 
258
372
  # TODO: check `search_term` against relations/modifiers? (regex/masked)
259
373
 
260
- self.stack.append(node)
374
+ # warn about single quotes (not working being quotes?)
375
+ search_term = node.search_term
376
+ if search_term.startswith("'") and search_term.endswith("'"):
377
+ self.validation_warning(
378
+ node,
379
+ (
380
+ f"Search term {search_term!r} is enclosed with single quotes [']."
381
+ ' Single quotes are not used quoting, only double qoutes ["]!'
382
+ " Endpoint may include the literal single quote when searching."
383
+ ),
384
+ )
385
+
261
386
  super().visit_SearchClause(node)
262
- self.stack.pop()
263
387
 
264
388
  def visit_Relation(self, node: Relation):
265
389
  # parent = self.stack[-1]
266
390
 
267
391
  relation = node.relation
268
- if relation is not None:
269
- if self.case_insensitive:
270
- relation = relation.lower()
271
- if relation not in self.KNOWN_RELATIONS:
272
- self.validation_error(node, f"Relation '{node.relation}' is unspecified!")
392
+ if self.case_insensitive:
393
+ relation = relation.lower()
394
+ if relation not in self.KNOWN_RELATIONS:
395
+ self.validation_error(node, f"Relation '{node.relation}' is unspecified!")
273
396
 
274
- if relation == "is" and node.modifiers:
397
+ # check modifiers
398
+ if node.modifiers:
399
+ if relation == "is":
275
400
  self.validation_error(node, f"Relation '{node.relation}' does not support any modifiers!")
401
+ # TODO: relation == "==", what modifiers make sense here?
402
+
403
+ # check duplicate modifiers (should not be useful in any scenario imaginable)
404
+ modifier_names: Set[str] = set()
405
+ for modifier in node.modifiers:
406
+ modifier_name = modifier.name
407
+ if self.case_insensitive:
408
+ modifier_name = modifier_name.lower()
409
+ if modifier_name in modifier_names:
410
+ self.validation_warning(
411
+ node, f"Relation '{node.relation}' has duplicate modifier '{modifier.name}'?"
412
+ )
413
+ modifier_names.add(modifier_name)
414
+
415
+ # check modifiers that ignore/respect do not appear together
416
+ if len(modifier_names) >= 2:
417
+ for i, excl_set in enumerate(self.MUTUALLY_EXCLUSIVE_MODIFIERS):
418
+ intersection = excl_set & modifier_names
419
+ if len(intersection) > 1:
420
+ self.validation_warning(
421
+ node,
422
+ (
423
+ f"Relation '{node.relation}' uses mutually exclusive modifiers"
424
+ f" {sorted(modifier_names)!r} (not allowed together "
425
+ f"{sorted(LexCQLValidatorV0_3.MUTUALLY_EXCLUSIVE_MODIFIERS[i])!r})!"
426
+ ),
427
+ )
276
428
 
277
- # TODO: check modifiers that ignore/respect do not appear together
278
- # TODO: masked/unmasked/regex should not appear together
279
-
280
- self.stack.append(node)
281
429
  super().visit_Relation(node)
282
- self.stack.pop()
283
430
 
284
431
  def visit_Modifier(self, node: Modifier):
285
432
  # parent = self.stack[-1]
@@ -287,24 +434,36 @@ class LexCQLValidatorV0_3(Validator):
287
434
  name = node.name
288
435
  if self.case_insensitive:
289
436
  name = name.lower()
290
-
291
- if name not in self.KNOWN_MODIFIERS:
292
- self.validation_error(node, f"Modifier '{node.name}' is unspecified!")
437
+ if self.allowed_modifiers:
438
+ if name not in self.allowed_modifiers:
439
+ self.validation_error(
440
+ node, f"Unknown modifier '{node.name}' (only allowed: {self.allowed_modifiers!r})!"
441
+ )
442
+ else:
443
+ if name not in self.KNOWN_MODIFIERS:
444
+ self.validation_error(node, f"Modifier '{node.name}' is unspecified!")
293
445
 
294
446
  # check against parent? --> visit_Relation "is" check
295
447
 
296
448
  relation = node.relation
297
449
  if relation and name != "lang":
298
- self.validation_error(node, f"Modifier '{node.name}' does not support any extra relation!")
450
+ if self.allowed_modifiers and name in self.allowed_modifiers and name not in self.KNOWN_MODIFIERS:
451
+ self.validation_warning(node, f"Custom modifier '{node.name}' may not support any extra relation?")
452
+ else:
453
+ self.validation_error(node, f"Modifier '{node.name}' does not support any extra relation!")
454
+
455
+ if not relation and name == "lang":
456
+ self.validation_error(node, f"Modifier '{node.name}' requires a relation value, e.g. 'lang=deu'.")
457
+
458
+ if relation and relation != "=":
459
+ self.validation_error(node, f"Modifier '{node.name}' uses unspecified relation: {relation!r}!")
299
460
 
300
- if relation and name == "lang":
301
- # TODO: do a valid language code check
302
- pass
461
+ # TODO: check valid `node.value` for lang modifier?
303
462
 
304
463
 
305
464
  # ---------------------------------------------------------------------------
306
465
 
307
- VALIDATORS: Dict[str, Validator] = {
466
+ VALIDATORS: Dict[str, Type[Validator]] = {
308
467
  LexCQLValidatorV0_3.SPECIFICATION_VERSION: LexCQLValidatorV0_3,
309
468
  }
310
469
  """Mapping of all known LexCQL Validators. Uses the LexCQL specification