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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +34 -8
  2. django_gisserver-2.1.dist-info/RECORD +68 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/compat.py +23 -0
  6. gisserver/conf.py +7 -0
  7. gisserver/crs.py +401 -0
  8. gisserver/db.py +126 -51
  9. gisserver/exceptions.py +132 -4
  10. gisserver/extensions/__init__.py +4 -0
  11. gisserver/{parsers/fes20 → extensions}/functions.py +131 -31
  12. gisserver/extensions/queries.py +266 -0
  13. gisserver/features.py +253 -181
  14. gisserver/geometries.py +64 -311
  15. gisserver/management/__init__.py +0 -0
  16. gisserver/management/commands/__init__.py +0 -0
  17. gisserver/management/commands/loadgeojson.py +311 -0
  18. gisserver/operations/base.py +130 -312
  19. gisserver/operations/wfs20.py +399 -375
  20. gisserver/output/__init__.py +14 -49
  21. gisserver/output/base.py +198 -144
  22. gisserver/output/csv.py +78 -75
  23. gisserver/output/geojson.py +37 -37
  24. gisserver/output/gml32.py +287 -259
  25. gisserver/output/iters.py +207 -0
  26. gisserver/output/results.py +73 -61
  27. gisserver/output/stored.py +143 -0
  28. gisserver/output/utils.py +81 -169
  29. gisserver/output/xmlschema.py +85 -46
  30. gisserver/parsers/__init__.py +10 -10
  31. gisserver/parsers/ast.py +426 -0
  32. gisserver/parsers/fes20/__init__.py +89 -31
  33. gisserver/parsers/fes20/expressions.py +172 -58
  34. gisserver/parsers/fes20/filters.py +116 -45
  35. gisserver/parsers/fes20/identifiers.py +66 -28
  36. gisserver/parsers/fes20/lookups.py +146 -0
  37. gisserver/parsers/fes20/operators.py +417 -161
  38. gisserver/parsers/fes20/sorting.py +113 -34
  39. gisserver/parsers/gml/__init__.py +17 -25
  40. gisserver/parsers/gml/base.py +36 -15
  41. gisserver/parsers/gml/geometries.py +105 -44
  42. gisserver/parsers/ows/__init__.py +25 -0
  43. gisserver/parsers/ows/kvp.py +198 -0
  44. gisserver/parsers/ows/requests.py +160 -0
  45. gisserver/parsers/query.py +179 -0
  46. gisserver/parsers/values.py +87 -4
  47. gisserver/parsers/wfs20/__init__.py +39 -0
  48. gisserver/parsers/wfs20/adhoc.py +253 -0
  49. gisserver/parsers/wfs20/base.py +148 -0
  50. gisserver/parsers/wfs20/projection.py +103 -0
  51. gisserver/parsers/wfs20/requests.py +483 -0
  52. gisserver/parsers/wfs20/stored.py +193 -0
  53. gisserver/parsers/xml.py +261 -0
  54. gisserver/projection.py +367 -0
  55. gisserver/static/gisserver/index.css +20 -4
  56. gisserver/templates/gisserver/base.html +12 -0
  57. gisserver/templates/gisserver/index.html +9 -15
  58. gisserver/templates/gisserver/service_description.html +12 -6
  59. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  60. gisserver/templates/gisserver/wfs/feature_field.html +3 -3
  61. gisserver/templates/gisserver/wfs/feature_type.html +35 -13
  62. gisserver/templatetags/gisserver_tags.py +20 -0
  63. gisserver/types.py +445 -313
  64. gisserver/views.py +227 -62
  65. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  66. gisserver/parsers/base.py +0 -149
  67. gisserver/parsers/fes20/query.py +0 -285
  68. gisserver/parsers/tags.py +0 -102
  69. gisserver/queries/__init__.py +0 -37
  70. gisserver/queries/adhoc.py +0 -185
  71. gisserver/queries/base.py +0 -186
  72. gisserver/queries/projection.py +0 -240
  73. gisserver/queries/stored.py +0 -206
  74. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  75. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  76. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
  77. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
gisserver/db.py CHANGED
@@ -2,14 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from functools import lru_cache, reduce
6
7
 
7
- 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
8
10
  from django.db import connection, connections, models
9
11
 
10
12
  from gisserver import conf
11
- from gisserver.geometries import CRS
12
- from gisserver.types import GmlElement
13
+ from gisserver.crs import CRS, WGS84
14
+ from gisserver.geometries import WGS84BoundingBox
15
+ from gisserver.types import GeometryXsdElement
16
+
17
+ logger = logging.getLogger(__name__)
13
18
 
14
19
 
15
20
  class AsEWKT(functions.GeoFunc):
@@ -26,6 +31,8 @@ class AsEWKT(functions.GeoFunc):
26
31
 
27
32
 
28
33
  class AsGML(functions.AsGML):
34
+ """An overwritten ST_AsGML() function to handle PostGIS extensions."""
35
+
29
36
  name = "AsGML"
30
37
 
31
38
  def __init__(
@@ -34,20 +41,55 @@ class AsGML(functions.AsGML):
34
41
  version=3,
35
42
  precision=conf.GISSERVER_DB_PRECISION,
36
43
  envelope=False,
44
+ is_latlon=False,
37
45
  **extra,
38
46
  ):
39
- # Note that Django's AsGml, version=2, precision=8
40
- # the options is postgres-only.
47
+ # Note that Django's AsGml the defaults are: version=2, precision=8
41
48
  super().__init__(expression, version, precision, **extra)
42
49
  self.envelope = envelope
50
+ self.is_latlon = is_latlon
43
51
 
44
52
  def as_postgresql(self, compiler, connection, **extra_context):
45
53
  # Fill options parameter (https://postgis.net/docs/ST_AsGML.html)
46
54
  options = 33 if self.envelope else 1 # 32 = bbox, 1 = long CRS urn
55
+ if self.is_latlon:
56
+ # PostGIS provides the data in longitude/latitude format (east/north to look like x/y).
57
+ # However, WFS 2.0 fixed their axis by following the authority. The ST_AsGML() doesn't
58
+ # detect this on their own. It needs to be told to flip the coordinates.
59
+ # Passing option 16 flips the coordinates unconditionally, like using ST_FlipCoordinates()
60
+ # but more efficiently done during rendering. That happens in:
61
+ # https://github.com/postgis/postgis/blob/81e2bc783b77cc740291445e992658e1db7179e0/liblwgeom/lwout_gml.c#L121
62
+ options |= 16
63
+
47
64
  template = f"%(function)s(%(expressions)s, {options})"
48
65
  return self.as_sql(compiler, connection, template=template, **extra_context)
49
66
 
50
67
 
68
+ class ST_SetSRID(functions.Transform):
69
+ """PostGIS function to assign an SRID to geometry.
70
+ When this is applied to the result from an ``Extent`` aggegrate,
71
+ it will convert that ``BBOX(...)`` value into a ``POLYGON(...)``.
72
+ """
73
+
74
+ name = "SetSRID"
75
+ geom_param_pos = ()
76
+
77
+ @property
78
+ def geo_field(self):
79
+ return PolygonField(srid=self.source_expressions[1].value)
80
+
81
+
82
+ class Box2D(functions.GeomOutputGeoFunc):
83
+ """PostGIS function (without ST_) that converts a ``POLYGON(...)`` back to a ``BOX(...)``."""
84
+
85
+ name = "Box2D"
86
+ function = "Box2D" # no ST_ prefix.
87
+ output_field = ExtentField()
88
+
89
+ def convert_value(self, value, expression, connection):
90
+ return connection.ops.convert_extent(value)
91
+
92
+
51
93
  class ST_Union(functions.Union):
52
94
  name = "Union"
53
95
  arity = None
@@ -59,6 +101,31 @@ class ST_Union(functions.Union):
59
101
  return self.as_sql(compiler, connection, **extra_context)
60
102
 
61
103
 
104
+ def get_wgs84_bounding_box(
105
+ queryset: models.QuerySet, geo_element: GeometryXsdElement
106
+ ) -> WGS84BoundingBox:
107
+ """Calculate the WGS84 bounding box for a feature.
108
+
109
+ Note that the ``<ows:WGS84BoundingBox>`` element
110
+ always uses longitude/latitude, and doesn't describe a CRS.
111
+ """
112
+ if connections[queryset.db].vendor == "postgresql":
113
+ # Allow a more efficient way to combine geometry first, transform once later
114
+ box = queryset.aggregate(
115
+ box=Box2D(
116
+ functions.Transform(
117
+ ST_SetSRID(Extent(geo_element.orm_path), srid=geo_element.source_srid),
118
+ srid=WGS84.srid,
119
+ )
120
+ )
121
+ )["box"]
122
+ else:
123
+ # Need to transform each element to srid 4326 before combining it in an extent.
124
+ box = queryset.aggregate(box=Extent(get_db_geometry_target(geo_element, WGS84)))["box"]
125
+
126
+ return WGS84BoundingBox(*box) if box else None
127
+
128
+
62
129
  def get_geometries_union(
63
130
  expressions: list[str | functions.GeoFunc], using="default"
64
131
  ) -> str | functions.Union:
@@ -78,63 +145,71 @@ def get_geometries_union(
78
145
  return reduce(functions.Union, expressions)
79
146
 
80
147
 
81
- def conditional_transform(
82
- expression: str | functions.GeoFunc, expression_srid: int, output_srid: int
83
- ) -> str | functions.Transform:
84
- """Apply a CRS Transform to the queried field if that is needed."""
85
- if expression_srid != output_srid:
86
- expression = functions.Transform(expression, srid=output_srid)
87
- return expression
88
-
89
-
90
- def build_db_annotations(
91
- selects: dict[str, str | functions.Func],
92
- name_template: str,
93
- wrapper_func: type[functions.Func],
94
- ) -> dict:
95
- """Utility to build annotations for all geometry fields for an XSD type.
96
- This is used by various DB-optimized rendering methods.
148
+ def replace_queryset_geometries(
149
+ queryset: models.QuerySet,
150
+ geo_elements: list[GeometryXsdElement],
151
+ output_crs: CRS,
152
+ wrapper_func: type[functions.GeoFunc],
153
+ **wrapper_kwargs,
154
+ ) -> models.QuerySet:
155
+ """Replace the queryset geometry retrieval with a database-rendered version.
156
+
157
+ This uses absolute paths in the queryset, but can use relative paths for related querysets.
97
158
  """
98
- return {
99
- escape_xml_name(name, name_template): wrapper_func(target)
100
- for name, target in selects.items()
101
- }
159
+ defer_names = []
160
+ as_geo_map = {}
161
+ for geo_element in geo_elements:
162
+ if geo_element.source is not None: # excludes GmlBoundedByElement
163
+ defer_names.append(geo_element.local_orm_path)
164
+ annotation_name = _as_annotation_name(geo_element.local_orm_path, wrapper_func)
165
+ as_geo_map[annotation_name] = wrapper_func(
166
+ get_db_geometry_target(geo_element, output_crs, use_relative_path=True),
167
+ **wrapper_kwargs,
168
+ )
169
+
170
+ if not defer_names:
171
+ return queryset
172
+
173
+ logger.debug(
174
+ "DB rendering: QuerySet for %s replacing %r with %r",
175
+ queryset.model._meta.label,
176
+ defer_names,
177
+ list(as_geo_map.keys()),
178
+ )
179
+ return queryset.defer(*defer_names).annotate(**as_geo_map)
102
180
 
103
181
 
104
- def get_db_annotation(instance: models.Model, name: str, name_template: str):
105
- """Retrieve the value that an annotation has added to the model."""
106
- # The "name" allows any XML-tag elements, escape the most obvious
107
- escaped_name = escape_xml_name(name, name_template)
182
+ def get_db_rendered_geometry(
183
+ instance: models.Model, geo_element: GeometryXsdElement, replacement: type[functions.GeoFunc]
184
+ ) -> str:
185
+ """Retrieve the database-rendered geometry.
186
+ This includes formatted EWKT or GML output, rendered by the database.
187
+ """
188
+ annotation_name = _as_annotation_name(geo_element.local_orm_path, replacement)
108
189
  try:
109
- return getattr(instance, escaped_name)
190
+ return getattr(instance, annotation_name)
110
191
  except AttributeError as e:
192
+ prefix = _as_annotation_name("", replacement)
193
+ available = ", ".join(key for key in instance.__dict__ if key.startswith(prefix)) or "none"
111
194
  raise AttributeError(
112
- f" DB annotation {instance._meta.model_name}.{escaped_name}"
113
- f" not found (using {name_template})"
195
+ f" DB annotation {instance._meta.label}.{annotation_name} not found. Found {available}."
114
196
  ) from e
115
197
 
116
198
 
117
199
  @lru_cache
118
- def escape_xml_name(name: str, template="{name}") -> str:
200
+ def _as_annotation_name(name: str, func: type[functions.GeoFunc]) -> str:
119
201
  """Escape an XML name to be used as annotation name."""
120
- return template.format(name=name.replace(".", "_"))
202
+ return f"_{func.__name__}_{name.replace('.', '_')}"
121
203
 
122
204
 
123
- def get_db_geometry_selects(
124
- gml_elements: list[GmlElement], output_crs: CRS
125
- ) -> dict[str, str | functions.Transform]:
126
- """Utility to generate select clauses for the geometry fields of a type.
127
- Key is the xsd element name, value is the database select expression.
205
+ def get_db_geometry_target(
206
+ geo_element: GeometryXsdElement, output_crs: CRS, use_relative_path: bool = False
207
+ ) -> str | functions.Transform:
208
+ """Translate a GML geometry field into the proper expression for retrieving it from the database.
209
+ The path will be wrapped into a CRS Transform function if needed.
128
210
  """
129
- return {
130
- gml_element.name: get_db_geometry_target(gml_element, output_crs)
131
- for gml_element in gml_elements
132
- if gml_element.source is not None
133
- }
134
-
135
-
136
- def get_db_geometry_target(gml_element: GmlElement, output_crs: CRS) -> str | functions.Transform:
137
- """Wrap the selection of a geometry field in a CRS Transform if needed."""
138
- return conditional_transform(
139
- gml_element.orm_path, gml_element.source.srid, output_srid=output_crs.srid
140
- )
211
+ orm_path = geo_element.local_orm_path if use_relative_path else geo_element.orm_path
212
+ if geo_element.source_srid != output_crs.srid:
213
+ return functions.Transform(orm_path, srid=output_crs.srid)
214
+ else:
215
+ return orm_path
gisserver/exceptions.py CHANGED
@@ -8,10 +8,126 @@ 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
+
13
+ import logging
14
+ import typing
15
+ from contextlib import contextmanager
16
+
11
17
  from django.conf import settings
18
+ from django.core.exceptions import FieldError, ValidationError
19
+ from django.db import InternalError, ProgrammingError
12
20
  from django.http import HttpResponse
13
21
  from django.utils.html import format_html
14
22
 
23
+ from gisserver import conf
24
+
25
+ if typing.TYPE_CHECKING:
26
+ from gisserver.parsers import wfs20
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @contextmanager
32
+ def wrap_parser_errors(name: str, locator: str):
33
+ """Convert the value into a Python format.
34
+ This catches any typical exceptions and transforms them into an OWSException.
35
+ """
36
+ try:
37
+ yield
38
+ except ExternalParsingError as e:
39
+ raise OperationParsingFailed(
40
+ f"Unable to parse {name} argument: {e}", locator=locator
41
+ ) from None
42
+ except (TypeError, ValueError, NotImplementedError) as e:
43
+ # TypeError/ValueError are raised by most handlers for unexpected data
44
+ # The NotImplementedError can be raised by fes parsing.
45
+ raise InvalidParameterValue(f"Invalid {name} argument: {e}", locator=locator) from None
46
+
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
+
15
131
 
16
132
  class ExternalValueError(ValueError):
17
133
  """Raise a ValueError for external input.
@@ -24,6 +140,14 @@ class ExternalParsingError(ValueError):
24
140
  """Raise a ValueError for a parsing problem."""
25
141
 
26
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
+
27
151
  class OWSException(Exception):
28
152
  """Base class for XML based exceptions in this module."""
29
153
 
@@ -38,7 +162,7 @@ class OWSException(Exception):
38
162
  def __init__(self, text=None, code=None, locator=None, status_code=None):
39
163
  text = text or self.text_template.format(code=self.code, locator=locator)
40
164
  if (code and len(text) < len(code)) or (locator and len(text) < len(locator)):
41
- raise ValueError("text/locator arguments are switched")
165
+ raise ValueError(f"text/locator arguments are switched: {text!r}, locator={locator!r}")
42
166
 
43
167
  super().__init__(text)
44
168
  self.locator = locator
@@ -46,15 +170,19 @@ class OWSException(Exception):
46
170
  self.code = code or self.code or self.__class__.__name__
47
171
  self.status_code = status_code or self.status_code
48
172
 
49
- def as_response(self):
173
+ def as_response(self) -> HttpResponse:
174
+ """Return the excetion as HTTP response."""
175
+ logger.debug("Returning HTTP %d for %s: %s", self.status_code, self.code, self.text)
176
+ xml_body = self.as_xml()
50
177
  return HttpResponse(
51
- b'<?xml version="1.0" encoding="UTF-8"?>\n%b' % self.as_xml().encode("utf-8"),
178
+ b'<?xml version="1.0" encoding="UTF-8"?>\n%b' % xml_body.encode("utf-8"),
52
179
  content_type="text/xml; charset=utf-8",
53
180
  status=self.status_code,
54
181
  reason=self.reason,
55
182
  )
56
183
 
57
- def as_xml(self):
184
+ def as_xml(self) -> str:
185
+ """Serialize the exception to an XML string."""
58
186
  return format_html(
59
187
  "<ows:ExceptionReport"
60
188
  ' xmlns:ows="http://www.opengis.net/ows/1.1"'
@@ -0,0 +1,4 @@
1
+ """Package that allows third party code to extend the server.
2
+
3
+ This allows registering extra stored procedures or filter functions.
4
+ """
@@ -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,8 +34,14 @@ FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
22
34
 
23
35
  @dataclass(order=True)
24
36
  class FesFunction:
25
- """Wrapper that defines a fes function, with type descriptions.
26
- This is also used to provide metadata to the GetCapabilities call.
37
+ """A registered database function that can be used by ``<fes:Function name="...">``.
38
+
39
+ The :class:`~gisserver.parsers.fes20.expressions.Function` class will resolve
40
+ these registered functions by name, and call :meth:`build_query` to include them
41
+ in the database query. This will actually insert a Django ORM function in the query!
42
+
43
+ This wrapper class also provides the metadata and type descriptions of the function,
44
+ which is exposed in the ``GetCapabilities`` call.
27
45
  """
28
46
 
29
47
  #: Name of the function
@@ -50,7 +68,7 @@ class FesFunction:
50
68
 
51
69
 
52
70
  class FesFunctionRegistry:
53
- """Registry of functions to be callable by <fes:Function>.
71
+ """Registry of functions to be callable by ``<fes:Function>``.
54
72
 
55
73
  The registered functions should be capable of running an SQL function.
56
74
  """
@@ -59,9 +77,11 @@ class FesFunctionRegistry:
59
77
  self.functions = {}
60
78
 
61
79
  def __bool__(self):
80
+ """Tell whether there are functions"""
62
81
  return bool(self.functions)
63
82
 
64
83
  def __iter__(self):
84
+ """Iterate over the functions"""
65
85
  return iter(sorted(self.functions.values())) # for template rendering
66
86
 
67
87
  def register(
@@ -102,19 +122,15 @@ class FesFunctionRegistry:
102
122
  return self.functions[function_name]
103
123
  except KeyError:
104
124
  raise InvalidParameterValue(
105
- "filter", f"Unsupported function: {function_name}"
125
+ f"Unsupported function: {function_name}", locator="filter"
106
126
  ) from None
107
127
 
108
128
 
129
+ #: The function registry
109
130
  function_registry = FesFunctionRegistry()
110
131
 
111
132
 
112
133
  # Register a set of default SQL functions.
113
- # These are based on GeoServer:
114
- # https://docs.geoserver.org/latest/en/user/filter/function_reference.html
115
- # Not implemented:
116
- # - Aggregates (like Collection_*)
117
- # - Comparisons (which already has fes variants)
118
134
 
119
135
  # -- strings
120
136
 
@@ -125,6 +141,27 @@ function_registry.register(
125
141
  returns=XsdTypes.string,
126
142
  )
127
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
+
128
165
  function_registry.register(
129
166
  "strToLowerCase",
130
167
  functions.Lower,
@@ -306,25 +343,25 @@ function_registry.register(
306
343
  # -- geometric
307
344
 
308
345
  function_registry.register(
309
- "Area",
346
+ "area",
310
347
  gis.Area,
311
- arguments={"geometry": XsdTypes.gmlAbstractGeometryType},
348
+ arguments={"geom": XsdTypes.gmlAbstractGeometryType},
312
349
  returns=XsdTypes.double,
313
350
  )
314
351
 
315
352
  function_registry.register(
316
- "Centroid",
353
+ "centroid",
317
354
  gis.Centroid,
318
- arguments={"features": XsdTypes.gmlAbstractGeometryType},
355
+ arguments={"geom": XsdTypes.gmlAbstractGeometryType},
319
356
  returns=XsdTypes.string,
320
357
  )
321
358
 
322
359
  function_registry.register(
323
- "Difference",
360
+ "difference",
324
361
  gis.Difference,
325
362
  arguments={
326
- "geometry1": XsdTypes.gmlAbstractGeometryType,
327
- "geometry2": XsdTypes.gmlAbstractGeometryType,
363
+ "a": XsdTypes.gmlAbstractGeometryType,
364
+ "b": XsdTypes.gmlAbstractGeometryType,
328
365
  },
329
366
  returns=XsdTypes.string,
330
367
  )
@@ -333,35 +370,98 @@ function_registry.register(
333
370
  "distance",
334
371
  gis.Distance,
335
372
  arguments={
336
- "geometry1": XsdTypes.gmlAbstractGeometryType,
337
- "geometry2": XsdTypes.gmlAbstractGeometryType,
373
+ "a": XsdTypes.gmlAbstractGeometryType,
374
+ "b": XsdTypes.gmlAbstractGeometryType,
338
375
  },
339
376
  returns=XsdTypes.double,
340
377
  )
341
378
 
342
379
  function_registry.register(
343
- "Envelope",
380
+ "envelope",
344
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,
345
389
  arguments={"geometry": XsdTypes.gmlAbstractGeometryType},
346
- returns=XsdTypes.string,
390
+ returns=XsdTypes.float,
347
391
  )
348
392
 
349
393
  function_registry.register(
350
- "Intersection",
394
+ "intersection",
351
395
  gis.Intersection,
352
396
  arguments={
353
- "geometry1": XsdTypes.gmlAbstractGeometryType,
354
- "geometry2": XsdTypes.gmlAbstractGeometryType,
397
+ "a": XsdTypes.gmlAbstractGeometryType,
398
+ "b": XsdTypes.gmlAbstractGeometryType,
355
399
  },
356
- returns=XsdTypes.string,
400
+ returns=XsdTypes.gmlAbstractGeometryType,
357
401
  )
358
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
+
359
413
  function_registry.register(
360
- "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",
361
461
  gis.Union,
362
462
  arguments={
363
- "geometry1": XsdTypes.gmlAbstractGeometryType,
364
- "geometry2": XsdTypes.gmlAbstractGeometryType,
463
+ "a": XsdTypes.gmlAbstractGeometryType,
464
+ "b": XsdTypes.gmlAbstractGeometryType,
365
465
  },
366
- returns=XsdTypes.string,
466
+ returns=XsdTypes.gmlAbstractGeometryType,
367
467
  )