django-gisserver 1.5.0__py3-none-any.whl → 2.1__py3-none-any.whl

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.
Files changed (77) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +34 -8
  2. django_gisserver-2.1.dist-info/RECORD +68 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/compat.py +23 -0
  6. gisserver/conf.py +7 -0
  7. gisserver/crs.py +401 -0
  8. gisserver/db.py +126 -51
  9. gisserver/exceptions.py +132 -4
  10. gisserver/extensions/__init__.py +4 -0
  11. gisserver/{parsers/fes20 → extensions}/functions.py +131 -31
  12. gisserver/extensions/queries.py +266 -0
  13. gisserver/features.py +253 -181
  14. gisserver/geometries.py +64 -311
  15. gisserver/management/__init__.py +0 -0
  16. gisserver/management/commands/__init__.py +0 -0
  17. gisserver/management/commands/loadgeojson.py +311 -0
  18. gisserver/operations/base.py +130 -312
  19. gisserver/operations/wfs20.py +399 -375
  20. gisserver/output/__init__.py +14 -49
  21. gisserver/output/base.py +198 -144
  22. gisserver/output/csv.py +78 -75
  23. gisserver/output/geojson.py +37 -37
  24. gisserver/output/gml32.py +287 -259
  25. gisserver/output/iters.py +207 -0
  26. gisserver/output/results.py +73 -61
  27. gisserver/output/stored.py +143 -0
  28. gisserver/output/utils.py +81 -169
  29. gisserver/output/xmlschema.py +85 -46
  30. gisserver/parsers/__init__.py +10 -10
  31. gisserver/parsers/ast.py +426 -0
  32. gisserver/parsers/fes20/__init__.py +89 -31
  33. gisserver/parsers/fes20/expressions.py +172 -58
  34. gisserver/parsers/fes20/filters.py +116 -45
  35. gisserver/parsers/fes20/identifiers.py +66 -28
  36. gisserver/parsers/fes20/lookups.py +146 -0
  37. gisserver/parsers/fes20/operators.py +417 -161
  38. gisserver/parsers/fes20/sorting.py +113 -34
  39. gisserver/parsers/gml/__init__.py +17 -25
  40. gisserver/parsers/gml/base.py +36 -15
  41. gisserver/parsers/gml/geometries.py +105 -44
  42. gisserver/parsers/ows/__init__.py +25 -0
  43. gisserver/parsers/ows/kvp.py +198 -0
  44. gisserver/parsers/ows/requests.py +160 -0
  45. gisserver/parsers/query.py +179 -0
  46. gisserver/parsers/values.py +87 -4
  47. gisserver/parsers/wfs20/__init__.py +39 -0
  48. gisserver/parsers/wfs20/adhoc.py +253 -0
  49. gisserver/parsers/wfs20/base.py +148 -0
  50. gisserver/parsers/wfs20/projection.py +103 -0
  51. gisserver/parsers/wfs20/requests.py +483 -0
  52. gisserver/parsers/wfs20/stored.py +193 -0
  53. gisserver/parsers/xml.py +261 -0
  54. gisserver/projection.py +367 -0
  55. gisserver/static/gisserver/index.css +20 -4
  56. gisserver/templates/gisserver/base.html +12 -0
  57. gisserver/templates/gisserver/index.html +9 -15
  58. gisserver/templates/gisserver/service_description.html +12 -6
  59. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  60. gisserver/templates/gisserver/wfs/feature_field.html +3 -3
  61. gisserver/templates/gisserver/wfs/feature_type.html +35 -13
  62. gisserver/templatetags/gisserver_tags.py +20 -0
  63. gisserver/types.py +445 -313
  64. gisserver/views.py +227 -62
  65. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  66. gisserver/parsers/base.py +0 -149
  67. gisserver/parsers/fes20/query.py +0 -285
  68. gisserver/parsers/tags.py +0 -102
  69. gisserver/queries/__init__.py +0 -37
  70. gisserver/queries/adhoc.py +0 -185
  71. gisserver/queries/base.py +0 -186
  72. gisserver/queries/projection.py +0 -240
  73. gisserver/queries/stored.py +0 -206
  74. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  75. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  76. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
  77. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
@@ -1,66 +1,89 @@
1
1
  """These classes map to the FES 2.0 specification for operators.
2
2
  The class names and attributes are identical to those in the FES spec.
3
+
4
+ Inheritance structure:
5
+
6
+ * :class:`Operator`
7
+
8
+ * :class:`IdOperator` chains ``<fes:ResourceId>``.
9
+ * :class:`NonIdOperator`
10
+
11
+ * :class:`ComparisonOperator`
12
+
13
+ * :class:`BinaryComparisonOperator` for :class:`BinaryComparisonName` tags like ``<fes:PropertyIsEqualTo>``.
14
+ * :class:`BetweenComparisonOperator` for ``<fes:PropertyIsBetween>``
15
+ * :class:`LikeOperator` for ``<fes:PropertyIsLike>``.
16
+ * :class:`NilOperator` for ``<fes:PropertyIsNil>``.
17
+ * :class:`NullOperator` for ``<fes:PropertyIsNill>``.
18
+
19
+ * :class:`SpatialOperator`
20
+
21
+ * :class:`DistanceOperator` for the :class:`DistanceOperator` tags: ``<fes:DWithin>`` and ``<fes:Beyond>``.
22
+ * :class:`BinarySpatialOperator` for :class:`SpatialOperatorName` tags like ``<fes:BBOX>``.
23
+
24
+ * :class:`TemporalOperator` for :class:`TemporalOperatorName` tags like ``<fes:After>``.
25
+ * :class:`LogicalOperator`
26
+
27
+ * :class:`BinaryLogicOperator` for the :class:`BinaryLogicType` tags: ``<fes:And>`` and ``<fes:Or>``.
28
+ * :class:`UnaryLogicOperator` for the :class:`UnaryLogicType` tag: ``<fes:Not>``.
29
+
30
+ * :class:`ExtensionOperator` for custom additions.
3
31
  """
4
32
 
5
33
  from __future__ import annotations
6
34
 
35
+ import logging
7
36
  import operator
8
37
  from dataclasses import dataclass, field
9
38
  from decimal import Decimal
10
39
  from enum import Enum
11
40
  from functools import cached_property, reduce
12
41
  from itertools import groupby
13
- from typing import Any, Union
14
- from xml.etree.ElementTree import Element, QName
42
+ from typing import ClassVar, Union
15
43
 
16
- from django.conf import settings
17
44
  from django.contrib.gis import measure
18
45
  from django.db.models import Q
19
46
 
20
- from gisserver.exceptions import ExternalParsingError, OperationProcessingFailed
47
+ from gisserver.exceptions import (
48
+ ExternalParsingError,
49
+ InvalidParameterValue,
50
+ OperationProcessingFailed,
51
+ )
21
52
  from gisserver.parsers import gml
22
- from gisserver.parsers.base import BaseNode, TagNameEnum, tag_registry
23
- from gisserver.parsers.tags import expect_children, expect_tag, get_attribute, get_child
24
- from gisserver.types import FES20
25
-
26
- from .expressions import Expression, Literal, RhsTypes, ValueReference
53
+ from gisserver.parsers.ast import (
54
+ AstNode,
55
+ TagNameEnum,
56
+ expect_children,
57
+ expect_tag,
58
+ tag_registry,
59
+ )
60
+ from gisserver.parsers.query import CompiledQuery, RhsTypes, ScalarTypes
61
+ from gisserver.parsers.values import fix_type_name
62
+ from gisserver.parsers.xml import NSElement, xmlns
63
+ from gisserver.types import GeometryXsdElement, XPathMatch
64
+
65
+ from .expressions import Expression, Literal, ValueReference
27
66
  from .identifiers import Id
28
- from .query import CompiledQuery
29
-
30
- SpatialDescription = Union[gml.GM_Object, gml.GM_Envelope, ValueReference]
31
- TemporalOperand = Union[gml.TM_Object, ValueReference]
32
-
67
+ from .lookups import ARRAY_LOOKUPS # also registers the lookups.
33
68
 
34
- # Define interface for any class that has "build_rhs()"
35
- try:
36
- from typing import Protocol
37
- except ImportError:
38
- HasBuildRhs = Any # Python 3.7 and below
39
- else:
69
+ logger = logging.getLogger(__name__)
40
70
 
41
- class HasBuildRhs(Protocol):
42
- def build_rhs(self, compiler) -> RhsTypes: ...
71
+ #: Define the types that a ``<gml:SpatialDescription>`` can be:
72
+ SpatialDescription = Union[gml.GM_Object, gml.GM_Envelope, ValueReference]
43
73
 
74
+ #: Define the types that a ``<gml:TemporalOperand>`` can be:
75
+ TemporalOperand = Union[gml.TM_Object, ValueReference]
44
76
 
45
- if "django.contrib.postgres" in settings.INSTALLED_APPS:
46
- # Comparisons with array fields go through a separate ORM lookup expression,
47
- # so these can check whether ANY element matches in the array.
48
- # This gives consistency between other repeated elements (e.g. M2M, reverse FK)
49
- # where the whole object is returned when one of the sub-objects match.
50
- ARRAY_LOOKUPS = {
51
- "exact": "fes_anyexact",
52
- "fes_notequal": "fes_anynotequal",
53
- "lt": "fes_anylt",
54
- "lte": "fes_anylte",
55
- "gt": "fes_anygt",
56
- "gte": "fes_anygte",
57
- }
58
- else:
59
- ARRAY_LOOKUPS = None
77
+ # Fully qualified tag names
78
+ FES_VALUE_REFERENCE = xmlns.fes20.qname("ValueReference")
79
+ FES_DISTANCE = xmlns.fes20.qname("Distance")
80
+ FES_LOWER_BOUNDARY = xmlns.fes20.qname("LowerBoundary")
81
+ FES_UPPER_BOUNDARY = xmlns.fes20.qname("UpperBoundary")
82
+ FES1_PROPERTY_NAME = xmlns.fes20.qname("PropertyName") # old tag sometimes used by clients
60
83
 
61
84
 
62
85
  class MatchAction(Enum):
63
- """Values for the 'matchAction' attribute of the BinaryComparisonOperator."""
86
+ """Values for the 'matchAction' attribute of the :class:`BinaryComparisonOperator`."""
64
87
 
65
88
  All = "All"
66
89
  Any = "Any"
@@ -73,7 +96,7 @@ class MatchAction(Enum):
73
96
 
74
97
  class BinaryComparisonName(TagNameEnum):
75
98
  """XML tag names for value comparisons.
76
- Values are the used field lookup.
99
+ This also maps their names to ORM field lookups.
77
100
  """
78
101
 
79
102
  PropertyIsEqualTo = "exact"
@@ -84,15 +107,23 @@ class BinaryComparisonName(TagNameEnum):
84
107
  PropertyIsGreaterThanOrEqualTo = "gte"
85
108
 
86
109
 
110
+ REVERSE_LOOKUPS = {
111
+ "gt": "lt",
112
+ "gte": "lte",
113
+ "lt": "gt",
114
+ "lte": "gte",
115
+ }
116
+
117
+
87
118
  class DistanceOperatorName(TagNameEnum):
88
- """XML tag names for distance operators"""
119
+ """XML tag names mapped to distance operators for the ORM."""
89
120
 
90
121
  Beyond = "fes_beyond" # using __distance_gt=.. would be slower.
91
122
  DWithin = "dwithin" # ST_DWithin uses indices, distance_lte does not.
92
123
 
93
124
 
94
125
  class SpatialOperatorName(TagNameEnum):
95
- """XML tag names for geometry operators.
126
+ """XML tag names mapped to geometry operators.
96
127
 
97
128
  The values correspond with GeoDjango operators. So a ``BBOX`` query
98
129
  will translate into ``geometry__intersects=Polygon(...)``.
@@ -103,18 +134,18 @@ class SpatialOperatorName(TagNameEnum):
103
134
  # BBOX can either be implemented using bboverlaps (more efficient), or the
104
135
  # more correct "intersects" option (e.g. a line near the box would match otherwise).
105
136
  BBOX = "intersects" # ISO version: "NOT DISJOINT"
106
- Equals = "equals" # Test whether t geometries are topologically equal
137
+ Equals = "equals" # Test whether two geometries are topologically equal
107
138
  Disjoint = "disjoint" # Tests whether two geometries are disjoint (do not interact)
108
139
  Intersects = "intersects" # Tests whether two geometries intersect
109
- Touches = "touches" # Tests whether two geometries touch
110
- Crosses = "crosses" # Tests whether two geometries cross
111
- Within = "within" # Tests whether a geometry is within another one
112
- Contains = "contains" # Tests whether a geometry contains another one
140
+ Touches = "touches" # Tests whether two geometries touch (e.g. country border).
141
+ Crosses = "crosses" # Tests whether two geometries cross (e.g. two streets).
142
+ Within = "within" # Tests a geometry is within another one (e.g. city within province).
143
+ Contains = "contains" # Tests a geometry contains another one (e.g. province contains city).
113
144
  Overlaps = "overlaps" # Test whether two geometries overlap
114
145
 
115
146
 
116
147
  class TemporalOperatorName(TagNameEnum):
117
- """XML tag names for datetime operators.
148
+ """XML tag names mapped to datetime operators.
118
149
 
119
150
  Explanation here: http://old.geotools.org/Temporal-Filters_211091519.html
120
151
  and: https://github.com/geotools/geotools/wiki/temporal-filters
@@ -149,27 +180,46 @@ class UnaryLogicType(TagNameEnum):
149
180
 
150
181
 
151
182
  @dataclass
152
- class Measure(BaseNode):
153
- """The <fes:Distance uom="...> element."""
183
+ @tag_registry.register("Distance")
184
+ class Measure(AstNode):
185
+ """A measurement for a distance element.
186
+
187
+ This parses and handles the syntax::
188
+
189
+ <fes:Distance uom="...">value</fes:Distance>
190
+
191
+ This element is used within the :class:`DistanceOperator` that
192
+ handles the ``<fes:DWithin>`` and ``<fes:Beyond>`` tags.
154
193
 
155
- xml_ns = FES20
194
+ The "unit of measurement" (uom) supports most standard units, like meters (``m``),
195
+ kilometers (``km``), nautical mile (``nm``), miles (``mi``), inches (``inch``).
196
+ The full list can be found at: https://docs.djangoproject.com/en/5.1/ref/contrib/gis/measure/#supported-units
197
+ """
198
+
199
+ xml_ns = xmlns.fes20
156
200
 
157
201
  value: Decimal
158
202
  uom: str # Unit of measurement, fes20:UomSymbol | fes20:UomURI
159
203
 
160
204
  @classmethod
161
- @expect_tag(FES20, "Distance")
162
- def from_xml(cls, element: Element):
163
- return cls(value=Decimal(element.text), uom=get_attribute(element, "uom"))
205
+ @expect_tag(xmlns.fes20, "Distance")
206
+ def from_xml(cls, element: NSElement):
207
+ return cls(value=Decimal(element.text), uom=element.get_str_attribute("uom"))
164
208
 
165
209
  def build_rhs(self, compiler) -> measure.Distance:
166
210
  return measure.Distance(default_unit=self.uom, **{self.uom: self.value})
167
211
 
168
212
 
169
- class Operator(BaseNode):
170
- """Abstract base class, as defined by FES spec."""
213
+ class Operator(AstNode):
214
+ """Abstract base class, as defined by FES spec.
171
215
 
172
- xml_ns = FES20
216
+ This base class is also used in parsing; for example the ``<fes:Filter>``
217
+ tag only allows ``Operator`` and ``Expression`` subclasses as allowed arguments.
218
+ Having all those classes as Python types, makes it very easy to validate
219
+ whether a given child element is the expected node type.
220
+ """
221
+
222
+ xml_ns = xmlns.fes20
173
223
 
174
224
  def build_query(self, compiler: CompiledQuery) -> Q | None:
175
225
  raise NotImplementedError(f"Using {self.__class__.__name__} is not supported yet.")
@@ -177,12 +227,20 @@ class Operator(BaseNode):
177
227
 
178
228
  @dataclass
179
229
  class IdOperator(Operator):
180
- """List of ResourceId objects"""
230
+ """List of :class:`~gisserver.parsers.fes20.identifers.ResourceId`` objects.
231
+
232
+ A ``<fes:Filter>`` only has a single predicate.
233
+ Hence, this operator is used to wrap the ``<fes:ResourceId>`` elements in the syntax::
234
+
235
+ <fes:Filter>
236
+ <fes:ResourceId rid="typename.123" />
237
+ <fes:ResourceId rid="typename.345" />
238
+ </fes:Filter>
239
+ """
181
240
 
182
241
  id: list[Id]
183
242
 
184
- @property
185
- def type_names(self) -> list[str]:
243
+ def get_type_names(self) -> list[str]:
186
244
  """Provide a list of all type names accessed by this operator"""
187
245
  return [type_name for type_name in self.grouped_ids if type_name is not None]
188
246
 
@@ -192,7 +250,7 @@ class IdOperator(Operator):
192
250
  ids = sorted(self.id, key=operator.attrgetter("rid"))
193
251
  return {
194
252
  type_name: list(items)
195
- for type_name, items in groupby(ids, key=operator.attrgetter("type_name"))
253
+ for type_name, items in groupby(ids, key=lambda id: id.get_type_name())
196
254
  }
197
255
 
198
256
  def build_query(self, compiler):
@@ -207,8 +265,20 @@ class IdOperator(Operator):
207
265
  compiler.mark_empty()
208
266
  return
209
267
 
268
+ type_names = {ft.xml_name for ft in compiler.feature_types}
210
269
  for type_name, items in self.grouped_ids.items():
211
- ids_subset = reduce(operator.or_, [id.build_query(compiler=None) for id in items])
270
+ type_name = fix_type_name(type_name, compiler.feature_types[0].xml_namespace)
271
+ if type_name not in type_names:
272
+ # When ResourceId + typenames is defined, it should be a value from typenames see WFS spec 7.9.2.4.1
273
+ # This is tested here for RESOURCEID with a typename.id format.
274
+ # Otherwise, this breaks the CITE RESOURCEID=test-UUID parameter.
275
+ raise InvalidParameterValue(
276
+ "When TYPENAMES and RESOURCEID are combined, "
277
+ "the RESOURCEID type should be included in TYPENAMES.",
278
+ locator="resourceId",
279
+ )
280
+
281
+ ids_subset = reduce(operator.or_, [id.build_query(compiler) for id in items])
212
282
  compiler.add_lookups(ids_subset, type_name=type_name)
213
283
 
214
284
 
@@ -216,10 +286,14 @@ class NonIdOperator(Operator):
216
286
  """Abstract base class, as defined by FES spec.
217
287
 
218
288
  This is used for nearly all operators,
219
- except those that have <fes:ResourceId> elements as children.
289
+ except those that have ``<fes:ResourceId>`` elements as children.
290
+
291
+ Some operators, such as the ``<fes:And>``, ``<fes:Or>`` and ``<fes:Not>`` operators
292
+ explicitly support only ``NonIdOperator`` elements as arguments.
293
+ Hence, having this base class as Python type simplifies parsing.
220
294
  """
221
295
 
222
- _source = None
296
+ _source = None # often declared as non-comparing in dataclasses below.
223
297
  allow_geometries = False
224
298
 
225
299
  def build_compare(
@@ -236,18 +310,28 @@ class NonIdOperator(Operator):
236
310
  # lhs and rhs are allowed to be reversed. However, the SQL compiler
237
311
  # works much simpler when Django can predict the actual data type.
238
312
  if isinstance(lhs, Literal) and isinstance(rhs, ValueReference):
313
+ logger.debug("Filter switches lhs/rhs for %s %s %s", lhs.raw_value, lookup, rhs.xpath)
239
314
  lhs, rhs = rhs, lhs
315
+ lookup = REVERSE_LOOKUPS.get(lookup, lookup) # >= should become <=
240
316
 
241
- if compiler.feature_type is not None:
242
- lookup = self.validate_comparison(compiler, lhs, lookup, rhs)
317
+ lookup = self.validate_comparison(compiler, lhs, lookup, rhs)
243
318
 
244
319
  # Build Django Q-object
245
- lhs = lhs.build_lhs(compiler)
320
+ lhs_orm_name = lhs.build_lhs(compiler)
246
321
 
247
322
  if isinstance(rhs, (Expression, gml.GM_Object)):
248
323
  rhs = rhs.build_rhs(compiler)
249
-
250
- comparison = Q(**{f"{lhs}__{lookup}": rhs})
324
+ if (
325
+ isinstance(lhs, ValueReference)
326
+ and isinstance(rhs, ScalarTypes)
327
+ and lookup != "isnull"
328
+ ):
329
+ # Building the expression resolved as a scalar after all (for BinaryOperator).
330
+ # It allows performing a better type check:
331
+ xsd_element = lhs.parse_xpath(compiler.feature_types).child
332
+ xsd_element.to_python(rhs)
333
+
334
+ comparison = Q(**{f"{lhs_orm_name}__{lookup}": rhs})
251
335
  return compiler.apply_extra_lookups(comparison)
252
336
 
253
337
  def validate_comparison(
@@ -255,17 +339,27 @@ class NonIdOperator(Operator):
255
339
  compiler: CompiledQuery,
256
340
  lhs: Expression,
257
341
  lookup: str,
258
- rhs: Expression | RhsTypes,
259
- ):
342
+ rhs: Expression | gml.GM_Object | RhsTypes,
343
+ ) -> str:
260
344
  """Validate whether a given comparison is even possible.
261
- Where needed, the lhs/rhs are already ordered in a logical sequence.
345
+
346
+ For example, comparisons like ``name == "test"`` are fine,
347
+ but ``geometry < 4`` or ``datefield == 35.2`` raise an error.
348
+
349
+ :param compiler: The object that holds the intermediate state
350
+ :param lhs: The left-hand-side of the comparison (e.g. the element).
351
+ :param lookup: The ORM lookup expression being used (e.g. ``equals`` or ``fes_like``).
352
+ :param rhs: The right-hand-side of the comparison (e.g. the value).
262
353
  """
354
+ if isinstance(lhs, Literal) and isinstance(rhs, ValueReference):
355
+ lhs, rhs = rhs, lhs
356
+
263
357
  if isinstance(lhs, ValueReference):
264
- xsd_element = compiler.feature_type.resolve_element(lhs.xpath).child
358
+ xsd_element = lhs.parse_xpath(compiler.feature_types).child
265
359
  tag = self._source if self._source is not None else None
266
360
 
267
361
  # e.g. deny <PropertyIsLessThanOrEqualTo> against <gml:boundedBy>
268
- if xsd_element.is_geometry and not self.allow_geometries:
362
+ if xsd_element.type.is_geometry and not self.allow_geometries:
269
363
  raise OperationProcessingFailed(
270
364
  f"Operator '{tag}' does not support comparing"
271
365
  f" geometry properties: '{xsd_element.xml_name}'.",
@@ -273,29 +367,33 @@ class NonIdOperator(Operator):
273
367
  status_code=400, # not HTTP 500 here. Spec allows both.
274
368
  )
275
369
 
276
- if isinstance(rhs, Literal):
277
- # Since the element is resolved, inform the Literal how to parse the value.
278
- # This avoids various validation errors along the path.
279
- rhs.bind_type(xsd_element.type)
280
-
281
- # When a common case of value comparison is done, the inputs
282
- # can be validated before the ORM query is constructed.
283
- xsd_element.validate_comparison(rhs.raw_value, lookup=lookup, tag=tag)
284
-
285
370
  # Checking scalar values against array fields will fail.
286
371
  # However, to make the queries consistent with other unbounded types (i.e. M2M fields),
287
372
  # it makes sense to return an object when *one* entry in the array matches.
288
373
  if xsd_element.is_array:
289
374
  try:
290
- return ARRAY_LOOKUPS[lookup]
375
+ lookup = ARRAY_LOOKUPS[lookup]
291
376
  except KeyError:
377
+ logger.debug("No array lookup known for %s", lookup)
292
378
  raise OperationProcessingFailed(
293
- "filter",
294
379
  f"Operator '{tag}' is not supported for "
295
380
  f"the '{xsd_element.name}' property.",
381
+ locator="filter",
296
382
  status_code=400, # not HTTP 500 here. Spec allows both.
297
383
  ) from None
298
384
 
385
+ if isinstance(rhs, Literal):
386
+ # Since the element is resolved, inform the Literal how to parse the value.
387
+ # This avoids various validation errors along the path.
388
+ rhs.bind_type(xsd_element.type)
389
+
390
+ # When a common case of value comparison is done, the inputs
391
+ # can be validated before the ORM query is constructed.
392
+ xsd_element.validate_comparison(rhs.raw_value, lookup=lookup, tag=tag)
393
+ elif isinstance(rhs, ScalarTypes) and lookup != "isnull":
394
+ # Can still compare two elements, e.g. date >= ((2020 - 12) - 10) by QGis
395
+ xsd_element.validate_comparison(rhs, lookup=lookup, tag=tag)
396
+
299
397
  return lookup
300
398
 
301
399
  def build_compare_between(
@@ -303,12 +401,11 @@ class NonIdOperator(Operator):
303
401
  compiler: CompiledQuery,
304
402
  lhs: Expression,
305
403
  lookup: str,
306
- rhs: tuple[HasBuildRhs, HasBuildRhs],
404
+ rhs: tuple[Expression | ValueReference | gml.GM_Object, Expression | Measure],
307
405
  ) -> Q:
308
406
  """Use the value in comparison with 2 other values (e.g. between query)"""
309
- if compiler.feature_type is not None:
310
- self.validate_comparison(compiler, lhs, lookup, rhs[0])
311
- self.validate_comparison(compiler, lhs, lookup, rhs[1])
407
+ self.validate_comparison(compiler, lhs, lookup, rhs[0])
408
+ self.validate_comparison(compiler, lhs, lookup, rhs[1])
312
409
 
313
410
  field_name = lhs.build_lhs(compiler)
314
411
  comparison = Q(
@@ -327,9 +424,21 @@ class SpatialOperator(NonIdOperator):
327
424
 
328
425
 
329
426
  @dataclass
330
- @tag_registry.register_names(DistanceOperatorName) # <Beyond>, <DWithin>
427
+ @tag_registry.register("Beyond")
428
+ @tag_registry.register("DWithin")
331
429
  class DistanceOperator(SpatialOperator):
332
- """Comparing the distance to a geometry."""
430
+ """Comparing the distance to a geometry.
431
+
432
+ This parses and handles the syntax::
433
+
434
+ <fes:DWithin>
435
+ <fes:ValueReference>geometry</fes:ValueReference>
436
+ <gml:Point srsDimension="2">
437
+ <gml:pos>43.55749 1.525864</gml:pos>
438
+ </gml:Point>
439
+ <fes:Distance oum="m:>100</fes:Distance>
440
+ </fes:DWithin>
441
+ """
333
442
 
334
443
  allow_geometries = True # override static attribute
335
444
 
@@ -337,11 +446,11 @@ class DistanceOperator(SpatialOperator):
337
446
  operatorType: DistanceOperatorName
338
447
  geometry: gml.GM_Object
339
448
  distance: Measure
340
- _source: str | None = field(compare=False, default=None)
449
+ _source: str | None = field(compare=False, default=None, repr=False)
341
450
 
342
451
  @classmethod
343
452
  @expect_children(3, ValueReference, gml.GM_Object, Measure)
344
- def from_xml(cls, element: Element):
453
+ def from_xml(cls, element: NSElement):
345
454
  geometries = gml.find_gml_nodes(element)
346
455
  if not geometries:
347
456
  raise ExternalParsingError(f"Missing gml element in <{element.tag}>")
@@ -349,10 +458,10 @@ class DistanceOperator(SpatialOperator):
349
458
  raise ExternalParsingError(f"Multiple gml elements found in <{element.tag}>")
350
459
 
351
460
  return cls(
352
- valueReference=ValueReference.from_xml(get_child(element, FES20, "ValueReference")),
461
+ valueReference=ValueReference.from_xml(element.find(FES_VALUE_REFERENCE)),
353
462
  operatorType=DistanceOperatorName.from_xml(element),
354
463
  geometry=gml.parse_gml_node(geometries[0]),
355
- distance=Measure.from_xml(get_child(element, FES20, "Distance")),
464
+ distance=Measure.from_xml(element.find(FES_DISTANCE)),
356
465
  _source=element.tag,
357
466
  )
358
467
 
@@ -366,19 +475,33 @@ class DistanceOperator(SpatialOperator):
366
475
 
367
476
 
368
477
  @dataclass
369
- @tag_registry.register_names(SpatialOperatorName) # <BBOX>, <Equals>, ...
478
+ @tag_registry.register(SpatialOperatorName) # <BBOX>, <Equals>, ...
370
479
  class BinarySpatialOperator(SpatialOperator):
371
- """A comparison of geometries using 2 values, e.g. A Within B."""
480
+ """A comparison of geometries using 2 values, e.g. A Within B.
481
+
482
+ This parses and handles the syntax, and its variants::
483
+
484
+ <fes:BBOX>
485
+ <fes:ValueReference>Geometry</fes:ValueReference>
486
+ <gml:Envelope srsName="http://www.opengis.net/def/crs/epsg/0/4326">
487
+ <gml:lowerCorner>13.0983 31.5899</gml:lowerCorner>
488
+ <gml:upperCorner>35.5472 42.8143</gml:upperCorner>
489
+ </gml:Envelope>
490
+ </fes:BBOX>
491
+
492
+ It also handles the ``<fes:Equals>``, ``<fes:Within>``, ``<fes:Intersects>``, etc..
493
+ that exist in the :class:`SpatialOperatorName` enum.
494
+ """
372
495
 
373
496
  allow_geometries = True # override static attribute
374
497
 
375
498
  operatorType: SpatialOperatorName
376
499
  operand1: ValueReference | None
377
500
  operand2: SpatialDescription
378
- _source: str | None = field(compare=False, default=None)
501
+ _source: str | None = field(compare=False, default=None, repr=False)
379
502
 
380
503
  @classmethod
381
- def from_xml(cls, element: Element):
504
+ def from_xml(cls, element: NSElement):
382
505
  operator_type = SpatialOperatorName.from_xml(element)
383
506
  if operator_type is SpatialOperatorName.BBOX and len(element) == 1:
384
507
  # For BBOX, the geometry operator is optional
@@ -392,16 +515,15 @@ class BinarySpatialOperator(SpatialOperator):
392
515
  return cls(
393
516
  operatorType=operator_type,
394
517
  operand1=ValueReference.from_xml(ref) if ref is not None else None,
395
- operand2=tag_registry.from_child_xml(
396
- geo, allowed_types=SpatialDescription.__args__ # get_args() in 3.8
397
- ),
518
+ operand2=tag_registry.node_from_xml(geo, allowed_types=SpatialDescription.__args__),
398
519
  _source=element.tag,
399
520
  )
400
521
 
401
522
  def build_query(self, compiler: CompiledQuery) -> Q:
402
523
  operant1 = self.operand1
403
524
  if operant1 is None:
404
- operant1 = ValueReference(xpath=compiler.feature_type.geometry_field.name)
525
+ # BBOX element points to the existing geometry element by default.
526
+ operant1 = _ResolvedValueReference(compiler.feature_types[0].main_geometry_element)
405
527
 
406
528
  return self.build_compare(
407
529
  compiler,
@@ -411,41 +533,105 @@ class BinarySpatialOperator(SpatialOperator):
411
533
  )
412
534
 
413
535
 
536
+ class _ResolvedValueReference(ValueReference):
537
+ """Internal ValueReference subclass that already knows which element it resolve to.
538
+ This avoids translating back and forth between an XPath path and the desired node.
539
+ """
540
+
541
+ def __init__(self, geo_element: GeometryXsdElement):
542
+ self.xpath = f"(mocked {geo_element.orm_path})" # keep __str__ and __repr__ happy.
543
+ self.xpath_ns_aliases = {}
544
+ self.geo_element = geo_element
545
+ self.orm_path = geo_element.orm_path
546
+
547
+ def parse_xpath(
548
+ self, feature_types: list, ns_aliases: dict[str, str] | None = None
549
+ ) -> XPathMatch:
550
+ return XPathMatch(feature_types[0], [self.geo_element], query="")
551
+
552
+ def build_lhs(self, compiler: CompiledQuery):
553
+ return self.orm_path
554
+
555
+
414
556
  @dataclass
415
- @tag_registry.register_names(TemporalOperatorName) # <After>, <Before>, ...
557
+ @tag_registry.register(TemporalOperatorName, hidden=True) # <After>, <Before>, ...
416
558
  class TemporalOperator(NonIdOperator):
417
- """Comparisons with dates"""
559
+ """Comparisons with dates.
560
+
561
+ For these operators, only the parsing is implemented.
562
+ These are not translated into ORM queries yet.
563
+
564
+ It supports a syntax such as::
565
+
566
+ <fes:TEquals>
567
+ <fes:ValueReference>SimpleTrajectory/gml:validTime/gml:TimeInstant</fes:ValueReference>
568
+ <gml:TimeInstant gml:id="TI1">
569
+ <gml:timePosition>2005-05-19T09:28:40Z</gml:timePosition>
570
+ </gml:TimeInstant>
571
+ </fes:TEquals>
572
+
573
+ or::
574
+
575
+ <fes:During>
576
+ <fes:ValueReference>SimpleTrajectory/gml:validTime/gml:TimeInstant</fes:ValueReference>
577
+ <gml:TimePeriod gml:id="TP1">
578
+ <gml:begin>
579
+ <gml:TimeInstant gml:id="TI1">
580
+ <gml:timePosition>2005-05-17T00:00:00Z</gml:timePosition>
581
+ </gml:TimeInstant>
582
+ </gml:begin>
583
+ <gml:end>
584
+ <gml:TimeInstant gml:id="TI2">
585
+ <gml:timePosition>2005-05-23T00:00:00Z</gml:timePosition>
586
+ </gml:TimeInstant>
587
+ </gml:end>
588
+ </gml:TimePeriod>
589
+ </fes:During>
590
+ """
418
591
 
419
592
  operatorType: TemporalOperatorName
420
593
  operand1: ValueReference
421
594
  operand2: TemporalOperand
422
- _source: str | None = field(compare=False, default=None)
595
+ _source: str | None = field(compare=False, default=None, repr=False)
423
596
 
424
597
  @classmethod
425
- @expect_children(2, ValueReference, TemporalOperand)
426
- def from_xml(cls, element: Element):
598
+ @expect_children(2, ValueReference, *TemporalOperand.__args__)
599
+ def from_xml(cls, element: NSElement):
427
600
  return cls(
428
601
  operatorType=TemporalOperatorName.from_xml(element),
429
602
  operand1=ValueReference.from_xml(element[0]),
430
- operand2=tag_registry.from_child_xml(
431
- element[1], allowed_types=TemporalOperand.__args__ # get_args() in 3.8
603
+ operand2=tag_registry.node_from_xml(
604
+ element[1], allowed_types=TemporalOperand.__args__
432
605
  ),
433
606
  _source=element.tag,
434
607
  )
435
608
 
436
609
 
437
610
  class ComparisonOperator(NonIdOperator):
438
- """Base class for comparisons"""
439
-
440
- # Start counting fresh here, to collect the capabilities
441
- # that are listed in the <fes20:ComparisonOperators> node:
442
- xml_tags = []
611
+ """Base class for comparisons.
612
+ This class name mirrors the fes-spec name,
613
+ and allows grouping various comparisons together.
614
+ """
443
615
 
444
616
 
445
617
  @dataclass
446
- @tag_registry.register_names(BinaryComparisonName) # <PropertyIs...>
618
+ @tag_registry.register(BinaryComparisonName) # <PropertyIs...>
447
619
  class BinaryComparisonOperator(ComparisonOperator):
448
- """A comparison between 2 values, e.g. A == B."""
620
+ """A comparison between 2 values, e.g. *A == B*.
621
+
622
+ This parses and handles the syntax::
623
+
624
+ <fes:PropertyIsEqualTo>
625
+ <fes:ValueReference>city/name</fes:ValueReference>
626
+ <fes:Literal>CloudCity</fes:Literal>
627
+ </fes:PropertyIsEqualTo>
628
+
629
+ ...and all variations (``<fes:PropertyIsLessThan>``, etc...)
630
+ that are listed in the :class:`BinaryComparisonName` enum.
631
+
632
+ Note that both arguments are expressions, so these can be value references, literals
633
+ or functions. The ``<fes:Literal>`` element may hold a GML element as its value.
634
+ """
449
635
 
450
636
  operatorType: BinaryComparisonName
451
637
  expression: tuple[Expression, Expression]
@@ -454,13 +640,13 @@ class BinaryComparisonOperator(ComparisonOperator):
454
640
  _source: str | None = field(compare=False, default=None)
455
641
 
456
642
  @classmethod
457
- @expect_children(2, Expression, Expression)
458
- def from_xml(cls, element: Element):
643
+ @expect_children(2, Expression, silent_allowed=(FES1_PROPERTY_NAME,))
644
+ def from_xml(cls, element: NSElement):
459
645
  return cls(
460
646
  operatorType=BinaryComparisonName.from_xml(element),
461
647
  expression=(
462
- Expression.from_child_xml(element[0]),
463
- Expression.from_child_xml(element[1]),
648
+ Expression.child_from_xml(element[0]),
649
+ Expression.child_from_xml(element[1]),
464
650
  ),
465
651
  matchCase=element.get("matchCase", True),
466
652
  matchAction=MatchAction(element.get("matchAction", default=MatchAction.Any)),
@@ -475,26 +661,36 @@ class BinaryComparisonOperator(ComparisonOperator):
475
661
  @dataclass
476
662
  @tag_registry.register("PropertyIsBetween")
477
663
  class BetweenComparisonOperator(ComparisonOperator):
478
- """Check whether a value is between two elements."""
664
+ """Check whether a value is between two elements.
665
+
666
+ This parses and handles the syntax::
667
+
668
+ <fes:PropertyIsBetween>
669
+ <fes:ValueReference>DEPTH</fes:ValueReference>
670
+ <fes:LowerBoundary><fes:Literal>100</fes:Literal></fes:LowerBoundary>
671
+ <fes:UpperBoundary><fes:Literal>200</fes:Literal></fes:UpperBoundary>
672
+ </fes:PropertyIsBetween>
673
+
674
+ Note that both boundary arguments receive expressions, so these can be value
675
+ references, literals or functions!
676
+ """
479
677
 
480
678
  expression: Expression
481
679
  lowerBoundary: Expression
482
680
  upperBoundary: Expression
483
- _source: str | None = field(compare=False, default=None)
681
+ _source: str | None = field(compare=False, default=None, repr=False)
484
682
 
485
683
  @classmethod
486
- @expect_children(3, Expression, "LowerBoundary", "UpperBoundary")
487
- def from_xml(cls, element: Element):
488
- if element[1].tag != QName(FES20, "LowerBoundary") or element[2].tag != QName(
489
- FES20, "UpperBoundary"
490
- ):
684
+ @expect_children(3, Expression, FES_LOWER_BOUNDARY, FES_UPPER_BOUNDARY)
685
+ def from_xml(cls, element: NSElement):
686
+ if (element[1].tag != FES_LOWER_BOUNDARY) or (element[2].tag != FES_UPPER_BOUNDARY):
491
687
  raise ExternalParsingError(
492
688
  f"{element.tag} should have 3 child nodes: "
493
689
  f"(expression), <LowerBoundary>, <UpperBoundary>"
494
690
  )
495
691
 
496
- lower = get_child(element, FES20, "LowerBoundary")
497
- upper = get_child(element, FES20, "UpperBoundary")
692
+ lower = element[1]
693
+ upper = element[2]
498
694
 
499
695
  if len(lower) != 1:
500
696
  raise ExternalParsingError(f"{lower.tag} should have 1 expression child node")
@@ -502,9 +698,9 @@ class BetweenComparisonOperator(ComparisonOperator):
502
698
  raise ExternalParsingError(f"{upper.tag} should have 1 expression child node")
503
699
 
504
700
  return cls(
505
- expression=Expression.from_child_xml(element[0]),
506
- lowerBoundary=Expression.from_child_xml(lower[0]),
507
- upperBoundary=Expression.from_child_xml(upper[0]),
701
+ expression=Expression.child_from_xml(element[0]),
702
+ lowerBoundary=Expression.child_from_xml(lower[0]),
703
+ upperBoundary=Expression.child_from_xml(upper[0]),
508
704
  _source=element.tag,
509
705
  )
510
706
 
@@ -520,26 +716,34 @@ class BetweenComparisonOperator(ComparisonOperator):
520
716
  @dataclass
521
717
  @tag_registry.register("PropertyIsLike")
522
718
  class LikeOperator(ComparisonOperator):
523
- """Perform wildcard matching."""
719
+ """Perform wildcard matching.
720
+
721
+ This parses and handles the syntax::
722
+
723
+ <fes:PropertyIsLike wildCard="*" singleChar="#" escapeChar="!">
724
+ <fes:ValueReference>last_name</fes:ValueReference>
725
+ <fes:Literal>John*</fes:Literal>
726
+ </fes:PropertyIsLike>
727
+ """
524
728
 
525
729
  expression: tuple[Expression, Expression]
526
730
  wildCard: str
527
731
  singleChar: str
528
732
  escapeChar: str
529
- _source: str | None = field(compare=False, default=None)
733
+ _source: str | None = field(compare=False, default=None, repr=False)
530
734
 
531
735
  @classmethod
532
- @expect_children(2, Expression, Expression)
533
- def from_xml(cls, element: Element):
736
+ @expect_children(2, Expression)
737
+ def from_xml(cls, element: NSElement):
534
738
  return cls(
535
739
  expression=(
536
- Expression.from_child_xml(element[0]),
537
- Expression.from_child_xml(element[1]),
740
+ Expression.child_from_xml(element[0]),
741
+ Expression.child_from_xml(element[1]),
538
742
  ),
539
743
  # These attributes are required by the WFS spec:
540
- wildCard=get_attribute(element, "wildCard"),
541
- singleChar=get_attribute(element, "singleChar"),
542
- escapeChar=get_attribute(element, "escapeChar"),
744
+ wildCard=element.get_str_attribute("wildCard"),
745
+ singleChar=element.get_str_attribute("singleChar"),
746
+ escapeChar=element.get_str_attribute("escapeChar"),
543
747
  _source=element.tag,
544
748
  )
545
749
 
@@ -558,7 +762,9 @@ class LikeOperator(ComparisonOperator):
558
762
 
559
763
  rhs = Literal(raw_value=value)
560
764
  else:
561
- raise ExternalParsingError(f"Expected a literal value for the {self.tag} operator.")
765
+ raise ExternalParsingError(
766
+ f"Expected a literal value for the {self.xml_name} operator."
767
+ )
562
768
 
563
769
  # Use the FesLike lookup
564
770
  return self.build_compare(compiler, lhs=lhs, lookup="fes_like", rhs=rhs)
@@ -568,18 +774,30 @@ class LikeOperator(ComparisonOperator):
568
774
  @tag_registry.register("PropertyIsNil")
569
775
  class NilOperator(ComparisonOperator):
570
776
  """Check whether the value evaluates to null/None.
571
- If the WFS returned a property element with <tns:p xsi:nil='true'>, this returns true.
777
+ If the WFS returned a property element with ``<app:field xsi:nil='true'>``, this returns true.
778
+
779
+ It parses and handles syntax such as::
780
+
781
+ <fes:PropertyIsNil>
782
+ <fes:ValueReference>city/name</fes:ValueReference>
783
+ </fes:PropertyIsNil>
784
+
785
+ Note that the provided argument can be any expression, not just a value reference.
786
+ Thus, it can also check whether a function returns null.
572
787
  """
573
788
 
574
789
  expression: Expression | None
575
790
  nilReason: str
576
- _source: str | None = field(compare=False, default=None)
791
+ _source: str | None = field(compare=False, default=None, repr=False)
792
+
793
+ # Allow checking whether the geometry is null
794
+ allow_geometries: ClassVar[bool] = True
577
795
 
578
796
  @classmethod
579
797
  @expect_children(1, Expression)
580
- def from_xml(cls, element: Element):
798
+ def from_xml(cls, element: NSElement):
581
799
  return cls(
582
- expression=Expression.from_child_xml(element[0]) if element else None,
800
+ expression=Expression.child_from_xml(element[0]) if element is not None else None,
583
801
  nilReason=element.get("nilReason"),
584
802
  _source=element.tag,
585
803
  )
@@ -593,16 +811,27 @@ class NilOperator(ComparisonOperator):
593
811
  @tag_registry.register("PropertyIsNull")
594
812
  class NullOperator(ComparisonOperator):
595
813
  """Check whether the property exists.
596
- If the WFS would not return the property element <tns:p>, this returns true.
814
+ If the WFS would not return the property element ``<app:field>``, this returns true.
815
+
816
+ It parses and handles syntax such as::
817
+
818
+ <fes:PropertyIsNull>
819
+ <fes:ValueReference>city/name</fes:ValueReference>
820
+ </fes:PropertyIsNull>
821
+
822
+ Note that the provided argument can be any expression, not just a value reference.
597
823
  """
598
824
 
599
825
  expression: Expression
600
- _source: str | None = field(compare=False, default=None)
826
+ _source: str | None = field(compare=False, default=None, repr=False)
827
+
828
+ # Allow checking whether the geometry is null
829
+ allow_geometries: ClassVar[bool] = True
601
830
 
602
831
  @classmethod
603
832
  @expect_children(1, Expression)
604
- def from_xml(cls, element: Element):
605
- return cls(expression=Expression.from_child_xml(element[0]), _source=element.tag)
833
+ def from_xml(cls, element: NSElement):
834
+ return cls(expression=Expression.child_from_xml(element[0]), _source=element.tag)
606
835
 
607
836
  def build_query(self, compiler: CompiledQuery) -> Q:
608
837
  # For now, the implementation is identical to PropertyIsNil.
@@ -613,24 +842,38 @@ class NullOperator(ComparisonOperator):
613
842
 
614
843
 
615
844
  class LogicalOperator(NonIdOperator):
616
- """Base class for AND, OR, NOT comparisons"""
845
+ """Base class in the fes-spec for AND, OR, NOT comparisons"""
617
846
 
618
847
 
619
848
  @dataclass
620
849
  @tag_registry.register("And")
621
850
  @tag_registry.register("Or")
622
851
  class BinaryLogicOperator(LogicalOperator):
623
- """Apply an 'AND' or 'OR' operator"""
852
+ """Apply an 'AND' or 'OR' operator.
853
+
854
+ This parses and handles the syntax::
855
+
856
+ <fes:And>
857
+ <fes:PropertyIsGreaterThanOrEqualTo>
858
+ ...
859
+ </fes:PropertyIsGreaterThanOrEqualTo>
860
+ <fes:BBOX>
861
+ ...
862
+ </fes:BBOX>
863
+ </fes:And>
864
+
865
+ Any tag deriving from :class:`NonIdOperator` is allowed here.
866
+ """
624
867
 
625
868
  operands: list[NonIdOperator]
626
869
  operatorType: BinaryLogicType
627
- _source: str | None = field(compare=False, default=None)
870
+ _source: str | None = field(compare=False, default=None, repr=False)
628
871
 
629
872
  @classmethod
630
- @expect_children(2, NonIdOperator, NonIdOperator)
631
- def from_xml(cls, element: Element):
873
+ @expect_children(2, NonIdOperator)
874
+ def from_xml(cls, element: NSElement):
632
875
  return cls(
633
- operands=[NonIdOperator.from_child_xml(child) for child in element],
876
+ operands=[NonIdOperator.child_from_xml(child) for child in element],
634
877
  operatorType=BinaryLogicType.from_xml(element),
635
878
  _source=element.tag,
636
879
  )
@@ -644,17 +887,26 @@ class BinaryLogicOperator(LogicalOperator):
644
887
  @dataclass
645
888
  @tag_registry.register("Not")
646
889
  class UnaryLogicOperator(LogicalOperator):
647
- """Apply a NOT operator"""
890
+ """Apply a NOT operator.
891
+
892
+ This parses and handles the syntax::
893
+
894
+ <fes:Not>
895
+ <fes:PropertyIsNil>
896
+ <fes:ValueReference>city/name</fes:ValueReference>
897
+ </fes:PropertyIsNil>
898
+ </fes:Not>
899
+ """
648
900
 
649
901
  operands: NonIdOperator
650
902
  operatorType: UnaryLogicType
651
- _source: str | None = field(compare=False, default=None)
903
+ _source: str | None = field(compare=False, default=None, repr=False)
652
904
 
653
905
  @classmethod
654
906
  @expect_children(1, NonIdOperator)
655
- def from_xml(cls, element: Element):
907
+ def from_xml(cls, element: NSElement):
656
908
  return cls(
657
- operands=NonIdOperator.from_child_xml(element[0]),
909
+ operands=NonIdOperator.child_from_xml(element[0]),
658
910
  operatorType=UnaryLogicType.from_xml(element),
659
911
  _source=element.tag,
660
912
  )
@@ -665,4 +917,8 @@ class UnaryLogicOperator(LogicalOperator):
665
917
 
666
918
 
667
919
  class ExtensionOperator(NonIdOperator):
668
- """Base class for extensions to FES20"""
920
+ """Base class for extensions to FES 2.0.
921
+
922
+ It's fully allowed to introduce new operators on your own namespace.
923
+ These need to inherit from this class.
924
+ """