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.
- {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/METADATA +15 -13
- django_gisserver-1.5.0.dist-info/RECORD +54 -0
- {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/db.py +14 -20
- gisserver/exceptions.py +23 -9
- gisserver/features.py +64 -100
- gisserver/geometries.py +2 -2
- gisserver/operations/base.py +31 -21
- gisserver/operations/wfs20.py +44 -38
- gisserver/output/__init__.py +2 -1
- gisserver/output/base.py +43 -27
- gisserver/output/csv.py +38 -33
- gisserver/output/geojson.py +43 -51
- gisserver/output/gml32.py +88 -67
- gisserver/output/results.py +23 -8
- gisserver/output/utils.py +18 -2
- gisserver/output/xmlschema.py +1 -1
- gisserver/parsers/base.py +2 -2
- gisserver/parsers/fes20/__init__.py +18 -0
- gisserver/parsers/fes20/expressions.py +7 -12
- gisserver/parsers/fes20/functions.py +1 -1
- gisserver/parsers/fes20/operators.py +7 -3
- gisserver/parsers/fes20/query.py +11 -1
- gisserver/parsers/fes20/sorting.py +3 -1
- gisserver/parsers/gml/base.py +1 -1
- gisserver/queries/__init__.py +3 -0
- gisserver/queries/adhoc.py +16 -12
- gisserver/queries/base.py +76 -36
- gisserver/queries/projection.py +240 -0
- gisserver/queries/stored.py +7 -6
- gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +2 -2
- gisserver/types.py +78 -24
- gisserver/views.py +9 -20
- django_gisserver-1.4.0.dist-info/RECORD +0 -54
- gisserver/output/gml32_lxml.py +0 -612
- {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/LICENSE +0 -0
- {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
|
gisserver/parsers/fes20/query.py
CHANGED
|
@@ -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(
|
|
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(
|
|
23
|
+
raise InvalidParameterValue(
|
|
24
|
+
"Expect ASC/DESC ordering direction", locator="sortby"
|
|
25
|
+
) from None
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
@dataclass
|
gisserver/parsers/gml/base.py
CHANGED
gisserver/queries/__init__.py
CHANGED
|
@@ -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
|
)
|
gisserver/queries/adhoc.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for
|
|
99
|
-
|
|
100
|
-
|
|
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"]
|
gisserver/queries/stored.py
CHANGED
|
@@ -80,14 +80,14 @@ class StoredQuery(QueryExpression):
|
|
|
80
80
|
args[name] = KVP[name]
|
|
81
81
|
except KeyError:
|
|
82
82
|
raise MissingParameterValue(
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
198
|
+
raise NotFound(f"Feature not found with ID {self.id}.", locator="ID")
|
|
198
199
|
|
|
199
200
|
return collection
|
|
200
201
|
|