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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/METADATA +14 -4
  2. django_gisserver-2.0.dist-info/RECORD +66 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/compat.py +23 -0
  6. gisserver/conf.py +7 -0
  7. gisserver/db.py +56 -47
  8. gisserver/exceptions.py +26 -2
  9. gisserver/extensions/__init__.py +4 -0
  10. gisserver/{parsers/fes20 → extensions}/functions.py +10 -4
  11. gisserver/extensions/queries.py +261 -0
  12. gisserver/features.py +220 -156
  13. gisserver/geometries.py +32 -37
  14. gisserver/management/__init__.py +0 -0
  15. gisserver/management/commands/__init__.py +0 -0
  16. gisserver/management/commands/loadgeojson.py +291 -0
  17. gisserver/operations/base.py +122 -308
  18. gisserver/operations/wfs20.py +423 -337
  19. gisserver/output/__init__.py +9 -48
  20. gisserver/output/base.py +178 -139
  21. gisserver/output/csv.py +65 -74
  22. gisserver/output/geojson.py +34 -35
  23. gisserver/output/gml32.py +254 -246
  24. gisserver/output/iters.py +207 -0
  25. gisserver/output/results.py +52 -26
  26. gisserver/output/stored.py +143 -0
  27. gisserver/output/utils.py +75 -170
  28. gisserver/output/xmlschema.py +85 -46
  29. gisserver/parsers/__init__.py +10 -10
  30. gisserver/parsers/ast.py +320 -0
  31. gisserver/parsers/fes20/__init__.py +13 -27
  32. gisserver/parsers/fes20/expressions.py +82 -38
  33. gisserver/parsers/fes20/filters.py +111 -43
  34. gisserver/parsers/fes20/identifiers.py +44 -26
  35. gisserver/parsers/fes20/lookups.py +144 -0
  36. gisserver/parsers/fes20/operators.py +331 -127
  37. gisserver/parsers/fes20/sorting.py +104 -33
  38. gisserver/parsers/gml/__init__.py +12 -11
  39. gisserver/parsers/gml/base.py +5 -2
  40. gisserver/parsers/gml/geometries.py +69 -35
  41. gisserver/parsers/ows/__init__.py +25 -0
  42. gisserver/parsers/ows/kvp.py +190 -0
  43. gisserver/parsers/ows/requests.py +158 -0
  44. gisserver/parsers/query.py +175 -0
  45. gisserver/parsers/values.py +26 -0
  46. gisserver/parsers/wfs20/__init__.py +37 -0
  47. gisserver/parsers/wfs20/adhoc.py +245 -0
  48. gisserver/parsers/wfs20/base.py +143 -0
  49. gisserver/parsers/wfs20/projection.py +103 -0
  50. gisserver/parsers/wfs20/requests.py +482 -0
  51. gisserver/parsers/wfs20/stored.py +192 -0
  52. gisserver/parsers/xml.py +249 -0
  53. gisserver/projection.py +357 -0
  54. gisserver/static/gisserver/index.css +12 -1
  55. gisserver/templates/gisserver/index.html +1 -1
  56. gisserver/templates/gisserver/service_description.html +2 -2
  57. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  58. gisserver/templates/gisserver/wfs/feature_field.html +2 -2
  59. gisserver/templatetags/gisserver_tags.py +20 -0
  60. gisserver/types.py +322 -259
  61. gisserver/views.py +198 -56
  62. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  63. gisserver/parsers/base.py +0 -149
  64. gisserver/parsers/fes20/query.py +0 -285
  65. gisserver/parsers/tags.py +0 -102
  66. gisserver/queries/__init__.py +0 -37
  67. gisserver/queries/adhoc.py +0 -185
  68. gisserver/queries/base.py +0 -186
  69. gisserver/queries/projection.py +0 -240
  70. gisserver/queries/stored.py +0 -206
  71. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  72. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  73. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/LICENSE +0 -0
  74. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,158 @@
1
+ """Request parsing for the common Generic Open Web Services (OWS) protocol bits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+
8
+ from django.http import QueryDict
9
+
10
+ from gisserver.exceptions import (
11
+ ExternalParsingError,
12
+ InvalidParameterValue,
13
+ OperationNotSupported,
14
+ OperationParsingFailed,
15
+ )
16
+ from gisserver.parsers.ast import BaseNode, tag_registry
17
+ from gisserver.parsers.xml import NSElement, parse_xml_from_string, split_ns, xmlns
18
+
19
+ from .kvp import KVPRequest
20
+
21
+ __all__ = (
22
+ "BaseOwsRequest",
23
+ "resolve_kvp_parser_class",
24
+ "resolve_xml_parser_class",
25
+ "parse_get_request",
26
+ "parse_post_request",
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class BaseOwsRequest(BaseNode):
32
+ """Base request data for all request types of the OWS standards.
33
+ This mirrors the ``<wfs:BaseRequestType>`` element from the WFS spec.
34
+ """
35
+
36
+ # dataclass limitation: when defaults are added, subclasses can't have required parameters anymore.
37
+ service: str
38
+ version: str | None # none for GetCapabilities only
39
+ handle: str | None
40
+
41
+ @classmethod
42
+ def from_xml(cls, element: NSElement):
43
+ """Initialize from an XML POST request."""
44
+ return cls(**cls.base_xml_init_parameters(element))
45
+
46
+ @classmethod
47
+ def from_kvp_request(cls, kvp: KVPRequest):
48
+ """Initialize from an KVP GET request."""
49
+ return cls(**cls.base_kvp_init_parameters(kvp))
50
+
51
+ @classmethod
52
+ def base_xml_init_parameters(cls, element: NSElement) -> dict:
53
+ """Parse the base attributes.
54
+ This parses the syntax such as::
55
+
56
+ <wfs:BaseRequest service="WFS" version="2.0.0" handle="...">
57
+
58
+ """
59
+ return dict(
60
+ service=element.get_str_attribute("service"),
61
+ version=element.get_str_attribute("version"),
62
+ handle=element.attrib.get("handle"),
63
+ )
64
+
65
+ @classmethod
66
+ def base_kvp_init_parameters(cls, kvp: KVPRequest) -> dict:
67
+ """Parse the common Key-Value-Pair format (GET request parameters).
68
+ This parses the syntax::
69
+
70
+ ?SERVICE=WFS&VERSION=2.0.0
71
+ """
72
+ return dict(
73
+ service=kvp.get_str("SERVICE"),
74
+ version=kvp.get_str("VERSION"),
75
+ handle=None,
76
+ )
77
+
78
+ def as_kvp(self) -> dict:
79
+ """Translate the POST request into KVP GET parameters. This is needed for pagination."""
80
+ return {
81
+ "SERVICE": self.service,
82
+ "VERSION": str(self.version),
83
+ "REQUEST": split_ns(self.xml_tags[0])[1],
84
+ }
85
+
86
+
87
+ @lru_cache
88
+ def _get_kvp_parsers() -> dict[str, dict[str, type[BaseOwsRequest]]]:
89
+ """Find which classes are registered."""
90
+ # This late initialization keeps the ability open in the future
91
+ # to handle request types that are registered in third party code.
92
+ ows_parsers = {}
93
+
94
+ for xml_name, parser_class in tag_registry.find_subclasses(BaseOwsRequest).items():
95
+ namespace, local_name = split_ns(xml_name)
96
+ ows_parsers.setdefault(namespace, {})[local_name.upper()] = parser_class
97
+
98
+ return ows_parsers
99
+
100
+
101
+ def resolve_kvp_parser_class(kvp: KVPRequest) -> type[BaseOwsRequest]:
102
+ """Find the appropriate class to parse the KVP GET request data."""
103
+ service = kvp.get_str("service").upper()
104
+ request = kvp.get_str("request")
105
+
106
+ # Find the appropriate request object
107
+ service_types = _get_kvp_parsers()
108
+ try:
109
+ # Translating the GET parameter to a namespace makes connecting to the parser registration.
110
+ # This doesn't take the VERSION/ACCEPTVERSIONS into account yet.
111
+ namespace = xmlns[service.lower()].value
112
+ request_classes = service_types[namespace]
113
+ except KeyError:
114
+ raise InvalidParameterValue(
115
+ f"Unsupported service type: {service}.", locator="service"
116
+ ) from None
117
+
118
+ try:
119
+ request_cls = request_classes[request.upper()]
120
+ except KeyError:
121
+ allowed = ", ".join(node.xml_tags[0] for node in request_classes.values())
122
+ raise OperationNotSupported(
123
+ f"'{request}' is not implemented, supported are: {allowed}.",
124
+ locator="request",
125
+ ) from None
126
+
127
+ return request_cls
128
+
129
+
130
+ def resolve_xml_parser_class(root: NSElement) -> type[BaseOwsRequest]:
131
+ """Find the correct class to parse the XML POST data with."""
132
+ return tag_registry.resolve_class(root, allowed_types=(BaseOwsRequest,))
133
+
134
+
135
+ def parse_get_request(
136
+ query_string: str | dict[str, str], ns_aliases: dict | None = None
137
+ ) -> BaseOwsRequest:
138
+ """Parse the WFS KVP GET request format into the internal request objects.
139
+ Most code calls the resolver internally, but this variation is easier for unit testing.
140
+ """
141
+ if isinstance(query_string, str):
142
+ query_string = QueryDict(query_string.lstrip("?"))
143
+
144
+ kvp = KVPRequest(query_string, ns_aliases=ns_aliases)
145
+ request_cls = resolve_kvp_parser_class(kvp)
146
+ return request_cls.from_kvp_request(kvp)
147
+
148
+
149
+ def parse_post_request(xml_string: str | bytes, ns_aliases: dict | None = None) -> BaseOwsRequest:
150
+ """Parse the XML POST request format into the internal request objects.
151
+ Most code calls the resolver internally, but this variation is easier for unit testing.
152
+ """
153
+ try:
154
+ root = parse_xml_from_string(xml_string, extra_ns_aliases=ns_aliases)
155
+ except ExternalParsingError as e:
156
+ raise OperationParsingFailed(f"Unable to parse XML: {e}") from e
157
+
158
+ return tag_registry.node_from_xml(root, allowed_types=(BaseOwsRequest,))
@@ -0,0 +1,175 @@
1
+ """The intermediate query result used by the various ``build_...()`` methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import operator
7
+ from datetime import date, datetime
8
+ from functools import reduce
9
+ from typing import Union
10
+
11
+ from django.contrib.gis.geos import GEOSGeometry
12
+ from django.core.exceptions import FieldError
13
+ from django.db.models import Q, QuerySet
14
+ from django.db.models.expressions import Combinable, Func
15
+
16
+ from gisserver.features import FeatureType
17
+
18
+ logger = logging.getLogger(__name__)
19
+ RhsTypes = Union[Combinable, Func, Q, GEOSGeometry, bool, int, str, date, datetime, tuple]
20
+
21
+
22
+ class CompiledQuery:
23
+ """Intermediate data for translating FES queries to Django.
24
+
25
+ This class effectively contains all data from the ``<fes:Filter>`` object,
26
+ but using a format that can be translated to a django QuerySet.
27
+
28
+ As the Abstract Syntax Tree of a FES-filter creates the ORM query,
29
+ it fills this object with all intermediate bits. This allows building
30
+ the final QuerySet object in a single round. Each ``build_...()`` method
31
+ in the tree may add extra lookups and annotations.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ feature_types: list[FeatureType],
37
+ lookups: list[Q] | None = None,
38
+ typed_lookups: dict[str, list[Q]] | None = None,
39
+ annotations: dict[str, Combinable | Q] | None = None,
40
+ ):
41
+ """
42
+ :param feature_types: The feature types this query uses.
43
+ Typically, this is one feature unless a JOIN syntax is used.
44
+
45
+ The extra parameters of the init method ar typically used only in unit tests.
46
+ """
47
+ self.feature_types = feature_types
48
+ self.lookups = lookups or []
49
+ self.typed_lookups = typed_lookups or {}
50
+ self.annotations = annotations or {}
51
+ self.aliases = 0
52
+ self.extra_lookups: list[Q] = []
53
+ self.ordering: list[str] = []
54
+ self.is_empty = False
55
+ self.distinct = False
56
+
57
+ def add_annotation(self, value: Combinable | Q) -> str:
58
+ """Create a named-alias for a function/Q object.
59
+ This alias can be used in a comparison, where expressions are used as left-hand-side.
60
+ """
61
+ self.aliases += 1
62
+ name = f"a{self.aliases}"
63
+ self.annotations[name] = value
64
+ return name
65
+
66
+ def add_distinct(self):
67
+ self.distinct = True
68
+
69
+ def add_lookups(self, q_object: Q, type_name: str | None = None):
70
+ """Register an extra 'WHERE' clause of the query.
71
+ This is used for comparisons, ID selectors and other query types.
72
+ """
73
+ if not isinstance(q_object, Q):
74
+ raise TypeError()
75
+
76
+ if type_name is not None:
77
+ if type_name not in self.typed_lookups:
78
+ self.typed_lookups[type_name] = []
79
+ self.typed_lookups[type_name].append(q_object)
80
+ else:
81
+ self.lookups.append(q_object)
82
+
83
+ def add_extra_lookup(self, q_object: Q):
84
+ """Temporary stash an extra lookup that the expression can't return yet.
85
+ This is used for XPath selectors that also filter on attributes,
86
+ e.g. "element[@attr=..]/child". The attribute lookup is processed as another filter.
87
+ """
88
+ if not isinstance(q_object, Q):
89
+ raise TypeError()
90
+ self.extra_lookups.append(q_object)
91
+
92
+ def add_ordering(self, ordering: list[str]):
93
+ """Read the desired result ordering from a ``<fes:SortBy>`` element."""
94
+ self.ordering.extend(ordering)
95
+
96
+ def apply_extra_lookups(self, comparison: Q) -> Q:
97
+ """Combine stashed lookups with the provided Q object.
98
+
99
+ This is called for functions that compile a "Q" object.
100
+ In case a node added extra lookups (for attributes), these are combined here
101
+ with the actual comparison.
102
+ """
103
+ if not self.extra_lookups:
104
+ return comparison
105
+
106
+ # The extra lookups are used for XPath queries such as "/node[@attr=..]/foo".
107
+ # A <ValueReference> with such lookup also requires to limit the filtered results,
108
+ # in addition to the comparison operator code that is wrapped up here.
109
+ result = reduce(operator.and_, [comparison] + self.extra_lookups)
110
+ self.extra_lookups.clear()
111
+ return result
112
+
113
+ def mark_empty(self):
114
+ """Mark as returning no results."""
115
+ self.is_empty = True
116
+
117
+ def get_queryset(self) -> QuerySet:
118
+ """Apply the filters and lookups to the queryset."""
119
+ queryset = self.feature_types[0].get_queryset()
120
+ if self.is_empty:
121
+ return queryset.none()
122
+
123
+ if self.extra_lookups:
124
+ # Each time an expression node calls add_extra_lookup(),
125
+ # the parent should have used apply_extra_lookups()
126
+ raise RuntimeError("apply_extra_lookups() was not called")
127
+
128
+ # All are applied at once.
129
+ if self.annotations:
130
+ queryset = queryset.annotate(**self.annotations)
131
+
132
+ lookups = self.lookups
133
+ try:
134
+ lookups += self.typed_lookups.pop(self.feature_types[0].xml_name)
135
+ except KeyError:
136
+ pass
137
+ if self.typed_lookups:
138
+ raise RuntimeError(
139
+ "Types lookups defined for unknown feature types: %r", list(self.typed_lookups)
140
+ )
141
+
142
+ if lookups:
143
+ try:
144
+ queryset = queryset.filter(*lookups)
145
+ except FieldError as e:
146
+ logger.debug("Query failed: %s, constructed query: %r", e.args[0], lookups)
147
+ e.args = (f"{e.args[0]} Constructed query: {lookups!r}",) + e.args[1:]
148
+ raise
149
+
150
+ if self.ordering:
151
+ queryset = queryset.order_by(*self.ordering)
152
+
153
+ if self.distinct:
154
+ queryset = queryset.distinct()
155
+
156
+ return queryset
157
+
158
+ def __repr__(self):
159
+ return (
160
+ "<CompiledQuery"
161
+ f" annotations={self.annotations!r},"
162
+ f" lookups={self.lookups!r},"
163
+ f" typed_lookups={self.typed_lookups!r}>"
164
+ )
165
+
166
+ def __eq__(self, other):
167
+ """For pytest comparisons."""
168
+ if isinstance(other, CompiledQuery):
169
+ return (
170
+ other.lookups == self.lookups
171
+ and other.typed_lookups == self.typed_lookups
172
+ and other.annotations == self.annotations
173
+ )
174
+ else:
175
+ return NotImplemented
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import re
2
3
  from datetime import datetime
3
4
  from decimal import Decimal as D
@@ -6,6 +7,7 @@ from django.utils.dateparse import parse_datetime
6
7
 
7
8
  from gisserver.exceptions import ExternalParsingError
8
9
 
10
+ logger = logging.getLogger(__name__)
9
11
  RE_FLOAT = re.compile(r"\A[0-9]+(\.[0-9]+)\Z")
10
12
 
11
13
 
@@ -38,3 +40,27 @@ def parse_bool(raw_value: str):
38
40
  return False
39
41
  else:
40
42
  raise ExternalParsingError(f"Can't cast '{raw_value}' to boolean")
43
+
44
+
45
+ def fix_type_name(type_name: str, feature_namespace: str):
46
+ """Fix the XML namespace for a typename value.
47
+
48
+ When the default namespace points to the "wfs" or "gml" namespaces,
49
+ parsing the QName of the type value will resolve that element as existing there.
50
+ This will correct such error, and restore the feature-type namespace.
51
+ """
52
+ if feature_namespace[0] == "{":
53
+ raise ValueError("Incorrect namespace argument")
54
+
55
+ alt_type_name = type_name
56
+ if type_name.startswith("{http://www.opengis.net/"):
57
+ # When the XML POST request used xmlns="http://www.opengis.net/wfs/2.0",
58
+ # this will define a default namespace that all QName values resolve to.
59
+ # As this is not detectable at the moment of parsing, correct it here.
60
+ alt_type_name = f"{{{feature_namespace}}}{type_name[type_name.index('}')+1:]}"
61
+ logger.debug("Corrected namespaced '%s' to '%s'", type_name, alt_type_name)
62
+ elif type_name[0] != "{":
63
+ # Typically happens in GET requests, or when no namespace prefix is used in XML POST
64
+ alt_type_name = f"{{{feature_namespace}}}{type_name}"
65
+ logger.debug("Corrected unnamespaced '%s' to namespaced '%s'", type_name, alt_type_name)
66
+ return alt_type_name
@@ -0,0 +1,37 @@
1
+ """WFS 2.0 element parsing.
2
+
3
+ The full spec can be found at: https://www.ogc.org/publications/standard/wfs/.
4
+ Secondly, using https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/wfs_xsd.html can be very
5
+ helpful to see which options each object type should support.
6
+ """
7
+
8
+ from .adhoc import AdhocQuery
9
+ from .base import QueryExpression
10
+ from .projection import PropertyName
11
+ from .requests import (
12
+ DescribeFeatureType,
13
+ DescribeStoredQueries,
14
+ GetCapabilities,
15
+ GetFeature,
16
+ GetPropertyValue,
17
+ ListStoredQueries,
18
+ ResultType,
19
+ )
20
+ from .stored import StoredQuery
21
+
22
+ __all__ = (
23
+ # Requests
24
+ "GetCapabilities",
25
+ "DescribeFeatureType",
26
+ "GetFeature",
27
+ "GetPropertyValue",
28
+ "ListStoredQueries",
29
+ "DescribeStoredQueries",
30
+ "ResultType",
31
+ # Queries
32
+ "QueryExpression",
33
+ "AdhocQuery",
34
+ "StoredQuery",
35
+ # Projection
36
+ "PropertyName",
37
+ )
@@ -0,0 +1,245 @@
1
+ """Handle adhoc-query objects.
2
+
3
+ The adhoc query is based on incoming request parameters,
4
+ such as the "FILTER", "BBOX" and "RESOURCEID" parameters.
5
+
6
+ These definitions follow the WFS spec.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from functools import cached_property
14
+
15
+ from django.db.models import Q
16
+
17
+ from gisserver.exceptions import (
18
+ InvalidParameterValue,
19
+ MissingParameterValue,
20
+ OperationNotSupported,
21
+ )
22
+ from gisserver.geometries import CRS
23
+ from gisserver.parsers import fes20
24
+ from gisserver.parsers.ast import tag_registry
25
+ from gisserver.parsers.ows import KVPRequest
26
+ from gisserver.parsers.query import CompiledQuery
27
+ from gisserver.parsers.xml import NSElement, xmlns
28
+ from gisserver.projection import FeatureProjection
29
+
30
+ from .base import QueryExpression
31
+ from .projection import PropertyName
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ ADHOC_QUERY_ELEMENTS = (PropertyName, fes20.Filter, fes20.SortBy)
36
+
37
+
38
+ @dataclass
39
+ @tag_registry.register("Query", xmlns.wfs)
40
+ class AdhocQuery(QueryExpression):
41
+ """The Ad hoc query expression parameters.
42
+
43
+ This parses and handles the syntax::
44
+
45
+ <wfs:Query typeNames="...">
46
+ <wfs:PropertyName>...</wfs:PropertyName>
47
+ <fes:Filter>...</fes:Filter>
48
+ <fes:SortBy>...</fes:SortBy>
49
+ </wfs:Query>
50
+
51
+ And supports the KVP syntax::
52
+
53
+ ?SERVICE=WFS&...&TYPENAMES=ns:myType&FILTER=...&SORTBY=...&SRSNAME=...&PROPERTYNAME=...
54
+ ?SERVICE=WFS&...&TYPENAMES=ns:myType&BBOX=...&SORTBY=...&SRSNAME=...&PROPERTYNAME=...
55
+ ?SERVICE=WFS&...&TYPENAMES=ns:myType&RESOURCEID=...
56
+
57
+ This represents all dynamic queries received as request (hence "adhoc"),
58
+ such as the "FILTER" and "BBOX" arguments from an HTTP GET.
59
+
60
+ The WFS Spec has 3 class levels for this:
61
+
62
+ - AdhocQueryExpression (types, projection, selection, sorting)
63
+ - Query (adds srsName, featureVersion)
64
+ - StoredQuery (adds storedQueryID)
65
+
66
+ For KVP requests, this class seems almost identifical to the provided parameters.
67
+ However, the KVP format allows to provide parameter lists,
68
+ to perform support multiple queries in a single request!
69
+
70
+ .. seealso::
71
+ https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/query_xsd.html#AbstractAdhocQueryExpressionType
72
+ """
73
+
74
+ # Tag attributes (fes:AbstractAdhocQueryExpression)
75
+ # WFS allows multiple names to construct JOIN queries.
76
+ # See https://docs.ogc.org/is/09-025r2/09-025r2.html#107
77
+ # and https://docs.ogc.org/is/09-025r2/09-025r2.html#190
78
+ typeNames: list[str] # typeNames in WFS/FES spec, multiple values means a JOIN is made.
79
+ aliases: list[str] | None = None # aliases for typeNames for joining the same table twice.
80
+ handle: str = "" # only for XML POST requests, is returned in ows:Exception
81
+ # Query tag attributes
82
+ srsName: CRS | None = None
83
+
84
+ # Projection clause (fes:AbstractProjectionClause)
85
+ property_names: list[PropertyName] | None = None
86
+
87
+ # Selection clause (fes:AbstractSelectionClause):
88
+ # - for XML POST this is encoded in a <fes:Query>
89
+ # - for HTTP GET, this is encoded as FILTER, FILTER_LANGUAGE, RESOURCEID, BBOX.
90
+ filter: fes20.Filter | None = None
91
+
92
+ # Sorting Clause (fes:AbstractSortingClause)
93
+ sortBy: fes20.SortBy | None = None
94
+
95
+ def __post_init__(self):
96
+ if len(self.typeNames) > 1:
97
+ raise OperationNotSupported("Join queries are not supported", locator="typeNames")
98
+ if self.aliases:
99
+ raise OperationNotSupported("Join queries are not supported", locator="aliases")
100
+
101
+ @classmethod
102
+ def from_xml(cls, element: NSElement) -> AdhocQuery:
103
+ """Parse the XML element of the Query tag."""
104
+ type_names = [
105
+ element.parse_qname(qname)
106
+ for qname in element.get_str_attribute("typeNames").split(" ")
107
+ ]
108
+ aliases = element.attrib.get("aliases", None)
109
+ srsName = element.attrib.get("srsName", None)
110
+ property_names = []
111
+ filter = None
112
+ sortBy = None
113
+
114
+ for child in element:
115
+ # The FES XSD dictates the element ordering, but this is ignored here.
116
+ node = tag_registry.node_from_xml(child, allowed_types=ADHOC_QUERY_ELEMENTS)
117
+ if isinstance(node, PropertyName):
118
+ property_names.append(node)
119
+ elif isinstance(node, fes20.Filter):
120
+ filter = node
121
+ elif isinstance(node, fes20.SortBy):
122
+ sortBy = node
123
+ else:
124
+ raise NotImplementedError(
125
+ f"Parsing {node.__class__} not handled in AdhocQuery.from_xml()"
126
+ )
127
+
128
+ return AdhocQuery(
129
+ typeNames=type_names,
130
+ aliases=aliases.split(" ") if aliases is not None else None,
131
+ handle=element.attrib.get("handle", ""),
132
+ property_names=property_names or None,
133
+ filter=filter,
134
+ sortBy=sortBy,
135
+ srsName=CRS.from_string(srsName) if srsName else None,
136
+ )
137
+
138
+ @classmethod
139
+ def from_kvp_request(cls, kvp: KVPRequest):
140
+ """Build this object from an HTTP GET (key-value-pair) request.
141
+
142
+ Note the caller should have split the KVP request parameter lists.
143
+ This class only handles a single parameter pair.
144
+ """
145
+ # Parse attributes
146
+ typeNames = [
147
+ kvp.parse_qname(qname)
148
+ for qname in kvp.get_list("typeNames", alias="TYPENAME", default=[])
149
+ ]
150
+ aliases = kvp.get_list("aliases", default=None)
151
+ srsName = kvp.get_custom("srsName", default=None, parser=CRS.from_string)
152
+
153
+ # KVP requests may omit the typenames if RESOURCEID=... is given.
154
+ if not typeNames and "RESOURCEID" not in kvp:
155
+ raise MissingParameterValue("Empty TYPENAMES parameter", locator="typeNames")
156
+
157
+ # Parse elements
158
+ filter = fes20.Filter.from_kvp_request(kvp)
159
+ sort_by = fes20.SortBy.from_kvp_request(kvp)
160
+
161
+ # Parse projection
162
+ property_names = None
163
+ if "PROPERTYNAME" in kvp:
164
+ names = kvp.get_list("propertyName", default=[])
165
+ # Check for WFS 1.x syntax of ?PROPERTYNAME=*
166
+ if names != ["*"]:
167
+ property_names = [
168
+ PropertyName(xpath=name, xpath_ns_aliases=kvp.ns_aliases) for name in names
169
+ ]
170
+
171
+ return AdhocQuery(
172
+ # Attributes
173
+ typeNames=typeNames,
174
+ aliases=aliases,
175
+ srsName=srsName,
176
+ # Elements
177
+ property_names=property_names,
178
+ filter=filter,
179
+ sortBy=sort_by,
180
+ )
181
+
182
+ @cached_property
183
+ def query_locator(self):
184
+ """Overrides the 'query_locator' attribute, so the 'locator' argument is correctly set."""
185
+ if self.filter is not None and self.filter.get_resource_id_types():
186
+ return "resourceId"
187
+ else:
188
+ return "filter"
189
+
190
+ def get_type_names(self) -> list[str]:
191
+ """Tell which type names this query uses."""
192
+ if not self.typeNames and self.filter is not None:
193
+ # Also make the behavior consistent, always supply the type name.
194
+ return self.filter.get_resource_id_types() or []
195
+ else:
196
+ return self.typeNames
197
+
198
+ def get_projection(self) -> FeatureProjection:
199
+ """Tell how the <wfs:Query> element should be displayed."""
200
+ return FeatureProjection(
201
+ self.feature_types,
202
+ self.property_names,
203
+ self.value_reference,
204
+ output_crs=self.srsName,
205
+ )
206
+
207
+ def bind(self, *args, **kwargs):
208
+ """Make sure the 'locator' points to the actual object that defined the type."""
209
+ try:
210
+ super().bind(*args, **kwargs)
211
+ except InvalidParameterValue as e:
212
+ if not self.typeNames:
213
+ e.locator = "resourceId"
214
+ raise
215
+
216
+ # Validate the srsName too
217
+ if self.srsName is not None:
218
+ self.srsName = self.feature_types[0].resolve_crs(self.srsName, locator="srsName")
219
+
220
+ def build_query(self, compiler: CompiledQuery) -> Q | None:
221
+ """Apply our collected filter data to the compiler."""
222
+ # Add the
223
+ if self.sortBy is not None:
224
+ self.sortBy.build_ordering(compiler)
225
+
226
+ if self.filter is not None:
227
+ # Generate the internal query object from the <fes:Filter>,
228
+ # this can return a Q object.
229
+ return self.filter.build_query(compiler)
230
+ else:
231
+ return None
232
+
233
+ def as_kvp(self):
234
+ """Translate the POST request into KVP GET parameters. This is needed for pagination."""
235
+ params = super().as_kvp()
236
+ params["TYPENAMES"] = ",".join(self.typeNames)
237
+ if self.srsName is not None:
238
+ params["SRSNAME"] = str(self.srsName)
239
+
240
+ if self.filter is not None:
241
+ raise NotImplementedError() # not going to parse that, nor does mapserver
242
+ if self.sortBy is not None:
243
+ params["SORTBY"] = self.sortBy.as_kvp()
244
+
245
+ return params