django-gisserver 1.4.0__py3-none-any.whl → 1.5.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 (38) hide show
  1. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/METADATA +15 -13
  2. django_gisserver-1.5.0.dist-info/RECORD +54 -0
  3. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/db.py +14 -20
  6. gisserver/exceptions.py +23 -9
  7. gisserver/features.py +64 -100
  8. gisserver/geometries.py +2 -2
  9. gisserver/operations/base.py +31 -21
  10. gisserver/operations/wfs20.py +44 -38
  11. gisserver/output/__init__.py +2 -1
  12. gisserver/output/base.py +43 -27
  13. gisserver/output/csv.py +38 -33
  14. gisserver/output/geojson.py +43 -51
  15. gisserver/output/gml32.py +88 -67
  16. gisserver/output/results.py +23 -8
  17. gisserver/output/utils.py +18 -2
  18. gisserver/output/xmlschema.py +1 -1
  19. gisserver/parsers/base.py +2 -2
  20. gisserver/parsers/fes20/__init__.py +18 -0
  21. gisserver/parsers/fes20/expressions.py +7 -12
  22. gisserver/parsers/fes20/functions.py +1 -1
  23. gisserver/parsers/fes20/operators.py +7 -3
  24. gisserver/parsers/fes20/query.py +11 -1
  25. gisserver/parsers/fes20/sorting.py +3 -1
  26. gisserver/parsers/gml/base.py +1 -1
  27. gisserver/queries/__init__.py +3 -0
  28. gisserver/queries/adhoc.py +16 -12
  29. gisserver/queries/base.py +76 -36
  30. gisserver/queries/projection.py +240 -0
  31. gisserver/queries/stored.py +7 -6
  32. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +2 -2
  33. gisserver/types.py +78 -24
  34. gisserver/views.py +9 -20
  35. django_gisserver-1.4.0.dist-info/RECORD +0 -54
  36. gisserver/output/gml32_lxml.py +0 -612
  37. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/LICENSE +0 -0
  38. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/top_level.txt +0 -0
@@ -92,7 +92,11 @@ class DistanceOperatorName(TagNameEnum):
92
92
 
93
93
 
94
94
  class SpatialOperatorName(TagNameEnum):
95
- """XML tag names for geometry operators"""
95
+ """XML tag names for geometry operators.
96
+
97
+ The values correspond with GeoDjango operators. So a ``BBOX`` query
98
+ will translate into ``geometry__intersects=Polygon(...)``.
99
+ """
96
100
 
97
101
  # (A Within B) implies that (B Contains A)
98
102
 
@@ -263,9 +267,9 @@ class NonIdOperator(Operator):
263
267
  # e.g. deny <PropertyIsLessThanOrEqualTo> against <gml:boundedBy>
264
268
  if xsd_element.is_geometry and not self.allow_geometries:
265
269
  raise OperationProcessingFailed(
266
- "filter",
267
270
  f"Operator '{tag}' does not support comparing"
268
271
  f" geometry properties: '{xsd_element.xml_name}'.",
272
+ locator="filter",
269
273
  status_code=400, # not HTTP 500 here. Spec allows both.
270
274
  )
271
275
 
@@ -616,7 +620,7 @@ class LogicalOperator(NonIdOperator):
616
620
  @tag_registry.register("And")
617
621
  @tag_registry.register("Or")
618
622
  class BinaryLogicOperator(LogicalOperator):
619
- """Apply an AND or OR operator"""
623
+ """Apply an 'AND' or 'OR' operator"""
620
624
 
621
625
  operands: list[NonIdOperator]
622
626
  operatorType: BinaryLogicType
@@ -84,7 +84,9 @@ class CompiledQuery:
84
84
  """Read the desired result ordering from a ``<fes:SortBy>`` element."""
85
85
  self.ordering += sort_by.build_ordering(self.feature_type)
86
86
 
87
- def add_value_reference(self, value_reference: expressions.ValueReference) -> str:
87
+ def add_value_reference(
88
+ self, value_reference: expressions.ValueReference
89
+ ) -> expressions.RhsTypes:
88
90
  """Add a reference that should be returned by the query.
89
91
 
90
92
  This includes the XPath expression to the query, in case that adds
@@ -92,8 +94,16 @@ class CompiledQuery:
92
94
  ``queryset.values()`` result. This is needed to support cases like
93
95
  these in the future: ``addresses/Address[street="Oxfordstrasse"]/number``
94
96
  """
97
+ # The actual limiting of fields happens inside the decorate_queryset() of the renderer.
95
98
  return value_reference.build_rhs(self)
96
99
 
100
+ def add_property_name(self, property_name: expressions.ValueReference) -> expressions.RhsTypes:
101
+ """Define which field should be returned by the query."""
102
+ # Make sure any xpath [attr=value] lookups work.
103
+ # This will also validate the name because it resolves the ORM path.
104
+ # The actual limiting of fields happens inside the decorate_queryset() of the renderer.
105
+ return property_name.build_rhs(self)
106
+
97
107
  def apply_extra_lookups(self, comparison: Q) -> Q:
98
108
  """Combine stashed lookups with the provided Q object.
99
109
 
@@ -20,7 +20,9 @@ class SortOrder(Enum):
20
20
  try:
21
21
  return cls[direction]
22
22
  except KeyError:
23
- raise InvalidParameterValue("sortby", "Expect ASC/DESC ordering direction") from None
23
+ raise InvalidParameterValue(
24
+ "Expect ASC/DESC ordering direction", locator="sortby"
25
+ ) from None
24
26
 
25
27
 
26
28
  @dataclass
@@ -14,7 +14,7 @@ class AbstractGeometry(BaseNode):
14
14
  """
15
15
 
16
16
  def build_rhs(self, compiler):
17
- # Allow the value to be used in an binary operator
17
+ # Allow the value to be used in a binary operator
18
18
  raise NotImplementedError()
19
19
 
20
20
 
@@ -13,6 +13,7 @@ The "GetFeatureById" is a mandatory built-in stored query.
13
13
 
14
14
  from .adhoc import AdhocQuery
15
15
  from .base import QueryExpression
16
+ from .projection import FeatureProjection, FeatureRelation
16
17
  from .stored import (
17
18
  GetFeatureById,
18
19
  QueryExpressionText,
@@ -31,4 +32,6 @@ __all__ = (
31
32
  "stored_query_registry",
32
33
  "StoredQueryParameter",
33
34
  "GetFeatureById",
35
+ "FeatureProjection",
36
+ "FeatureRelation",
34
37
  )
@@ -30,7 +30,7 @@ class AdhocQuery(QueryExpression):
30
30
  """The Ad hoc query expression parameters.
31
31
 
32
32
  This represents all dynamic queries received as request (hence "adhoc"),
33
- such as the "FILTER" and "BBOX" arguments from a HTTP GET.
33
+ such as the "FILTER" and "BBOX" arguments from an HTTP GET.
34
34
 
35
35
  The WFS Spec has 3 class levels for this:
36
36
  - AdhocQueryExpression (types, projection, selection, sorting)
@@ -39,23 +39,26 @@ class AdhocQuery(QueryExpression):
39
39
  For KVP requests, this dataclass is almost identical to **params.
40
40
  However, it allows combining the filter parameters. These become
41
41
  one single XML request for HTTP POST requests later.
42
+
43
+ .. seealso::
44
+ https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/query_xsd.html#AbstractAdhocQueryExpressionType
42
45
  """
43
46
 
44
47
  typeNames: list[FeatureType] # typeNames in WFS/FES spec
45
48
  # aliases: Optional[List[str]] = None
46
49
  handle: str = "" # only for XML POST requests
47
50
 
48
- # Projection clause:
49
- # propertyName
51
+ # Projection clause (fes:AbstractProjectionClause)
52
+ property_names: list[fes20.ValueReference] | None = None
50
53
 
51
- # Selection clause:
54
+ # Selection clause (fes:AbstractSelectionClause):
52
55
  # - for XML POST this is encoded in a <fes:Query>
53
56
  # - for HTTP GET, this is encoded as FILTER, FILTER_LANGUAGE, RESOURCEID, BBOX.
54
57
  filter: fes20.Filter | None = None
55
58
  filter_language: str = fes20.Filter.query_language
56
59
  bbox: BoundingBox | None = None
57
60
 
58
- # Sorting Clause
61
+ # Sorting Clause (fes:AbstractSortingClause)
59
62
  sortBy: fes20.SortBy | None = None
60
63
 
61
64
  # Officially part of the GetFeature/GetPropertyValue request object,
@@ -69,24 +72,24 @@ class AdhocQuery(QueryExpression):
69
72
 
70
73
  @classmethod
71
74
  def from_kvp_request(cls, **params):
72
- """Build this object from a HTTP GET (key-value-pair) request."""
75
+ """Build this object from an HTTP GET (key-value-pair) request."""
73
76
  # Validate optionally required parameters
74
77
  if not params["typeNames"] and not params["resourceID"]:
75
- raise MissingParameterValue("typeNames", "Empty TYPENAMES parameter")
78
+ raise MissingParameterValue("Empty TYPENAMES parameter", locator="typeNames")
76
79
 
77
80
  # Validate mutually exclusive parameters
78
81
  if params["filter"] and (params["bbox"] or params["resourceID"]):
79
82
  raise InvalidParameterValue(
80
- "filter",
81
83
  "The FILTER parameter is mutually exclusive with BBOX and RESOURCEID",
84
+ locator="filter",
82
85
  )
83
86
 
84
87
  # Validate mutually exclusive parameters
85
88
  if params["resourceID"]:
86
89
  if params["bbox"] or params["filter"]:
87
90
  raise InvalidParameterValue(
88
- "resourceID",
89
91
  "The RESOURCEID parameter is mutually exclusive with BBOX and FILTER",
92
+ locator="resourceID",
90
93
  )
91
94
 
92
95
  # When ResourceId + typenames is defined, it should be a value from typenames
@@ -99,13 +102,14 @@ class AdhocQuery(QueryExpression):
99
102
  kvp_type_names = {feature_type.name for feature_type in params["typeNames"]}
100
103
  if not kvp_type_names.issuperset(id_type_names):
101
104
  raise InvalidParameterValue(
102
- "resourceID",
103
105
  "When TYPENAMES and RESOURCEID are combined, "
104
106
  "the RESOURCEID type should be included in TYPENAMES.",
107
+ locator="resourceID",
105
108
  )
106
109
 
107
110
  return AdhocQuery(
108
111
  typeNames=params["typeNames"],
112
+ property_names=params["propertyName"],
109
113
  filter=params["filter"],
110
114
  filter_language=params["filter_language"],
111
115
  bbox=params["bbox"],
@@ -115,7 +119,7 @@ class AdhocQuery(QueryExpression):
115
119
  )
116
120
 
117
121
  def bind(self, *args, **kwargs):
118
- """Inform this quey object of the available feature types"""
122
+ """Inform this query object of the available feature types"""
119
123
  super().bind(*args, **kwargs)
120
124
 
121
125
  if self.resourceId:
@@ -144,7 +148,7 @@ class AdhocQuery(QueryExpression):
144
148
  def _compile_non_filter_query(self, feature_type: FeatureType, using=None):
145
149
  """Generate the query based on the remaining parameters.
146
150
 
147
- This is slightly more efficient then generating the fes Filter object
151
+ This is slightly more efficient than generating the fes Filter object
148
152
  from these KVP parameters (which could also be done within the request method).
149
153
  """
150
154
  compiler = fes20.CompiledQuery(feature_type=feature_type, using=using)
gisserver/queries/base.py CHANGED
@@ -8,10 +8,17 @@ from gisserver.output import FeatureCollection, SimpleFeatureCollection
8
8
  from gisserver.parsers import fes20
9
9
  from gisserver.types import split_xml_name
10
10
 
11
+ from .projection import FeatureProjection
12
+
11
13
 
12
14
  class QueryExpression:
13
15
  """WFS base class for all queries.
14
- This object type is defined in the WFS spec (as <fes:AbstractQueryExpression>).
16
+ This object type is defined in the WFS spec (as ``<fes:AbstractQueryExpression>``).
17
+
18
+ The WFS server can initiate queries in multiple ways.
19
+ This class provides the common interface for all these query types;
20
+ whether the request provided "ad-hoc" parameters or called a stored procedure.
21
+ Each query type has its own way of generating the actual database statement to perform.
15
22
 
16
23
  The subclasses can override the following logic:
17
24
 
@@ -21,21 +28,33 @@ class QueryExpression:
21
28
  For full control, these methods can also be overwritten instead:
22
29
 
23
30
  * :meth:`get_queryset` defines the full results.
24
- * :meth:`get_hits` to return the collection for RESULTTYPE=hits.
25
- * :meth:`get_results` to return the collection for RESULTTYPE=results
31
+ * :meth:`get_hits` to return the collection for ``RESULTTYPE=hits``.
32
+ * :meth:`get_results` to return the collection for ``RESULTTYPE=results``.
26
33
  """
27
34
 
28
35
  handle = ""
29
- value_reference = None
36
+ value_reference: fes20.ValueReference | None = None
37
+ property_names: list[fes20.ValueReference] | None = None
38
+ projections: dict[FeatureType, FeatureProjection] | None = None
30
39
 
31
40
  def bind(
32
41
  self,
33
42
  all_feature_types: dict[str, FeatureType],
34
- value_reference: fes20.ValueReference | None,
43
+ value_reference: fes20.ValueReference | None = None,
44
+ property_names: list[fes20.ValueReference] | None = None,
35
45
  ):
36
- """Bind the query to presentation-layer logic"""
46
+ """Bind the query to presentation-layer logic (e.g. request parameters).
47
+
48
+ :param all_feature_types: Which features are queried.
49
+ :param value_reference: Which field is returned (by ``GetPropertyValue``)
50
+ :param property_name: Which field is returned (by ``GetFeature`` + propertyName parameter)
51
+ """
52
+ self.projections = {}
37
53
  self.all_feature_types = all_feature_types
38
- self.value_reference = value_reference
54
+ if value_reference is not None:
55
+ self.value_reference = value_reference
56
+ if property_names is not None:
57
+ self.property_names = property_names
39
58
 
40
59
  def check_permissions(self, request):
41
60
  """Verify whether the user has access to view these data sources"""
@@ -44,7 +63,7 @@ class QueryExpression:
44
63
 
45
64
  def resolve_type_name(self, type_name, locator="typename") -> FeatureType:
46
65
  """Find the feature type for a given name.
47
- This is an utility that cusstom subclasses can use.
66
+ This is a utility that custom subclasses can use.
48
67
  """
49
68
  # Strip the namespace prefix. The Python ElementTree parser does
50
69
  # not expose the used namespace prefixes, so text-values can't be
@@ -64,16 +83,21 @@ class QueryExpression:
64
83
  Override this method in case you need full control over the response data.
65
84
  Otherwise, override :meth:`compile_query` or :meth:`get_queryset`.
66
85
  """
67
- querysets = self.get_querysets()
68
- return FeatureCollection(
69
- results=[
70
- # Include empty feature collections,
71
- # so the selected feature types are still known.
72
- SimpleFeatureCollection(feature_type=ft, queryset=qs.none(), start=0, stop=0)
73
- for ft, qs in querysets
74
- ],
75
- number_matched=sum(qs.count() for ft, qs in querysets),
76
- )
86
+ results = []
87
+ number_matched = 0
88
+ for feature_type in self.get_type_names():
89
+ queryset = self.get_queryset(feature_type)
90
+ number_matched += queryset.count()
91
+
92
+ # Include empty feature collections,
93
+ # so the selected feature types are still known.
94
+ results.append(
95
+ SimpleFeatureCollection(
96
+ self, feature_type, queryset=queryset.none(), start=0, stop=0
97
+ )
98
+ )
99
+
100
+ return FeatureCollection(results=results, number_matched=number_matched)
77
101
 
78
102
  def get_results(self, start_index=0, count=100) -> FeatureCollection:
79
103
  """Run the query, return the full paginated results.
@@ -82,24 +106,22 @@ class QueryExpression:
82
106
  Otherwise, override :meth:`compile_query` or :meth:`get_queryset`.
83
107
  """
84
108
  stop = start_index + count
85
-
86
- # The querysets are not executed yet, until the output is reading them.
87
- querysets = self.get_querysets()
88
- return FeatureCollection(
89
- results=[
90
- SimpleFeatureCollection(feature_type, qs, start=start_index, stop=stop)
91
- for feature_type, qs in querysets
92
- ]
93
- )
94
-
95
- def get_querysets(self) -> list[tuple[FeatureType, QuerySet]]:
96
- """Construct the querysets that return the database results."""
97
- results = []
98
- for feature_type in self.get_type_names():
99
- queryset = self.get_queryset(feature_type)
100
- results.append((feature_type, queryset))
101
-
102
- return results
109
+ results = [
110
+ # The querysets are not executed yet, until the output is reading them.
111
+ SimpleFeatureCollection(
112
+ self,
113
+ feature_type,
114
+ queryset=self.get_queryset(feature_type),
115
+ start=start_index,
116
+ stop=stop,
117
+ )
118
+ for feature_type in self.get_type_names()
119
+ ]
120
+
121
+ # number_matched is not given here, so some rendering formats can count it instead.
122
+ # For GML it need to be printed at the start, but for GeoJSON it can be rendered
123
+ # as the last bit of the response. That avoids performing an expensive COUNT query.
124
+ return FeatureCollection(results=results)
103
125
 
104
126
  def get_queryset(self, feature_type: FeatureType) -> QuerySet:
105
127
  """Generate the queryset for the specific feature type.
@@ -112,6 +134,11 @@ class QueryExpression:
112
134
  # Apply filters
113
135
  compiler = self.compile_query(feature_type, using=queryset.db)
114
136
 
137
+ # If defined, limit which fields will be queried.
138
+ if self.property_names:
139
+ for property_name in self.property_names:
140
+ compiler.add_property_name(property_name)
141
+
115
142
  if self.value_reference is not None:
116
143
  if feature_type.resolve_element(self.value_reference.xpath) is None:
117
144
  raise InvalidParameterValue(
@@ -137,6 +164,19 @@ class QueryExpression:
137
164
  f"{self.__class__.__name__}.get_type_names() should be implemented."
138
165
  )
139
166
 
167
+ def get_projection(self, feature_type: FeatureType) -> FeatureProjection:
168
+ """Provide the projection of this query for a given feature.
169
+
170
+ NOTE: as the AdhocQuery has a typeNames (plural!) argument,
171
+ this class still needs to check per feature type which fields to apply to.
172
+ """
173
+ try:
174
+ return self.projections[feature_type]
175
+ except KeyError:
176
+ projection = FeatureProjection(feature_type, self.property_names)
177
+ self.projections[feature_type] = projection
178
+ return projection
179
+
140
180
  def compile_query(self, feature_type: FeatureType, using=None) -> fes20.CompiledQuery:
141
181
  """Define the compiled query that filters the queryset.
142
182
 
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from functools import cached_property
6
+ from typing import cast
7
+
8
+ from django.db import models
9
+
10
+ from gisserver.features import FeatureType
11
+ from gisserver.parsers import fes20
12
+ from gisserver.types import GmlElement, XPathMatch, XsdElement, _XsdElement_WithComplexType
13
+
14
+
15
+ class FeatureProjection:
16
+ """Tell which fields to access and render for a single feature.
17
+ This is inspired on 'fes:AbstractProjectionClause'.
18
+
19
+ Instead of walking over the full XSD object tree,
20
+ this object wraps that and makes sure only the actual requested fields are used.
21
+ When a PROPERTYNAME is used in the request, this will limit
22
+ which fields to retrieve, which to prefetch, and which to render.
23
+ """
24
+
25
+ feature_type: FeatureType
26
+
27
+ def __init__(self, feature_type: FeatureType, property_names: list[fes20.ValueReference]):
28
+ self.feature_type = feature_type
29
+ self.property_names = property_names
30
+
31
+ if property_names:
32
+ # Discover which elements should be rendered.
33
+ child_nodes = self._get_child_nodes_subset()
34
+ self.xsd_root_elements = child_nodes.pop(None) # Pop it to avoid recursion risks
35
+ self.xsd_child_nodes = child_nodes
36
+ else:
37
+ # Retrieve all elements.
38
+ self.xsd_root_elements = feature_type.xsd_type.all_elements
39
+ self.xsd_child_nodes = feature_type.xsd_type.elements_with_children
40
+
41
+ def _get_child_nodes_subset(self) -> dict[XsdElement | None, list[XsdElement]]:
42
+ """Translate the PROPERTYNAME into a dictionary of nodes to render."""
43
+ child_nodes = defaultdict(list)
44
+ for xpath_match in self.xpath_matches:
45
+ # Make sure the tree is known beforehand, and it has no duplicate parent nodes.
46
+ # (e.g. PROPERTYNAME=node/child1,node/child2)
47
+ parent = None
48
+ for xsd_node in xpath_match.nodes:
49
+ if xsd_node not in child_nodes[parent]:
50
+ child_nodes[parent].append(xsd_node)
51
+ parent = xsd_node
52
+
53
+ return dict(child_nodes)
54
+
55
+ def add_field(self, xsd_element: XsdElement):
56
+ """Restore the retrieval of a field that was not asked for in the original query."""
57
+ if not self.property_names:
58
+ return
59
+ if xsd_element.type.is_complex_type:
60
+ raise NotImplementedError("Can't restore nested elements right now")
61
+
62
+ # For now, add to all cached properties:
63
+ # TODO: have a better approach?
64
+ self.xsd_root_elements.append(xsd_element)
65
+ if xsd_element.is_geometry:
66
+ self.geometry_elements.append(xsd_element)
67
+
68
+ if xsd_element.orm_path not in self.only_fields:
69
+ self.only_fields.append(xsd_element.orm_path)
70
+
71
+ if xsd_element.is_flattened:
72
+ self.flattened_elements.append(xsd_element)
73
+
74
+ @cached_property
75
+ def xpath_matches(self) -> list[XPathMatch]:
76
+ """Resolve which elements the property names point to"""
77
+ if not self.property_names:
78
+ raise RuntimeError("This method is only useful for propertyname projections.")
79
+
80
+ return [
81
+ self.feature_type.resolve_element(property_name.xpath)
82
+ for property_name in self.property_names
83
+ ]
84
+
85
+ @cached_property
86
+ def geometry_elements(self) -> list[GmlElement]:
87
+ """Tell which GML elements will be hit."""
88
+ gml_elements = []
89
+ for e in self.xsd_root_elements:
90
+ if e.is_geometry and e.xml_name != "gml:boundedBy":
91
+ # Prefetching a flattened relation
92
+ gml_elements.append(e)
93
+
94
+ for xsd_children in self.xsd_child_nodes.values():
95
+ for e in xsd_children:
96
+ if e.is_geometry and e.xml_name != "gml:boundedBy":
97
+ gml_elements.append(e)
98
+
99
+ return gml_elements
100
+
101
+ @cached_property
102
+ def complex_elements(self) -> list[_XsdElement_WithComplexType]:
103
+ """Shortcut to get all elements with a complex type"""
104
+ if not self.property_names:
105
+ return self.feature_type.xsd_type.complex_elements
106
+ else:
107
+ return [e for e in self.xsd_root_elements if e.type.is_complex_type]
108
+
109
+ @cached_property
110
+ def flattened_elements(self) -> list[XsdElement]:
111
+ """Shortcut to get all elements with a flattened model attribute"""
112
+ if not self.property_names:
113
+ return self.feature_type.xsd_type.flattened_elements
114
+ else:
115
+ return [e for e in self.xsd_root_elements if e.is_flattened]
116
+
117
+ @cached_property
118
+ def main_geometry_element(self) -> GmlElement | None:
119
+ """Return the field used to describe the geometry of the feature.
120
+ When the projection excludes the geometry, ``None`` is returned.
121
+ """
122
+ main_field = self.feature_type.geometry_field
123
+ if self.property_names and main_field not in self.xsd_root_elements:
124
+ return None
125
+
126
+ xpath_match = self.feature_type.resolve_element(main_field)
127
+ return cast(GmlElement, xpath_match.child)
128
+
129
+ @cached_property
130
+ def orm_relations(self) -> list[FeatureRelation]:
131
+ """Tell which fields will be retrieved from related fields.
132
+
133
+ This gives an object layout based on the XSD elements,
134
+ that can be used for prefetching data.
135
+ """
136
+ related_models: dict[str, type[models.Model]] = {}
137
+ fields: dict[str, set[XsdElement]] = defaultdict(set)
138
+ elements = defaultdict(list)
139
+
140
+ # Check all elements that render as "dotted" flattened relation
141
+ for xsd_element in self.flattened_elements:
142
+ if xsd_element.source is not None:
143
+ # Split "relation.field" notation into path, and take the field as child attribute.
144
+ obj_path, field = xsd_element.orm_relation
145
+ elements[obj_path].append(xsd_element)
146
+ fields[obj_path].add(xsd_element)
147
+ # field is already on relation:
148
+ related_models[obj_path] = xsd_element.source.model
149
+
150
+ # Check all elements that render as "nested" complex type:
151
+ for xsd_element in self.complex_elements:
152
+ # The complex element itself points to the root of the path,
153
+ # all sub elements become the child attributes.
154
+ obj_path = xsd_element.orm_path
155
+ elements[obj_path].append(xsd_element)
156
+ fields[obj_path] = {
157
+ f
158
+ for f in self.xsd_child_nodes[xsd_element]
159
+ if not f.is_many or f.is_array # exclude M2M, but include ArrayField
160
+ }
161
+ if xsd_element.source:
162
+ # field references a related object:
163
+ related_models[obj_path] = xsd_element.source.related_model
164
+
165
+ return [
166
+ FeatureRelation(
167
+ orm_path=obj_path,
168
+ sub_fields=sub_fields,
169
+ related_model=related_models.get(obj_path),
170
+ xsd_elements=elements[obj_path],
171
+ )
172
+ for obj_path, sub_fields in fields.items()
173
+ ]
174
+
175
+ @cached_property
176
+ def only_fields(self) -> list[str]:
177
+ """Tell which fields to limit the queryset to.
178
+ This excludes M2M fields because those are not part of the local model data.
179
+ """
180
+ if self.property_names is not None:
181
+ return [
182
+ # TODO: While ORM rel__child paths can be passed to .only(),
183
+ # these may not be accurately applied to foreign keys yet.
184
+ # Also, this is bypassed by our generated Prefetch() objects.
185
+ xpath_match.orm_path
186
+ for xpath_match in self.xpath_matches
187
+ if not xpath_match.is_many or xpath_match.child.is_array
188
+ ]
189
+ else:
190
+ # Also limit the queryset to the actual fields that are shown.
191
+ # No need to request more data
192
+ return [
193
+ f.orm_field
194
+ for f in self.feature_type.xsd_type.elements
195
+ if not f.is_many or f.is_array # avoid M2M fields for .only(), but keep ArrayField
196
+ ]
197
+
198
+
199
+ @dataclass
200
+ class FeatureRelation:
201
+ """Tell which related fields are queried by the feature.
202
+ Each dict holds an ORM-path, with the relevant sub-elements.
203
+ """
204
+
205
+ #: The ORM path that is queried for this particular relation
206
+ orm_path: str
207
+ #: The fields that will be retrieved for that path (limited by the projection)
208
+ sub_fields: set[XsdElement]
209
+ #: The model that is accessed for this relation (if set)
210
+ related_model: type[models.Model] | None
211
+ #: The source elements that access this relation.
212
+ xsd_elements: list[XsdElement]
213
+
214
+ @cached_property
215
+ def _local_model_field_names(self) -> list[str]:
216
+ """Tell which local fields of the model will be accessed by this feature."""
217
+ result = []
218
+ for field in self.sub_fields:
219
+ model_field = field.source
220
+ if not model_field.many_to_many and not model_field.one_to_many:
221
+ result.append(model_field.name)
222
+
223
+ result.extend(self._local_backlink_field_names)
224
+ return result
225
+
226
+ @property
227
+ def _local_backlink_field_names(self) -> list[str]:
228
+ # When this relation is retrieved through a ManyToOneRel (reverse FK),
229
+ # the prefetch_related() also needs to have the original foreign key
230
+ # in order to link all prefetches to the proper parent instance.
231
+ return [
232
+ xsd_element.source.field.name
233
+ for xsd_element in self.xsd_elements
234
+ if xsd_element.source is not None and xsd_element.source.one_to_many
235
+ ]
236
+
237
+ @property
238
+ def geometry_elements(self) -> list[GmlElement]:
239
+ """Tell which geometry elements this relation will access."""
240
+ return [f for f in self.sub_fields if f.is_geometry and f.name != "gml:boundedBy"]
@@ -80,14 +80,14 @@ class StoredQuery(QueryExpression):
80
80
  args[name] = KVP[name]
81
81
  except KeyError:
82
82
  raise MissingParameterValue(
83
- name, f"Stored query {cls.meta.id} requires an '{name}' parameter"
83
+ f"Stored query {cls.meta.id} requires an '{name}' parameter", locator=name
84
84
  ) from None
85
85
 
86
86
  # Avoid unexpected behavior, check whether the client also sends adhoc query parameters
87
87
  for name in ("filter", "bbox", "resourceID"):
88
88
  if name not in args and KVP.get(name.upper()):
89
89
  raise InvalidParameterValue(
90
- name, "Stored query can't be combined with adhoc-query parameters"
90
+ "Stored query can't be combined with adhoc-query parameters", locator=name
91
91
  )
92
92
 
93
93
  return args
@@ -121,7 +121,8 @@ class StoredQueryRegistry:
121
121
  return self.stored_queries[query_id]
122
122
  except KeyError:
123
123
  raise InvalidParameterValue(
124
- "STOREDQUERY_ID", f"Stored query does not exist: {query_id}"
124
+ f"Stored query does not exist: {query_id}",
125
+ locator="STOREDQUERY_ID",
125
126
  ) from None
126
127
 
127
128
 
@@ -169,7 +170,7 @@ class GetFeatureById(StoredQuery):
169
170
  type_name, id = ID.rsplit(".", 1)
170
171
  except ValueError:
171
172
  # Always report this as 404
172
- raise NotFound("ID", "Expected typeName.id for ID parameter") from None
173
+ raise NotFound("Expected typeName.id for ID parameter", locator="ID") from None
173
174
 
174
175
  self.type_name = type_name
175
176
  self.id = id
@@ -184,7 +185,7 @@ class GetFeatureById(StoredQuery):
184
185
  try:
185
186
  return super().get_queryset(feature_type)
186
187
  except (ValueError, TypeError) as e:
187
- raise InvalidParameterValue("ID", f"Invalid ID value: {e}") from e
188
+ raise InvalidParameterValue(f"Invalid ID value: {e}", locator="ID") from e
188
189
 
189
190
  def get_results(self, *args, **kwargs) -> FeatureCollection:
190
191
  """Override to implement 404 checking."""
@@ -194,7 +195,7 @@ class GetFeatureById(StoredQuery):
194
195
  # Avoid having to do that in the output renderer.
195
196
  if collection.results[0].first() is None:
196
197
  # WFS 2.0.2: Return NotFound instead of InvalidParameterValue
197
- raise NotFound("ID", f"Feature not found with ID {self.id}.")
198
+ raise NotFound(f"Feature not found with ID {self.id}.", locator="ID")
198
199
 
199
200
  return collection
200
201