django-gisserver 1.4.1__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 (73) hide show
  1. {django_gisserver-1.4.1.dist-info → django_gisserver-2.0.dist-info}/METADATA +23 -13
  2. django_gisserver-2.0.dist-info/RECORD +66 -0
  3. {django_gisserver-1.4.1.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 +63 -60
  8. gisserver/exceptions.py +47 -9
  9. gisserver/extensions/__init__.py +4 -0
  10. gisserver/{parsers/fes20 → extensions}/functions.py +11 -5
  11. gisserver/extensions/queries.py +261 -0
  12. gisserver/features.py +267 -240
  13. gisserver/geometries.py +34 -39
  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 +129 -305
  18. gisserver/operations/wfs20.py +428 -336
  19. gisserver/output/__init__.py +10 -48
  20. gisserver/output/base.py +198 -143
  21. gisserver/output/csv.py +81 -85
  22. gisserver/output/geojson.py +63 -72
  23. gisserver/output/gml32.py +310 -281
  24. gisserver/output/iters.py +207 -0
  25. gisserver/output/results.py +71 -30
  26. gisserver/output/stored.py +143 -0
  27. gisserver/output/utils.py +75 -154
  28. gisserver/output/xmlschema.py +86 -47
  29. gisserver/parsers/__init__.py +10 -10
  30. gisserver/parsers/ast.py +320 -0
  31. gisserver/parsers/fes20/__init__.py +15 -11
  32. gisserver/parsers/fes20/expressions.py +89 -50
  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 +336 -128
  37. gisserver/parsers/fes20/sorting.py +107 -34
  38. gisserver/parsers/gml/__init__.py +12 -11
  39. gisserver/parsers/gml/base.py +6 -3
  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 +11 -11
  58. gisserver/templates/gisserver/wfs/feature_field.html +2 -2
  59. gisserver/templatetags/gisserver_tags.py +20 -0
  60. gisserver/types.py +375 -258
  61. gisserver/views.py +206 -75
  62. django_gisserver-1.4.1.dist-info/RECORD +0 -53
  63. gisserver/parsers/base.py +0 -149
  64. gisserver/parsers/fes20/query.py +0 -275
  65. gisserver/parsers/tags.py +0 -102
  66. gisserver/queries/__init__.py +0 -34
  67. gisserver/queries/adhoc.py +0 -181
  68. gisserver/queries/base.py +0 -146
  69. gisserver/queries/stored.py +0 -205
  70. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  71. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  72. {django_gisserver-1.4.1.dist-info → django_gisserver-2.0.dist-info}/LICENSE +0 -0
  73. {django_gisserver-1.4.1.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,32 +82,36 @@ 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.
93
+
94
+ The values correspond with GeoDjango operators. So a ``BBOX`` query
95
+ will translate into ``geometry__intersects=Polygon(...)``.
96
+ """
96
97
 
97
98
  # (A Within B) implies that (B Contains A)
98
99
 
99
100
  # BBOX can either be implemented using bboverlaps (more efficient), or the
100
101
  # more correct "intersects" option (e.g. a line near the box would match otherwise).
101
102
  BBOX = "intersects" # ISO version: "NOT DISJOINT"
102
- Equals = "equals" # Test whether t geometries are topologically equal
103
+ Equals = "equals" # Test whether two geometries are topologically equal
103
104
  Disjoint = "disjoint" # Tests whether two geometries are disjoint (do not interact)
104
105
  Intersects = "intersects" # Tests whether two geometries intersect
105
- Touches = "touches" # Tests whether two geometries touch
106
- Crosses = "crosses" # Tests whether two geometries cross
107
- Within = "within" # Tests whether a geometry is within another one
108
- 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).
109
110
  Overlaps = "overlaps" # Test whether two geometries overlap
110
111
 
111
112
 
112
113
  class TemporalOperatorName(TagNameEnum):
113
- """XML tag names for datetime operators.
114
+ """XML tag names mapped to datetime operators.
114
115
 
115
116
  Explanation here: http://old.geotools.org/Temporal-Filters_211091519.html
116
117
  and: https://github.com/geotools/geotools/wiki/temporal-filters
@@ -146,26 +147,44 @@ class UnaryLogicType(TagNameEnum):
146
147
 
147
148
  @dataclass
148
149
  class Measure(BaseNode):
149
- """The <fes:Distance uom="...> element."""
150
+ """A measurement for a distance element.
151
+
152
+ This parses and handles the syntax::
150
153
 
151
- xml_ns = FES20
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
152
165
 
153
166
  value: Decimal
154
167
  uom: str # Unit of measurement, fes20:UomSymbol | fes20:UomURI
155
168
 
156
169
  @classmethod
157
- @expect_tag(FES20, "Distance")
158
- def from_xml(cls, element: Element):
159
- 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"))
160
173
 
161
174
  def build_rhs(self, compiler) -> measure.Distance:
162
175
  return measure.Distance(default_unit=self.uom, **{self.uom: self.value})
163
176
 
164
177
 
165
178
  class Operator(BaseNode):
166
- """Abstract base class, as defined by FES spec."""
179
+ """Abstract base class, as defined by FES spec.
180
+
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
+ """
167
186
 
168
- xml_ns = FES20
187
+ xml_ns = xmlns.fes20
169
188
 
170
189
  def build_query(self, compiler: CompiledQuery) -> Q | None:
171
190
  raise NotImplementedError(f"Using {self.__class__.__name__} is not supported yet.")
@@ -177,8 +196,7 @@ class IdOperator(Operator):
177
196
 
178
197
  id: list[Id]
179
198
 
180
- @property
181
- def type_names(self) -> list[str]:
199
+ def get_type_names(self) -> list[str]:
182
200
  """Provide a list of all type names accessed by this operator"""
183
201
  return [type_name for type_name in self.grouped_ids if type_name is not None]
184
202
 
@@ -188,7 +206,7 @@ class IdOperator(Operator):
188
206
  ids = sorted(self.id, key=operator.attrgetter("rid"))
189
207
  return {
190
208
  type_name: list(items)
191
- 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())
192
210
  }
193
211
 
194
212
  def build_query(self, compiler):
@@ -203,8 +221,20 @@ class IdOperator(Operator):
203
221
  compiler.mark_empty()
204
222
  return
205
223
 
224
+ type_names = {ft.xml_name for ft in compiler.feature_types}
206
225
  for type_name, items in self.grouped_ids.items():
207
- 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])
208
238
  compiler.add_lookups(ids_subset, type_name=type_name)
209
239
 
210
240
 
@@ -213,9 +243,13 @@ class NonIdOperator(Operator):
213
243
 
214
244
  This is used for nearly all operators,
215
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.
216
250
  """
217
251
 
218
- _source = None
252
+ _source = None # often declared as non-comparing in dataclasses below.
219
253
  allow_geometries = False
220
254
 
221
255
  def build_compare(
@@ -234,7 +268,7 @@ class NonIdOperator(Operator):
234
268
  if isinstance(lhs, Literal) and isinstance(rhs, ValueReference):
235
269
  lhs, rhs = rhs, lhs
236
270
 
237
- if compiler.feature_type is not None:
271
+ if compiler.feature_types:
238
272
  lookup = self.validate_comparison(compiler, lhs, lookup, rhs)
239
273
 
240
274
  # Build Django Q-object
@@ -251,47 +285,58 @@ class NonIdOperator(Operator):
251
285
  compiler: CompiledQuery,
252
286
  lhs: Expression,
253
287
  lookup: str,
254
- rhs: Expression | RhsTypes,
288
+ rhs: Expression | gml.GM_Object | RhsTypes,
255
289
  ):
256
290
  """Validate whether a given comparison is even possible.
257
- 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).
258
302
  """
259
303
  if isinstance(lhs, ValueReference):
260
- xsd_element = compiler.feature_type.resolve_element(lhs.xpath).child
304
+ xsd_element = lhs.parse_xpath(compiler.feature_types).child
261
305
  tag = self._source if self._source is not None else None
262
306
 
263
307
  # e.g. deny <PropertyIsLessThanOrEqualTo> against <gml:boundedBy>
264
- if xsd_element.is_geometry and not self.allow_geometries:
308
+ if xsd_element.type.is_geometry and not self.allow_geometries:
265
309
  raise OperationProcessingFailed(
266
- "filter",
267
310
  f"Operator '{tag}' does not support comparing"
268
311
  f" geometry properties: '{xsd_element.xml_name}'.",
312
+ locator="filter",
269
313
  status_code=400, # not HTTP 500 here. Spec allows both.
270
314
  )
271
315
 
272
- if isinstance(rhs, Literal):
273
- # Since the element is resolved, inform the Literal how to parse the value.
274
- # This avoids various validation errors along the path.
275
- rhs.bind_type(xsd_element.type)
276
-
277
- # When a common case of value comparison is done, the inputs
278
- # can be validated before the ORM query is constructed.
279
- xsd_element.validate_comparison(rhs.raw_value, lookup=lookup, tag=tag)
280
-
281
316
  # Checking scalar values against array fields will fail.
282
317
  # However, to make the queries consistent with other unbounded types (i.e. M2M fields),
283
318
  # it makes sense to return an object when *one* entry in the array matches.
284
319
  if xsd_element.is_array:
285
320
  try:
286
- return ARRAY_LOOKUPS[lookup]
321
+ lookup = ARRAY_LOOKUPS[lookup]
287
322
  except KeyError:
323
+ logger.debug("No array lookup known for %s", lookup)
288
324
  raise OperationProcessingFailed(
289
- "filter",
290
325
  f"Operator '{tag}' is not supported for "
291
326
  f"the '{xsd_element.name}' property.",
327
+ locator="filter",
292
328
  status_code=400, # not HTTP 500 here. Spec allows both.
293
329
  ) from None
294
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
+
295
340
  return lookup
296
341
 
297
342
  def build_compare_between(
@@ -299,10 +344,10 @@ class NonIdOperator(Operator):
299
344
  compiler: CompiledQuery,
300
345
  lhs: Expression,
301
346
  lookup: str,
302
- rhs: tuple[HasBuildRhs, HasBuildRhs],
347
+ rhs: tuple[Expression | ValueReference | gml.GM_Object, Expression | Measure],
303
348
  ) -> Q:
304
349
  """Use the value in comparison with 2 other values (e.g. between query)"""
305
- if compiler.feature_type is not None:
350
+ if compiler.feature_types:
306
351
  self.validate_comparison(compiler, lhs, lookup, rhs[0])
307
352
  self.validate_comparison(compiler, lhs, lookup, rhs[1])
308
353
 
@@ -323,9 +368,21 @@ class SpatialOperator(NonIdOperator):
323
368
 
324
369
 
325
370
  @dataclass
326
- @tag_registry.register_names(DistanceOperatorName) # <Beyond>, <DWithin>
371
+ @tag_registry.register("Beyond")
372
+ @tag_registry.register("DWithin")
327
373
  class DistanceOperator(SpatialOperator):
328
- """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
+ """
329
386
 
330
387
  allow_geometries = True # override static attribute
331
388
 
@@ -337,7 +394,7 @@ class DistanceOperator(SpatialOperator):
337
394
 
338
395
  @classmethod
339
396
  @expect_children(3, ValueReference, gml.GM_Object, Measure)
340
- def from_xml(cls, element: Element):
397
+ def from_xml(cls, element: NSElement):
341
398
  geometries = gml.find_gml_nodes(element)
342
399
  if not geometries:
343
400
  raise ExternalParsingError(f"Missing gml element in <{element.tag}>")
@@ -345,10 +402,10 @@ class DistanceOperator(SpatialOperator):
345
402
  raise ExternalParsingError(f"Multiple gml elements found in <{element.tag}>")
346
403
 
347
404
  return cls(
348
- valueReference=ValueReference.from_xml(get_child(element, FES20, "ValueReference")),
405
+ valueReference=ValueReference.from_xml(element.find(FES_VALUE_REFERENCE)),
349
406
  operatorType=DistanceOperatorName.from_xml(element),
350
407
  geometry=gml.parse_gml_node(geometries[0]),
351
- distance=Measure.from_xml(get_child(element, FES20, "Distance")),
408
+ distance=Measure.from_xml(element.find(FES_DISTANCE)),
352
409
  _source=element.tag,
353
410
  )
354
411
 
@@ -362,9 +419,23 @@ class DistanceOperator(SpatialOperator):
362
419
 
363
420
 
364
421
  @dataclass
365
- @tag_registry.register_names(SpatialOperatorName) # <BBOX>, <Equals>, ...
422
+ @tag_registry.register(SpatialOperatorName) # <BBOX>, <Equals>, ...
366
423
  class BinarySpatialOperator(SpatialOperator):
367
- """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
+ """
368
439
 
369
440
  allow_geometries = True # override static attribute
370
441
 
@@ -374,7 +445,7 @@ class BinarySpatialOperator(SpatialOperator):
374
445
  _source: str | None = field(compare=False, default=None)
375
446
 
376
447
  @classmethod
377
- def from_xml(cls, element: Element):
448
+ def from_xml(cls, element: NSElement):
378
449
  operator_type = SpatialOperatorName.from_xml(element)
379
450
  if operator_type is SpatialOperatorName.BBOX and len(element) == 1:
380
451
  # For BBOX, the geometry operator is optional
@@ -388,16 +459,15 @@ class BinarySpatialOperator(SpatialOperator):
388
459
  return cls(
389
460
  operatorType=operator_type,
390
461
  operand1=ValueReference.from_xml(ref) if ref is not None else None,
391
- operand2=tag_registry.from_child_xml(
392
- geo, allowed_types=SpatialDescription.__args__ # get_args() in 3.8
393
- ),
462
+ operand2=tag_registry.node_from_xml(geo, allowed_types=SpatialDescription.__args__),
394
463
  _source=element.tag,
395
464
  )
396
465
 
397
466
  def build_query(self, compiler: CompiledQuery) -> Q:
398
467
  operant1 = self.operand1
399
468
  if operant1 is None:
400
- 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)
401
471
 
402
472
  return self.build_compare(
403
473
  compiler,
@@ -407,10 +477,61 @@ class BinarySpatialOperator(SpatialOperator):
407
477
  )
408
478
 
409
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
+
410
500
  @dataclass
411
- @tag_registry.register_names(TemporalOperatorName) # <After>, <Before>, ...
501
+ @tag_registry.register(TemporalOperatorName) # <After>, <Before>, ...
412
502
  class TemporalOperator(NonIdOperator):
413
- """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
+ """
414
535
 
415
536
  operatorType: TemporalOperatorName
416
537
  operand1: ValueReference
@@ -418,20 +539,23 @@ class TemporalOperator(NonIdOperator):
418
539
  _source: str | None = field(compare=False, default=None)
419
540
 
420
541
  @classmethod
421
- @expect_children(2, ValueReference, TemporalOperand)
422
- def from_xml(cls, element: Element):
542
+ @expect_children(2, ValueReference, *TemporalOperand.__args__)
543
+ def from_xml(cls, element: NSElement):
423
544
  return cls(
424
545
  operatorType=TemporalOperatorName.from_xml(element),
425
546
  operand1=ValueReference.from_xml(element[0]),
426
- operand2=tag_registry.from_child_xml(
427
- 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__
428
549
  ),
429
550
  _source=element.tag,
430
551
  )
431
552
 
432
553
 
433
554
  class ComparisonOperator(NonIdOperator):
434
- """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
+ """
435
559
 
436
560
  # Start counting fresh here, to collect the capabilities
437
561
  # that are listed in the <fes20:ComparisonOperators> node:
@@ -439,9 +563,23 @@ class ComparisonOperator(NonIdOperator):
439
563
 
440
564
 
441
565
  @dataclass
442
- @tag_registry.register_names(BinaryComparisonName) # <PropertyIs...>
566
+ @tag_registry.register(BinaryComparisonName) # <PropertyIs...>
443
567
  class BinaryComparisonOperator(ComparisonOperator):
444
- """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
+ """
445
583
 
446
584
  operatorType: BinaryComparisonName
447
585
  expression: tuple[Expression, Expression]
@@ -451,12 +589,12 @@ class BinaryComparisonOperator(ComparisonOperator):
451
589
 
452
590
  @classmethod
453
591
  @expect_children(2, Expression, Expression)
454
- def from_xml(cls, element: Element):
592
+ def from_xml(cls, element: NSElement):
455
593
  return cls(
456
594
  operatorType=BinaryComparisonName.from_xml(element),
457
595
  expression=(
458
- Expression.from_child_xml(element[0]),
459
- Expression.from_child_xml(element[1]),
596
+ Expression.child_from_xml(element[0]),
597
+ Expression.child_from_xml(element[1]),
460
598
  ),
461
599
  matchCase=element.get("matchCase", True),
462
600
  matchAction=MatchAction(element.get("matchAction", default=MatchAction.Any)),
@@ -471,7 +609,19 @@ class BinaryComparisonOperator(ComparisonOperator):
471
609
  @dataclass
472
610
  @tag_registry.register("PropertyIsBetween")
473
611
  class BetweenComparisonOperator(ComparisonOperator):
474
- """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
+ """
475
625
 
476
626
  expression: Expression
477
627
  lowerBoundary: Expression
@@ -480,17 +630,15 @@ class BetweenComparisonOperator(ComparisonOperator):
480
630
 
481
631
  @classmethod
482
632
  @expect_children(3, Expression, "LowerBoundary", "UpperBoundary")
483
- def from_xml(cls, element: Element):
484
- if element[1].tag != QName(FES20, "LowerBoundary") or element[2].tag != QName(
485
- FES20, "UpperBoundary"
486
- ):
633
+ def from_xml(cls, element: NSElement):
634
+ if (element[1].tag != FES_LOWER_BOUNDARY) or (element[2].tag != FES_UPPER_BOUNDARY):
487
635
  raise ExternalParsingError(
488
636
  f"{element.tag} should have 3 child nodes: "
489
637
  f"(expression), <LowerBoundary>, <UpperBoundary>"
490
638
  )
491
639
 
492
- lower = get_child(element, FES20, "LowerBoundary")
493
- upper = get_child(element, FES20, "UpperBoundary")
640
+ lower = element[1]
641
+ upper = element[2]
494
642
 
495
643
  if len(lower) != 1:
496
644
  raise ExternalParsingError(f"{lower.tag} should have 1 expression child node")
@@ -498,9 +646,9 @@ class BetweenComparisonOperator(ComparisonOperator):
498
646
  raise ExternalParsingError(f"{upper.tag} should have 1 expression child node")
499
647
 
500
648
  return cls(
501
- expression=Expression.from_child_xml(element[0]),
502
- lowerBoundary=Expression.from_child_xml(lower[0]),
503
- 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]),
504
652
  _source=element.tag,
505
653
  )
506
654
 
@@ -516,7 +664,15 @@ class BetweenComparisonOperator(ComparisonOperator):
516
664
  @dataclass
517
665
  @tag_registry.register("PropertyIsLike")
518
666
  class LikeOperator(ComparisonOperator):
519
- """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
+ """
520
676
 
521
677
  expression: tuple[Expression, Expression]
522
678
  wildCard: str
@@ -526,16 +682,16 @@ class LikeOperator(ComparisonOperator):
526
682
 
527
683
  @classmethod
528
684
  @expect_children(2, Expression, Expression)
529
- def from_xml(cls, element: Element):
685
+ def from_xml(cls, element: NSElement):
530
686
  return cls(
531
687
  expression=(
532
- Expression.from_child_xml(element[0]),
533
- Expression.from_child_xml(element[1]),
688
+ Expression.child_from_xml(element[0]),
689
+ Expression.child_from_xml(element[1]),
534
690
  ),
535
691
  # These attributes are required by the WFS spec:
536
- wildCard=get_attribute(element, "wildCard"),
537
- singleChar=get_attribute(element, "singleChar"),
538
- 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"),
539
695
  _source=element.tag,
540
696
  )
541
697
 
@@ -554,7 +710,9 @@ class LikeOperator(ComparisonOperator):
554
710
 
555
711
  rhs = Literal(raw_value=value)
556
712
  else:
557
- 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
+ )
558
716
 
559
717
  # Use the FesLike lookup
560
718
  return self.build_compare(compiler, lhs=lhs, lookup="fes_like", rhs=rhs)
@@ -565,17 +723,29 @@ class LikeOperator(ComparisonOperator):
565
723
  class NilOperator(ComparisonOperator):
566
724
  """Check whether the value evaluates to null/None.
567
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.
568
735
  """
569
736
 
570
737
  expression: Expression | None
571
738
  nilReason: str
572
739
  _source: str | None = field(compare=False, default=None)
573
740
 
741
+ # Allow checking whether the geometry is null
742
+ allow_geometries: ClassVar[bool] = True
743
+
574
744
  @classmethod
575
745
  @expect_children(1, Expression)
576
- def from_xml(cls, element: Element):
746
+ def from_xml(cls, element: NSElement):
577
747
  return cls(
578
- 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,
579
749
  nilReason=element.get("nilReason"),
580
750
  _source=element.tag,
581
751
  )
@@ -590,15 +760,26 @@ class NilOperator(ComparisonOperator):
590
760
  class NullOperator(ComparisonOperator):
591
761
  """Check whether the property exists.
592
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.
593
771
  """
594
772
 
595
773
  expression: Expression
596
774
  _source: str | None = field(compare=False, default=None)
597
775
 
776
+ # Allow checking whether the geometry is null
777
+ allow_geometries: ClassVar[bool] = True
778
+
598
779
  @classmethod
599
780
  @expect_children(1, Expression)
600
- def from_xml(cls, element: Element):
601
- 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)
602
783
 
603
784
  def build_query(self, compiler: CompiledQuery) -> Q:
604
785
  # For now, the implementation is identical to PropertyIsNil.
@@ -609,14 +790,28 @@ class NullOperator(ComparisonOperator):
609
790
 
610
791
 
611
792
  class LogicalOperator(NonIdOperator):
612
- """Base class for AND, OR, NOT comparisons"""
793
+ """Base class in the fes-spec for AND, OR, NOT comparisons"""
613
794
 
614
795
 
615
796
  @dataclass
616
797
  @tag_registry.register("And")
617
798
  @tag_registry.register("Or")
618
799
  class BinaryLogicOperator(LogicalOperator):
619
- """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
+ """
620
815
 
621
816
  operands: list[NonIdOperator]
622
817
  operatorType: BinaryLogicType
@@ -624,9 +819,9 @@ class BinaryLogicOperator(LogicalOperator):
624
819
 
625
820
  @classmethod
626
821
  @expect_children(2, NonIdOperator, NonIdOperator)
627
- def from_xml(cls, element: Element):
822
+ def from_xml(cls, element: NSElement):
628
823
  return cls(
629
- operands=[NonIdOperator.from_child_xml(child) for child in element],
824
+ operands=[NonIdOperator.child_from_xml(child) for child in element],
630
825
  operatorType=BinaryLogicType.from_xml(element),
631
826
  _source=element.tag,
632
827
  )
@@ -640,7 +835,16 @@ class BinaryLogicOperator(LogicalOperator):
640
835
  @dataclass
641
836
  @tag_registry.register("Not")
642
837
  class UnaryLogicOperator(LogicalOperator):
643
- """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
+ """
644
848
 
645
849
  operands: NonIdOperator
646
850
  operatorType: UnaryLogicType
@@ -648,9 +852,9 @@ class UnaryLogicOperator(LogicalOperator):
648
852
 
649
853
  @classmethod
650
854
  @expect_children(1, NonIdOperator)
651
- def from_xml(cls, element: Element):
855
+ def from_xml(cls, element: NSElement):
652
856
  return cls(
653
- operands=NonIdOperator.from_child_xml(element[0]),
857
+ operands=NonIdOperator.child_from_xml(element[0]),
654
858
  operatorType=UnaryLogicType.from_xml(element),
655
859
  _source=element.tag,
656
860
  )
@@ -661,4 +865,8 @@ class UnaryLogicOperator(LogicalOperator):
661
865
 
662
866
 
663
867
  class ExtensionOperator(NonIdOperator):
664
- """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
+ """