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