django-gisserver 2.0__py3-none-any.whl → 2.1.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 (56) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
  2. django_gisserver-2.1.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/conf.py +23 -1
  6. gisserver/crs.py +452 -0
  7. gisserver/db.py +78 -6
  8. gisserver/exceptions.py +106 -2
  9. gisserver/extensions/functions.py +122 -28
  10. gisserver/extensions/queries.py +15 -10
  11. gisserver/features.py +46 -33
  12. gisserver/geometries.py +64 -306
  13. gisserver/management/commands/loadgeojson.py +41 -21
  14. gisserver/operations/base.py +11 -7
  15. gisserver/operations/wfs20.py +31 -93
  16. gisserver/output/__init__.py +6 -2
  17. gisserver/output/base.py +28 -13
  18. gisserver/output/csv.py +18 -6
  19. gisserver/output/geojson.py +7 -6
  20. gisserver/output/gml32.py +86 -27
  21. gisserver/output/results.py +25 -39
  22. gisserver/output/utils.py +9 -2
  23. gisserver/parsers/ast.py +177 -68
  24. gisserver/parsers/fes20/__init__.py +76 -4
  25. gisserver/parsers/fes20/expressions.py +97 -27
  26. gisserver/parsers/fes20/filters.py +9 -6
  27. gisserver/parsers/fes20/identifiers.py +27 -7
  28. gisserver/parsers/fes20/lookups.py +8 -6
  29. gisserver/parsers/fes20/operators.py +101 -49
  30. gisserver/parsers/fes20/sorting.py +14 -6
  31. gisserver/parsers/gml/__init__.py +10 -19
  32. gisserver/parsers/gml/base.py +32 -14
  33. gisserver/parsers/gml/geometries.py +54 -21
  34. gisserver/parsers/ows/kvp.py +10 -2
  35. gisserver/parsers/ows/requests.py +6 -4
  36. gisserver/parsers/query.py +6 -2
  37. gisserver/parsers/values.py +61 -4
  38. gisserver/parsers/wfs20/__init__.py +2 -0
  39. gisserver/parsers/wfs20/adhoc.py +28 -18
  40. gisserver/parsers/wfs20/base.py +12 -7
  41. gisserver/parsers/wfs20/projection.py +3 -3
  42. gisserver/parsers/wfs20/requests.py +1 -0
  43. gisserver/parsers/wfs20/stored.py +3 -2
  44. gisserver/parsers/xml.py +12 -0
  45. gisserver/projection.py +17 -7
  46. gisserver/static/gisserver/index.css +27 -6
  47. gisserver/templates/gisserver/base.html +15 -0
  48. gisserver/templates/gisserver/index.html +10 -16
  49. gisserver/templates/gisserver/service_description.html +12 -6
  50. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  51. gisserver/templates/gisserver/wfs/feature_type.html +44 -13
  52. gisserver/types.py +152 -82
  53. gisserver/views.py +47 -24
  54. django_gisserver-2.0.dist-info/RECORD +0 -66
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
  56. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/top_level.txt +0 -0
gisserver/db.py CHANGED
@@ -5,11 +5,13 @@ from __future__ import annotations
5
5
  import logging
6
6
  from functools import lru_cache, reduce
7
7
 
8
- from django.contrib.gis.db.models import functions
8
+ from django.contrib.gis.db.models import Extent, PolygonField, functions
9
+ from django.contrib.gis.db.models.fields import ExtentField
9
10
  from django.db import connection, connections, models
10
11
 
11
12
  from gisserver import conf
12
- from gisserver.geometries import CRS
13
+ from gisserver.crs import CRS, WGS84
14
+ from gisserver.geometries import WGS84BoundingBox
13
15
  from gisserver.types import GeometryXsdElement
14
16
 
15
17
  logger = logging.getLogger(__name__)
@@ -29,6 +31,8 @@ class AsEWKT(functions.GeoFunc):
29
31
 
30
32
 
31
33
  class AsGML(functions.AsGML):
34
+ """An overwritten ST_AsGML() function to handle PostGIS extensions."""
35
+
32
36
  name = "AsGML"
33
37
 
34
38
  def __init__(
@@ -37,20 +41,61 @@ class AsGML(functions.AsGML):
37
41
  version=3,
38
42
  precision=conf.GISSERVER_DB_PRECISION,
39
43
  envelope=False,
44
+ is_latlon=False,
45
+ long_urn=False,
40
46
  **extra,
41
47
  ):
42
- # Note that Django's AsGml, version=2, precision=8
43
- # the options is postgres-only.
48
+ # Note that Django's AsGml the defaults are: version=2, precision=8
44
49
  super().__init__(expression, version, precision, **extra)
45
50
  self.envelope = envelope
51
+ self.is_latlon = is_latlon
52
+ self.long_urn = long_urn
46
53
 
47
54
  def as_postgresql(self, compiler, connection, **extra_context):
48
55
  # Fill options parameter (https://postgis.net/docs/ST_AsGML.html)
49
- options = 33 if self.envelope else 1 # 32 = bbox, 1 = long CRS urn
56
+ options = 0
57
+ if self.long_urn:
58
+ options |= 1 # long CRS urn
59
+ if self.envelope:
60
+ options |= 32 # bbox
61
+ if self.is_latlon:
62
+ # PostGIS provides the data in longitude/latitude format (east/north to look like x/y).
63
+ # However, WFS 2.0 fixed their axis by following the authority. The ST_AsGML() doesn't
64
+ # detect this on their own. It needs to be told to flip the coordinates.
65
+ # Passing option 16 flips the coordinates unconditionally, like using ST_FlipCoordinates()
66
+ # but more efficiently done during rendering. That happens in:
67
+ # https://github.com/postgis/postgis/blob/81e2bc783b77cc740291445e992658e1db7179e0/liblwgeom/lwout_gml.c#L121
68
+ options |= 16
69
+
50
70
  template = f"%(function)s(%(expressions)s, {options})"
51
71
  return self.as_sql(compiler, connection, template=template, **extra_context)
52
72
 
53
73
 
74
+ class ST_SetSRID(functions.Transform):
75
+ """PostGIS function to assign an SRID to geometry.
76
+ When this is applied to the result from an ``Extent`` aggegrate,
77
+ it will convert that ``BBOX(...)`` value into a ``POLYGON(...)``.
78
+ """
79
+
80
+ name = "SetSRID"
81
+ geom_param_pos = ()
82
+
83
+ @property
84
+ def geo_field(self):
85
+ return PolygonField(srid=self.source_expressions[1].value)
86
+
87
+
88
+ class Box2D(functions.GeomOutputGeoFunc):
89
+ """PostGIS function (without ST_) that converts a ``POLYGON(...)`` back to a ``BOX(...)``."""
90
+
91
+ name = "Box2D"
92
+ function = "Box2D" # no ST_ prefix.
93
+ output_field = ExtentField()
94
+
95
+ def convert_value(self, value, expression, connection):
96
+ return connection.ops.convert_extent(value)
97
+
98
+
54
99
  class ST_Union(functions.Union):
55
100
  name = "Union"
56
101
  arity = None
@@ -62,6 +107,31 @@ class ST_Union(functions.Union):
62
107
  return self.as_sql(compiler, connection, **extra_context)
63
108
 
64
109
 
110
+ def get_wgs84_bounding_box(
111
+ queryset: models.QuerySet, geo_element: GeometryXsdElement
112
+ ) -> WGS84BoundingBox:
113
+ """Calculate the WGS84 bounding box for a feature.
114
+
115
+ Note that the ``<ows:WGS84BoundingBox>`` element
116
+ always uses longitude/latitude, and doesn't describe a CRS.
117
+ """
118
+ if connections[queryset.db].vendor == "postgresql":
119
+ # Allow a more efficient way to combine geometry first, transform once later
120
+ box = queryset.aggregate(
121
+ box=Box2D(
122
+ functions.Transform(
123
+ ST_SetSRID(Extent(geo_element.orm_path), srid=geo_element.source_srid),
124
+ srid=WGS84.srid,
125
+ )
126
+ )
127
+ )["box"]
128
+ else:
129
+ # Need to transform each element to srid 4326 before combining it in an extent.
130
+ box = queryset.aggregate(box=Extent(get_db_geometry_target(geo_element, WGS84)))["box"]
131
+
132
+ return WGS84BoundingBox(*box) if box else None
133
+
134
+
65
135
  def get_geometries_union(
66
136
  expressions: list[str | functions.GeoFunc], using="default"
67
137
  ) -> str | functions.Union:
@@ -86,6 +156,7 @@ def replace_queryset_geometries(
86
156
  geo_elements: list[GeometryXsdElement],
87
157
  output_crs: CRS,
88
158
  wrapper_func: type[functions.GeoFunc],
159
+ **wrapper_kwargs,
89
160
  ) -> models.QuerySet:
90
161
  """Replace the queryset geometry retrieval with a database-rendered version.
91
162
 
@@ -98,7 +169,8 @@ def replace_queryset_geometries(
98
169
  defer_names.append(geo_element.local_orm_path)
99
170
  annotation_name = _as_annotation_name(geo_element.local_orm_path, wrapper_func)
100
171
  as_geo_map[annotation_name] = wrapper_func(
101
- get_db_geometry_target(geo_element, output_crs, use_relative_path=True)
172
+ get_db_geometry_target(geo_element, output_crs, use_relative_path=True),
173
+ **wrapper_kwargs,
102
174
  )
103
175
 
104
176
  if not defer_names:
gisserver/exceptions.py CHANGED
@@ -8,13 +8,23 @@ https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#35
8
8
  https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#411
9
9
  """
10
10
 
11
+ from __future__ import annotations
12
+
11
13
  import logging
14
+ import typing
12
15
  from contextlib import contextmanager
13
16
 
14
17
  from django.conf import settings
18
+ from django.core.exceptions import FieldError, ValidationError
19
+ from django.db import InternalError, ProgrammingError
15
20
  from django.http import HttpResponse
16
21
  from django.utils.html import format_html
17
22
 
23
+ from gisserver import conf
24
+
25
+ if typing.TYPE_CHECKING:
26
+ from gisserver.parsers import wfs20
27
+
18
28
  logger = logging.getLogger(__name__)
19
29
 
20
30
 
@@ -35,6 +45,90 @@ def wrap_parser_errors(name: str, locator: str):
35
45
  raise InvalidParameterValue(f"Invalid {name} argument: {e}", locator=locator) from None
36
46
 
37
47
 
48
+ @contextmanager
49
+ def wrap_filter_errors(query: wfs20.QueryExpression): # noqa:C901
50
+ """Perform a QuerySet/filter creation operation.
51
+ and trap many parser errors in the making of it."""
52
+ try:
53
+ yield
54
+ except ExternalParsingError as e:
55
+ # Bad input data
56
+ _log_filter_error(query, logging.ERROR, e)
57
+ raise OperationParsingFailed(str(e), locator=query.query_locator) from e
58
+ except ExternalValueError as e:
59
+ # Bad input data
60
+ _log_filter_error(query, logging.ERROR, e)
61
+ raise InvalidParameterValue(str(e), locator=query.query_locator) from e
62
+ except ValidationError as e:
63
+ # Bad input data
64
+ _log_filter_error(query, logging.ERROR, e)
65
+ raise OperationParsingFailed(
66
+ "\n".join(map(str, e.messages)),
67
+ locator=query.query_locator,
68
+ ) from e
69
+ except FieldError as e:
70
+ # e.g. doing a LIKE on a foreign key, or requesting an unknown field.
71
+ if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
72
+ raise
73
+ _log_filter_error(query, logging.ERROR, e)
74
+ raise InvalidParameterValue(
75
+ "Internal error when processing filter",
76
+ locator=query.query_locator,
77
+ ) from e
78
+ except (InternalError, ProgrammingError) as e:
79
+ # e.g. comparing datetime against integer
80
+ if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
81
+ raise
82
+ logger.exception("WFS request failed: %s\nQuery: %r", str(e), query)
83
+ msg = str(e)
84
+ locator = "srsName" if "Cannot find SRID" in msg else query.query_locator
85
+ raise InvalidParameterValue(f"Invalid request: {msg}", locator=locator) from e
86
+ except (TypeError, ValueError) as e:
87
+ # TypeError/ValueError could reference a datatype mismatch in an
88
+ # ORM query, but it could also be an internal bug. In most cases,
89
+ # this is already caught by XsdElement.validate_comparison().
90
+ raise
91
+ if _is_orm_error(e):
92
+ if query.query_locator == "STOREDQUERY_ID":
93
+ # This is a fallback, ideally the stored query performs its own validation.
94
+ raise InvalidParameterValue(
95
+ f"Invalid stored query parameter: {e}", locator=query.query_locator
96
+ ) from e
97
+ else:
98
+ raise InvalidParameterValue(
99
+ f"Invalid filter query: {e}", locator=query.query_locator
100
+ ) from e
101
+ raise
102
+
103
+
104
+ def _is_orm_error(exception: Exception) -> bool:
105
+ """Tell whether an exception is caused by the ORM."""
106
+ traceback = exception.__traceback__
107
+ while traceback.tb_next is not None:
108
+ traceback = traceback.tb_next
109
+ if "/django/db/models/query" in traceback.tb_frame.f_code.co_filename:
110
+ return True
111
+ return False
112
+
113
+
114
+ def _log_filter_error(query, level, exc):
115
+ """Report a filtering parsing error in the logging"""
116
+ filter = getattr(query, "filter", None) # AdhocQuery only
117
+ fes_xml = filter.source if filter is not None else "(not provided)"
118
+ try:
119
+ sql = exc.__cause__.cursor.query.decode()
120
+ except AttributeError:
121
+ logger.log(level, "WFS query failed: %s\nFilter:\n%s", exc, fes_xml)
122
+ else:
123
+ logger.log(
124
+ level,
125
+ "WFS query failed: %s\nSQL Query: %s\n\nFilter:\n%s",
126
+ exc,
127
+ sql,
128
+ fes_xml,
129
+ )
130
+
131
+
38
132
  class ExternalValueError(ValueError):
39
133
  """Raise a ValueError for external input.
40
134
  This helps to distinguish between internal bugs
@@ -46,6 +140,14 @@ class ExternalParsingError(ValueError):
46
140
  """Raise a ValueError for a parsing problem."""
47
141
 
48
142
 
143
+ class XmlElementNotSupported(ExternalParsingError):
144
+ """Raise a ValueError when an XML tag is not known by the parser at all."""
145
+
146
+
147
+ class InvalidXmlElement(ExternalParsingError):
148
+ """Raise a ValueError when a particular XML tag wasn't expected."""
149
+
150
+
49
151
  class OWSException(Exception):
50
152
  """Base class for XML based exceptions in this module."""
51
153
 
@@ -68,7 +170,8 @@ class OWSException(Exception):
68
170
  self.code = code or self.code or self.__class__.__name__
69
171
  self.status_code = status_code or self.status_code
70
172
 
71
- def as_response(self):
173
+ def as_response(self) -> HttpResponse:
174
+ """Return the excetion as HTTP response."""
72
175
  logger.debug("Returning HTTP %d for %s: %s", self.status_code, self.code, self.text)
73
176
  xml_body = self.as_xml()
74
177
  return HttpResponse(
@@ -78,7 +181,8 @@ class OWSException(Exception):
78
181
  reason=self.reason,
79
182
  )
80
183
 
81
- def as_xml(self):
184
+ def as_xml(self) -> str:
185
+ """Serialize the exception to an XML string."""
82
186
  return format_html(
83
187
  "<ows:ExceptionReport"
84
188
  ' xmlns:ows="http://www.opengis.net/ows/1.1"'
@@ -1,4 +1,15 @@
1
- """Functions to be callable from fes."""
1
+ """Functions to be callable from query filters.
2
+
3
+ By using the :attr:`function_registry`, custom stored functions can be registered in this server.
4
+ These are called by the filter queries using the :class:`~gisserver.parsers.fes20.expressions.Function` element.
5
+ Out of the box, various built-in functions are present.
6
+
7
+ Built-in options are documented in :ref:`functions`.
8
+
9
+ Most of the out-of-the box options are inspired
10
+ by `GeoServer <https://docs.geoserver.org/latest/en/user/filter/function_reference.html>`_.
11
+ Functions which already have a fes-syntax equivalent have been are omitted.
12
+ """
2
13
 
3
14
  from __future__ import annotations
4
15
 
@@ -6,15 +17,16 @@ from collections.abc import Callable
6
17
  from dataclasses import dataclass
7
18
  from typing import Union
8
19
 
20
+ import django
9
21
  from django.contrib.gis.db.models import functions as gis
10
22
  from django.db import models
11
23
  from django.db.models import functions
12
- from django.db.models.expressions import Combinable
24
+ from django.db.models.expressions import Combinable, Value
13
25
 
14
26
  from gisserver.exceptions import InvalidParameterValue
15
27
  from gisserver.types import XsdTypes
16
28
 
17
- __all__ = ["function_registry"]
29
+ __all__ = ["function_registry", "FesFunctionRegistry", "FesFunction"]
18
30
 
19
31
  FesArg = Union[Combinable, models.Q]
20
32
  FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
@@ -22,7 +34,7 @@ FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
22
34
 
23
35
  @dataclass(order=True)
24
36
  class FesFunction:
25
- """A registered database function that can be used by ``<fes:Function name="...">`.
37
+ """A registered database function that can be used by ``<fes:Function name="...">``.
26
38
 
27
39
  The :class:`~gisserver.parsers.fes20.expressions.Function` class will resolve
28
40
  these registered functions by name, and call :meth:`build_query` to include them
@@ -65,9 +77,11 @@ class FesFunctionRegistry:
65
77
  self.functions = {}
66
78
 
67
79
  def __bool__(self):
80
+ """Tell whether there are functions"""
68
81
  return bool(self.functions)
69
82
 
70
83
  def __iter__(self):
84
+ """Iterate over the functions"""
71
85
  return iter(sorted(self.functions.values())) # for template rendering
72
86
 
73
87
  def register(
@@ -112,15 +126,11 @@ class FesFunctionRegistry:
112
126
  ) from None
113
127
 
114
128
 
129
+ #: The function registry
115
130
  function_registry = FesFunctionRegistry()
116
131
 
117
132
 
118
133
  # Register a set of default SQL functions.
119
- # These are based on GeoServer:
120
- # https://docs.geoserver.org/latest/en/user/filter/function_reference.html
121
- # Not implemented:
122
- # - Aggregates (like Collection_*)
123
- # - Comparisons (which already has fes variants)
124
134
 
125
135
  # -- strings
126
136
 
@@ -131,6 +141,27 @@ function_registry.register(
131
141
  returns=XsdTypes.string,
132
142
  )
133
143
 
144
+ function_registry.register(
145
+ "strIndexOf",
146
+ lambda string, substring: (functions.StrIndex(string, Value(substring)) - 1),
147
+ arguments={"string": XsdTypes.string, "substring": XsdTypes.string},
148
+ returns=XsdTypes.string,
149
+ )
150
+
151
+ function_registry.register(
152
+ "strSubstring",
153
+ lambda string, begin, end: functions.Substr(string, begin + 1, end - begin),
154
+ arguments={"string": XsdTypes.string, "begin": XsdTypes.integer, "end": XsdTypes.integer},
155
+ returns=XsdTypes.string,
156
+ )
157
+
158
+ function_registry.register(
159
+ "strSubstringStart",
160
+ lambda string, begin, end: functions.Substr(string, begin + 1),
161
+ arguments={"string": XsdTypes.string, "begin": XsdTypes.integer},
162
+ returns=XsdTypes.string,
163
+ )
164
+
134
165
  function_registry.register(
135
166
  "strToLowerCase",
136
167
  functions.Lower,
@@ -312,25 +343,25 @@ function_registry.register(
312
343
  # -- geometric
313
344
 
314
345
  function_registry.register(
315
- "Area",
346
+ "area",
316
347
  gis.Area,
317
- arguments={"geometry": XsdTypes.gmlAbstractGeometryType},
348
+ arguments={"geom": XsdTypes.gmlAbstractGeometryType},
318
349
  returns=XsdTypes.double,
319
350
  )
320
351
 
321
352
  function_registry.register(
322
- "Centroid",
353
+ "centroid",
323
354
  gis.Centroid,
324
- arguments={"features": XsdTypes.gmlAbstractGeometryType},
355
+ arguments={"geom": XsdTypes.gmlAbstractGeometryType},
325
356
  returns=XsdTypes.string,
326
357
  )
327
358
 
328
359
  function_registry.register(
329
- "Difference",
360
+ "difference",
330
361
  gis.Difference,
331
362
  arguments={
332
- "geometry1": XsdTypes.gmlAbstractGeometryType,
333
- "geometry2": XsdTypes.gmlAbstractGeometryType,
363
+ "a": XsdTypes.gmlAbstractGeometryType,
364
+ "b": XsdTypes.gmlAbstractGeometryType,
334
365
  },
335
366
  returns=XsdTypes.string,
336
367
  )
@@ -339,35 +370,98 @@ function_registry.register(
339
370
  "distance",
340
371
  gis.Distance,
341
372
  arguments={
342
- "geometry1": XsdTypes.gmlAbstractGeometryType,
343
- "geometry2": XsdTypes.gmlAbstractGeometryType,
373
+ "a": XsdTypes.gmlAbstractGeometryType,
374
+ "b": XsdTypes.gmlAbstractGeometryType,
344
375
  },
345
376
  returns=XsdTypes.double,
346
377
  )
347
378
 
348
379
  function_registry.register(
349
- "Envelope",
380
+ "envelope",
350
381
  gis.Envelope,
382
+ arguments={"geom": XsdTypes.gmlAbstractGeometryType},
383
+ returns=XsdTypes.gmlAbstractGeometryType, # returns point or polygon
384
+ )
385
+
386
+ function_registry.register(
387
+ "geomLength",
388
+ gis.Length,
351
389
  arguments={"geometry": XsdTypes.gmlAbstractGeometryType},
352
- returns=XsdTypes.string,
390
+ returns=XsdTypes.float,
353
391
  )
354
392
 
355
393
  function_registry.register(
356
- "Intersection",
394
+ "intersection",
357
395
  gis.Intersection,
358
396
  arguments={
359
- "geometry1": XsdTypes.gmlAbstractGeometryType,
360
- "geometry2": XsdTypes.gmlAbstractGeometryType,
397
+ "a": XsdTypes.gmlAbstractGeometryType,
398
+ "b": XsdTypes.gmlAbstractGeometryType,
361
399
  },
362
- returns=XsdTypes.string,
400
+ returns=XsdTypes.gmlAbstractGeometryType,
363
401
  )
364
402
 
403
+ if django.VERSION >= (4, 2):
404
+ function_registry.register(
405
+ "isEmpty",
406
+ gis.IsEmpty,
407
+ arguments={
408
+ "geom": XsdTypes.gmlAbstractGeometryType,
409
+ },
410
+ returns=XsdTypes.boolean,
411
+ )
412
+
365
413
  function_registry.register(
366
- "Union",
414
+ "isValid",
415
+ gis.IsValid,
416
+ arguments={
417
+ "geom": XsdTypes.gmlAbstractGeometryType,
418
+ },
419
+ returns=XsdTypes.boolean,
420
+ )
421
+
422
+ function_registry.register(
423
+ "numGeometries",
424
+ gis.NumGeometries,
425
+ arguments={
426
+ "collection": XsdTypes.gmlAbstractGeometryType,
427
+ },
428
+ returns=XsdTypes.integer,
429
+ )
430
+
431
+ function_registry.register(
432
+ "numPoints",
433
+ gis.NumPoints,
434
+ arguments={
435
+ "collection": XsdTypes.gmlAbstractGeometryType,
436
+ },
437
+ returns=XsdTypes.integer,
438
+ )
439
+
440
+ function_registry.register(
441
+ "perimeter",
442
+ gis.Perimeter,
443
+ arguments={
444
+ "geom": XsdTypes.gmlAbstractGeometryType,
445
+ },
446
+ returns=XsdTypes.integer,
447
+ )
448
+
449
+ function_registry.register(
450
+ "symDifference",
451
+ gis.SymDifference,
452
+ arguments={
453
+ "a": XsdTypes.gmlAbstractGeometryType,
454
+ "b": XsdTypes.gmlAbstractGeometryType,
455
+ },
456
+ returns=XsdTypes.gmlAbstractGeometryType,
457
+ )
458
+
459
+ function_registry.register(
460
+ "union",
367
461
  gis.Union,
368
462
  arguments={
369
- "geometry1": XsdTypes.gmlAbstractGeometryType,
370
- "geometry2": XsdTypes.gmlAbstractGeometryType,
463
+ "a": XsdTypes.gmlAbstractGeometryType,
464
+ "b": XsdTypes.gmlAbstractGeometryType,
371
465
  },
372
- returns=XsdTypes.string,
466
+ returns=XsdTypes.gmlAbstractGeometryType,
373
467
  )
@@ -1,8 +1,8 @@
1
1
  """Storage and registry for stored queries.
2
2
  These definitions follow the WFS spec.
3
3
 
4
- By using the registry, custom stored queries can be registered in this server.
5
- Out of the box, only the mandatory built-in ``GetFeatureById`` query is present.
4
+ By using the :attr:`stored_query_registry`, custom stored queries can be registered in this server.
5
+ Out of the box, only the mandatory built-in :class:`GetFeatureById` query is present.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -30,10 +30,14 @@ __all__ = (
30
30
  "StoredQueryDescription",
31
31
  "StoredQueryRegistry",
32
32
  "stored_query_registry",
33
+ "WFS_LANGUAGE",
34
+ "FES_LANGUAGE",
33
35
  )
34
36
 
35
- WFS_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-WFS::WFS_QueryExpression" # body is <wfs:Query>
36
- FES_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-FES:Filter" # body is <fes:Filter>
37
+ #: The query body is a ``<wfs:Query>``.
38
+ WFS_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-WFS::WFS_QueryExpression"
39
+ #: The query body is a ``<fes:Filter>``.
40
+ FES_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-FES:Filter"
37
41
 
38
42
 
39
43
  @dataclass
@@ -41,7 +45,7 @@ class QueryExpressionText:
41
45
  """Define the body of a stored query.
42
46
 
43
47
  This object type is defined in the WFS spec.
44
- It may contain a wfs:Query or wfs:StoredQuery element.
48
+ It may contain a ``<wfs:Query>`` or ``<fes:Filter>`` element.
45
49
  """
46
50
 
47
51
  #: Which types the query will return.
@@ -61,7 +65,7 @@ class StoredQueryDescription:
61
65
  This is based on the ``<wfs:StoredQueryDescription>`` element,
62
66
  and returned in ``DescribeStoredQueries``.
63
67
 
64
- While it's possible to define multiple QueryExpressionText nodes
68
+ While it's possible to define multiple :class:`QueryExpressionText` nodes
65
69
  as metadata to describe a query, there is still only one implementation.
66
70
  Note there is no 'typeNames=...' parameter for stored queries.
67
71
  Only direct parameters act as input.
@@ -127,7 +131,7 @@ class StoredQueryImplementation:
127
131
 
128
132
 
129
133
  class StoredQueryRegistry:
130
- """Registry of functions to be callable by <wfs:StoredQuery>."""
134
+ """Registry of functions to be callable by ``<wfs:StoredQuery>`` and ``STOREDQUERY_ID=...``."""
131
135
 
132
136
  def __init__(self):
133
137
  self.stored_queries: dict[str, type(StoredQueryImplementation)] = {}
@@ -157,8 +161,7 @@ class StoredQueryRegistry:
157
161
  raise TypeError("Either provide the 'meta' object or 'meta_kwargs'")
158
162
 
159
163
  if query_expression is not None:
160
- self._register(meta, query_expression)
161
- return meta
164
+ return self._register(meta, query_expression)
162
165
  else:
163
166
  return partial(self._register, meta) # decorator effect.
164
167
 
@@ -174,6 +177,7 @@ class StoredQueryRegistry:
174
177
  # for now link both. There is always a single implementation for the metadata.
175
178
  meta.implementation_class = implementation_class
176
179
  self.stored_queries[meta.id] = meta
180
+ return implementation_class # for decorator usage
177
181
 
178
182
  def resolve_query(self, query_id) -> type[StoredQueryDescription]:
179
183
  """Find the stored procedure using the ID."""
@@ -186,6 +190,7 @@ class StoredQueryRegistry:
186
190
  ) from None
187
191
 
188
192
 
193
+ #: The stored query registry
189
194
  stored_query_registry = StoredQueryRegistry()
190
195
 
191
196
 
@@ -211,7 +216,7 @@ class GetFeatureById(StoredQueryImplementation):
211
216
 
212
217
  The execution of the ``GetFeatureById`` query is essentially the same as::
213
218
 
214
- <wfs:Query xmlns:wfs='..." xmlns:fes='...'>
219
+ <wfs:Query xmlns:wfs="..." xmlns:fes="...'>
215
220
  <fes:Filter><fes:ResourceId rid='{ID}'/></fes:Filter>
216
221
  </wfs:Query>
217
222