django-gisserver 1.2.7__py3-none-any.whl → 1.4.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.2.7.dist-info → django_gisserver-1.4.0.dist-info}/METADATA +12 -12
- django_gisserver-1.4.0.dist-info/RECORD +54 -0
- {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/conf.py +9 -12
- gisserver/db.py +6 -10
- gisserver/exceptions.py +1 -0
- gisserver/features.py +18 -29
- gisserver/geometries.py +11 -25
- gisserver/operations/base.py +19 -40
- gisserver/operations/wfs20.py +8 -20
- gisserver/output/__init__.py +7 -2
- gisserver/output/base.py +4 -13
- gisserver/output/csv.py +15 -19
- gisserver/output/geojson.py +16 -20
- gisserver/output/gml32.py +310 -283
- gisserver/output/gml32_lxml.py +612 -0
- gisserver/output/results.py +107 -22
- gisserver/output/utils.py +15 -5
- gisserver/output/xmlschema.py +7 -8
- gisserver/parsers/base.py +2 -4
- gisserver/parsers/fes20/expressions.py +6 -13
- gisserver/parsers/fes20/filters.py +6 -5
- gisserver/parsers/fes20/functions.py +4 -4
- gisserver/parsers/fes20/identifiers.py +1 -0
- gisserver/parsers/fes20/operators.py +16 -43
- gisserver/parsers/fes20/query.py +1 -3
- gisserver/parsers/fes20/sorting.py +1 -3
- gisserver/parsers/gml/__init__.py +1 -0
- gisserver/parsers/gml/base.py +1 -0
- gisserver/parsers/values.py +1 -3
- gisserver/queries/__init__.py +1 -0
- gisserver/queries/adhoc.py +3 -6
- gisserver/queries/base.py +2 -6
- gisserver/queries/stored.py +3 -6
- gisserver/types.py +59 -46
- gisserver/views.py +7 -10
- django_gisserver-1.2.7.dist-info/RECORD +0 -54
- gisserver/output/buffer.py +0 -64
- {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/LICENSE +0 -0
- {django_gisserver-1.2.7.dist-info → django_gisserver-1.4.0.dist-info}/top_level.txt +0 -0
gisserver/output/results.py
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
The "SimpleFeatureCollection" and "FeatureCollection" and their
|
|
4
4
|
properties match the WFS 2.0 spec closely.
|
|
5
5
|
"""
|
|
6
|
+
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import math
|
|
9
10
|
import operator
|
|
10
|
-
from
|
|
11
|
-
from
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from datetime import timezone
|
|
13
|
+
from functools import cached_property, reduce
|
|
12
14
|
|
|
13
|
-
import django
|
|
14
15
|
from django.db import models
|
|
15
|
-
from django.utils.
|
|
16
|
-
from django.utils.timezone import now, utc
|
|
16
|
+
from django.utils.timezone import now
|
|
17
17
|
|
|
18
|
+
from gisserver import conf
|
|
18
19
|
from gisserver.features import FeatureType
|
|
19
20
|
from gisserver.geometries import BoundingBox
|
|
20
21
|
|
|
@@ -41,6 +42,7 @@ class SimpleFeatureCollection:
|
|
|
41
42
|
self.stop = stop
|
|
42
43
|
self._result_cache = None
|
|
43
44
|
self._result_iterator = None
|
|
45
|
+
self._has_more = None
|
|
44
46
|
|
|
45
47
|
def __iter__(self) -> Iterable[models.Model]:
|
|
46
48
|
"""Iterate through all results.
|
|
@@ -78,8 +80,14 @@ class SimpleFeatureCollection:
|
|
|
78
80
|
# resulttype=hits
|
|
79
81
|
return iter([])
|
|
80
82
|
else:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
if self._use_sentinel_record:
|
|
84
|
+
model_iter = self._paginated_queryset(add_sentinel=True).iterator()
|
|
85
|
+
self._result_iterator = CountingIterator(
|
|
86
|
+
model_iter, max_results=(self.stop - self.start)
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
model_iter = self._paginated_queryset().iterator()
|
|
90
|
+
self._result_iterator = CountingIterator(model_iter)
|
|
83
91
|
return iter(self._result_iterator)
|
|
84
92
|
|
|
85
93
|
def _chunked_iterator(self):
|
|
@@ -88,7 +96,7 @@ class SimpleFeatureCollection:
|
|
|
88
96
|
self._result_iterator = ChunkedQuerySetIterator(self._paginated_queryset())
|
|
89
97
|
return iter(self._result_iterator)
|
|
90
98
|
|
|
91
|
-
def _paginated_queryset(self) -> models.QuerySet:
|
|
99
|
+
def _paginated_queryset(self, add_sentinel=True) -> models.QuerySet:
|
|
92
100
|
"""Apply the pagination to the queryset."""
|
|
93
101
|
if self.stop == math.inf:
|
|
94
102
|
# Infinite page requested
|
|
@@ -97,7 +105,7 @@ class SimpleFeatureCollection:
|
|
|
97
105
|
else:
|
|
98
106
|
return self.queryset
|
|
99
107
|
else:
|
|
100
|
-
return self.queryset[self.start : self.stop]
|
|
108
|
+
return self.queryset[self.start : self.stop + (1 if add_sentinel else 0)]
|
|
101
109
|
|
|
102
110
|
def first(self):
|
|
103
111
|
try:
|
|
@@ -121,15 +129,37 @@ class SimpleFeatureCollection:
|
|
|
121
129
|
# This still allows prefetch_related() to work,
|
|
122
130
|
# since QuerySet.iterator() is avoided.
|
|
123
131
|
if self.stop == math.inf:
|
|
124
|
-
# Infinite page requested
|
|
125
|
-
if self.start
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
# Infinite page requested, see if start is still requested
|
|
133
|
+
qs = self.queryset[self.start :] if self.start else self.queryset.all()
|
|
134
|
+
self._result_cache = list(qs)
|
|
135
|
+
elif self._use_sentinel_record:
|
|
136
|
+
# No counting, but instead fetch an extra item as sentinel to see if there are more results.
|
|
137
|
+
qs = self.queryset[self.start : self.stop + 1]
|
|
138
|
+
page_results = list(qs)
|
|
139
|
+
|
|
140
|
+
# The stop + 1 sentinel allows checking if there is a next page.
|
|
141
|
+
# This means no COUNT() is needed to detect that.
|
|
142
|
+
page_size = self.stop - self.start
|
|
143
|
+
self._has_more = len(page_results) > page_size
|
|
144
|
+
if self._has_more:
|
|
145
|
+
# remove extra element
|
|
146
|
+
page_results.pop()
|
|
147
|
+
|
|
148
|
+
self._result_cache = page_results
|
|
129
149
|
else:
|
|
150
|
+
# Fetch exactly the page size, no more is needed.
|
|
151
|
+
# Will use a COUNT on the total table, so it can be used to see if there are more pages.
|
|
130
152
|
qs = self.queryset[self.start : self.stop]
|
|
153
|
+
self._result_cache = list(qs)
|
|
131
154
|
|
|
132
|
-
|
|
155
|
+
@cached_property
|
|
156
|
+
def _use_sentinel_record(self) -> bool:
|
|
157
|
+
"""Tell whether a sentinel record should be included in the result set.
|
|
158
|
+
This is used to determine whether there are more results, without having to perform a COUNT query
|
|
159
|
+
"""
|
|
160
|
+
return conf.GISSERVER_COUNT_NUMBER_MATCHED == 0 or (
|
|
161
|
+
conf.GISSERVER_COUNT_NUMBER_MATCHED == 2 and self.start
|
|
162
|
+
)
|
|
133
163
|
|
|
134
164
|
@cached_property
|
|
135
165
|
def number_returned(self) -> int:
|
|
@@ -149,8 +179,7 @@ class SimpleFeatureCollection:
|
|
|
149
179
|
@cached_property
|
|
150
180
|
def number_matched(self) -> int:
|
|
151
181
|
"""Return the total number of matches across all pages."""
|
|
152
|
-
|
|
153
|
-
if page_size and self.number_returned < page_size:
|
|
182
|
+
if self._is_surely_last_page:
|
|
154
183
|
# For resulttype=results, an expensive COUNT query can be avoided
|
|
155
184
|
# when this is the first and only page or the last page.
|
|
156
185
|
return self.start + self.number_returned
|
|
@@ -165,13 +194,46 @@ class SimpleFeatureCollection:
|
|
|
165
194
|
}
|
|
166
195
|
if clean_annotations != qs.query.annotations:
|
|
167
196
|
qs = self.queryset.all() # make a clone to allow editing
|
|
168
|
-
|
|
169
|
-
qs.query.annotations = clean_annotations
|
|
170
|
-
else:
|
|
171
|
-
qs.query._annotations = clean_annotations
|
|
197
|
+
qs.query.annotations = clean_annotations
|
|
172
198
|
|
|
173
199
|
return qs.count()
|
|
174
200
|
|
|
201
|
+
@property
|
|
202
|
+
def _is_surely_last_page(self):
|
|
203
|
+
"""Return true when it's totally clear this is the last page."""
|
|
204
|
+
# Optimization to avoid making COUNT() queries when we can already know the answer.
|
|
205
|
+
if self.stop == math.inf:
|
|
206
|
+
return True # Infinite page requested
|
|
207
|
+
elif self._use_sentinel_record:
|
|
208
|
+
if self._has_more is not None:
|
|
209
|
+
# did page+1 record check here, answer is known.
|
|
210
|
+
return not self._has_more
|
|
211
|
+
elif (
|
|
212
|
+
isinstance(self._result_iterator, CountingIterator)
|
|
213
|
+
and self._result_iterator.has_more is not None
|
|
214
|
+
):
|
|
215
|
+
# did page+1 record check via the CountingIterator.
|
|
216
|
+
return not self._result_iterator.has_more
|
|
217
|
+
|
|
218
|
+
# Here different things will happen.
|
|
219
|
+
# For GeoJSON output, the iterator was read first, and `number_returned` is already filled in.
|
|
220
|
+
# For GML output, the pagination details are requested first, and will fetch all data.
|
|
221
|
+
# Hence, reading `number_returned` here can be quite an intensive operation.
|
|
222
|
+
page_size = self.stop - self.start # is 0 for resulttype=hits
|
|
223
|
+
return page_size and (self.number_returned < page_size or self._has_more is False)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def has_next(self):
|
|
227
|
+
if self.stop == math.inf:
|
|
228
|
+
return False
|
|
229
|
+
elif self._has_more is not None:
|
|
230
|
+
return self._has_more # did page+1 record check, answer is known.
|
|
231
|
+
elif self._is_surely_last_page:
|
|
232
|
+
return False # Less results then expected, answer is known.
|
|
233
|
+
|
|
234
|
+
# This will perform an slow COUNT() query...
|
|
235
|
+
return self.stop < self.number_matched
|
|
236
|
+
|
|
175
237
|
def get_bounding_box(self) -> BoundingBox:
|
|
176
238
|
"""Determine bounding box of all items."""
|
|
177
239
|
self.fetch_results() # Avoid querying results twice
|
|
@@ -218,7 +280,7 @@ class FeatureCollection:
|
|
|
218
280
|
self.next = next
|
|
219
281
|
self.previous = previous
|
|
220
282
|
self.date = now()
|
|
221
|
-
self.timestamp = self.date.astimezone(utc).isoformat()
|
|
283
|
+
self.timestamp = self.date.astimezone(timezone.utc).isoformat()
|
|
222
284
|
|
|
223
285
|
def get_bounding_box(self) -> BoundingBox:
|
|
224
286
|
"""Determine bounding box of all items."""
|
|
@@ -239,10 +301,33 @@ class FeatureCollection:
|
|
|
239
301
|
# By making the number_matched lazy, the calling method has a chance to
|
|
240
302
|
# decorate the results with extra annotations before they are evaluated.
|
|
241
303
|
# This is needed since SimpleCollection.number_matched already evaluates the queryset.
|
|
304
|
+
if conf.GISSERVER_COUNT_NUMBER_MATCHED == 0 or (
|
|
305
|
+
conf.GISSERVER_COUNT_NUMBER_MATCHED == 2 and self.results[0].start > 0
|
|
306
|
+
):
|
|
307
|
+
# Report "unknown" for either all pages, or the second page.
|
|
308
|
+
# Most clients don't need this metadata and thus we avoid a COUNT query.
|
|
309
|
+
return None
|
|
310
|
+
|
|
242
311
|
return sum(c.number_matched for c in self.results)
|
|
243
312
|
else:
|
|
244
313
|
# Evaluate any lazy attributes
|
|
245
314
|
return int(self._number_matched)
|
|
246
315
|
|
|
316
|
+
@property
|
|
317
|
+
def has_next(self) -> bool:
|
|
318
|
+
"""Efficient way to see if a next link needs to be written.
|
|
319
|
+
|
|
320
|
+
This method will show up in profiling through
|
|
321
|
+
as it will be the first moment where queries are executed.
|
|
322
|
+
"""
|
|
323
|
+
if all(c._is_surely_last_page for c in self.results):
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
for c in self.results: # noqa: SIM110
|
|
327
|
+
# This may perform an COUNT query or read the results and detect the sentinel object:
|
|
328
|
+
if c.has_next:
|
|
329
|
+
return True
|
|
330
|
+
return False
|
|
331
|
+
|
|
247
332
|
def __iter__(self):
|
|
248
333
|
return iter(self.results)
|
gisserver/output/utils.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections import defaultdict
|
|
4
|
+
from collections.abc import Iterable
|
|
4
5
|
from itertools import islice
|
|
5
|
-
from typing import
|
|
6
|
+
from typing import TypeVar
|
|
6
7
|
|
|
7
8
|
from django.db import models
|
|
8
9
|
from lru import LRU
|
|
@@ -15,10 +16,12 @@ DEFAULT_SQL_CHUNK_SIZE = 2000 # allow unit tests to alter this.
|
|
|
15
16
|
class CountingIterator(Iterable[M]):
|
|
16
17
|
"""A simple iterator that counts how many results are given."""
|
|
17
18
|
|
|
18
|
-
def __init__(self, iterator: Iterable[M]):
|
|
19
|
+
def __init__(self, iterator: Iterable[M], max_results=0):
|
|
19
20
|
self._iterator = iterator
|
|
20
21
|
self._number_returned = 0
|
|
21
22
|
self._in_iterator = False
|
|
23
|
+
self._max_results = max_results
|
|
24
|
+
self._has_more = None
|
|
22
25
|
|
|
23
26
|
def __iter__(self):
|
|
24
27
|
# Count the number of returned items while reading them.
|
|
@@ -27,9 +30,14 @@ class CountingIterator(Iterable[M]):
|
|
|
27
30
|
try:
|
|
28
31
|
self._number_returned = 0
|
|
29
32
|
for instance in self._iterator:
|
|
33
|
+
if self._max_results and self._number_returned == self._max_results:
|
|
34
|
+
self._has_more = True
|
|
35
|
+
break
|
|
30
36
|
self._number_returned += 1
|
|
31
37
|
yield instance
|
|
32
38
|
finally:
|
|
39
|
+
if self._max_results and self._has_more is None:
|
|
40
|
+
self._has_more = False # ignored the sentinel item
|
|
33
41
|
self._in_iterator = False
|
|
34
42
|
|
|
35
43
|
@property
|
|
@@ -39,6 +47,10 @@ class CountingIterator(Iterable[M]):
|
|
|
39
47
|
raise RuntimeError("Can't read number of returned results during iteration")
|
|
40
48
|
return self._number_returned
|
|
41
49
|
|
|
50
|
+
@property
|
|
51
|
+
def has_more(self) -> bool | None:
|
|
52
|
+
return self._has_more
|
|
53
|
+
|
|
42
54
|
|
|
43
55
|
class ChunkedQuerySetIterator(Iterable[M]):
|
|
44
56
|
"""An optimal strategy to perform ``prefetch_related()`` on large datasets.
|
|
@@ -113,9 +125,7 @@ class ChunkedQuerySetIterator(Iterable[M]):
|
|
|
113
125
|
|
|
114
126
|
# Reuse the Django machinery for retrieving missing sub objects.
|
|
115
127
|
# and analyse the ForeignKey caches to allow faster prefetches next time
|
|
116
|
-
models.prefetch_related_objects(
|
|
117
|
-
instances, *self.queryset._prefetch_related_lookups
|
|
118
|
-
)
|
|
128
|
+
models.prefetch_related_objects(instances, *self.queryset._prefetch_related_lookups)
|
|
119
129
|
self._persist_prefetch_cache(instances)
|
|
120
130
|
|
|
121
131
|
def _persist_prefetch_cache(self, instances):
|
gisserver/output/xmlschema.py
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections import deque
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import cast
|
|
5
7
|
|
|
6
8
|
from gisserver.features import FeatureType
|
|
7
9
|
from gisserver.operations.base import WFSMethod
|
|
8
10
|
from gisserver.types import XsdComplexType
|
|
9
11
|
|
|
10
12
|
from .base import OutputRenderer
|
|
11
|
-
from .buffer import StringBuffer
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class XMLSchemaRenderer(OutputRenderer):
|
|
@@ -30,12 +31,10 @@ class XMLSchemaRenderer(OutputRenderer):
|
|
|
30
31
|
# the prefixes to be different.
|
|
31
32
|
xmlns_features = "\n ".join(
|
|
32
33
|
f'xmlns:{p}="{self.app_xml_namespace}"'
|
|
33
|
-
for p in sorted(
|
|
34
|
-
{feature_type.xml_prefix for feature_type in self.feature_types}
|
|
35
|
-
)
|
|
34
|
+
for p in sorted({feature_type.xml_prefix for feature_type in self.feature_types})
|
|
36
35
|
)
|
|
37
36
|
|
|
38
|
-
output =
|
|
37
|
+
output = StringIO()
|
|
39
38
|
output.write(
|
|
40
39
|
f"""<?xml version='1.0' encoding="UTF-8" ?>
|
|
41
40
|
<schema
|
|
@@ -63,7 +62,7 @@ class XMLSchemaRenderer(OutputRenderer):
|
|
|
63
62
|
)
|
|
64
63
|
|
|
65
64
|
def render_feature_type(self, feature_type: FeatureType):
|
|
66
|
-
output =
|
|
65
|
+
output = StringIO()
|
|
67
66
|
xsd_type: XsdComplexType = feature_type.xsd_type
|
|
68
67
|
|
|
69
68
|
# This declares the that a top-level <app:featureName> is a class of a type.
|
|
@@ -83,7 +82,7 @@ class XMLSchemaRenderer(OutputRenderer):
|
|
|
83
82
|
|
|
84
83
|
def render_complex_type(self, complex_type: XsdComplexType):
|
|
85
84
|
"""Write the definition of a single class."""
|
|
86
|
-
output =
|
|
85
|
+
output = StringIO()
|
|
87
86
|
output.write(
|
|
88
87
|
f' <complexType name="{complex_type.name}">\n'
|
|
89
88
|
" <complexContent>\n"
|
gisserver/parsers/base.py
CHANGED
|
@@ -23,9 +23,7 @@ class TagNameEnum(Enum):
|
|
|
23
23
|
|
|
24
24
|
@classmethod
|
|
25
25
|
def _missing_(cls, value):
|
|
26
|
-
raise NotImplementedError(
|
|
27
|
-
f"<{value}> is not registered as valid {cls.__name__}"
|
|
28
|
-
)
|
|
26
|
+
raise NotImplementedError(f"<{value}> is not registered as valid {cls.__name__}")
|
|
29
27
|
|
|
30
28
|
def __repr__(self):
|
|
31
29
|
# Make repr(filter) easier to copy-paste
|
|
@@ -108,7 +106,7 @@ class TagRegistry:
|
|
|
108
106
|
|
|
109
107
|
def _dec(sub_class: type[BaseNode]):
|
|
110
108
|
# Looping over _member_names_ will skip aliased items (like BBOX/Within)
|
|
111
|
-
for member_name in names.__members__
|
|
109
|
+
for member_name in names.__members__:
|
|
112
110
|
self.register(name=member_name, namespace=namespace)(sub_class)
|
|
113
111
|
return sub_class
|
|
114
112
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""These classes map to the FES 2.0 specification for expressions.
|
|
2
2
|
The class names are identical to those in the FES spec.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
6
7
|
import operator
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from datetime import date, datetime
|
|
9
10
|
from decimal import Decimal as D
|
|
11
|
+
from functools import cached_property
|
|
10
12
|
from typing import Union
|
|
11
13
|
from xml.etree.ElementTree import Element
|
|
12
14
|
|
|
@@ -14,7 +16,6 @@ from django.contrib.gis.geos import GEOSGeometry
|
|
|
14
16
|
from django.db import models
|
|
15
17
|
from django.db.models import Func, Q, Value
|
|
16
18
|
from django.db.models.expressions import Combinable
|
|
17
|
-
from django.utils.functional import cached_property
|
|
18
19
|
|
|
19
20
|
from gisserver.exceptions import ExternalParsingError
|
|
20
21
|
from gisserver.parsers.base import BaseNode, TagNameEnum, tag_registry
|
|
@@ -31,12 +32,8 @@ from gisserver.parsers.values import auto_cast
|
|
|
31
32
|
from gisserver.types import FES20, ORMPath, XsdTypes, split_xml_name
|
|
32
33
|
|
|
33
34
|
NoneType = type(None)
|
|
34
|
-
RhsTypes = Union[
|
|
35
|
-
|
|
36
|
-
]
|
|
37
|
-
ParsedValue = Union[
|
|
38
|
-
int, str, date, D, datetime, GM_Object, GM_Envelope, TM_Object, NoneType
|
|
39
|
-
]
|
|
35
|
+
RhsTypes = Union[Combinable, Func, Q, GEOSGeometry, bool, int, str, date, datetime, tuple]
|
|
36
|
+
ParsedValue = Union[int, str, date, D, datetime, GM_Object, GM_Envelope, TM_Object, NoneType]
|
|
40
37
|
|
|
41
38
|
OUTPUT_FIELDS = {
|
|
42
39
|
bool: models.BooleanField(),
|
|
@@ -148,9 +145,7 @@ class Literal(Expression):
|
|
|
148
145
|
By aliasing the value using an annotation,
|
|
149
146
|
it can be queried like a regular field name.
|
|
150
147
|
"""
|
|
151
|
-
return compiler.add_annotation(
|
|
152
|
-
Value(self.value, output_field=self.get_output_field())
|
|
153
|
-
)
|
|
148
|
+
return compiler.add_annotation(Value(self.value, output_field=self.get_output_field()))
|
|
154
149
|
|
|
155
150
|
def get_output_field(self):
|
|
156
151
|
# When the value is used a left-hand-side, Django needs to know the output type.
|
|
@@ -169,9 +164,7 @@ class Literal(Expression):
|
|
|
169
164
|
|
|
170
165
|
@dataclass(repr=False)
|
|
171
166
|
@tag_registry.register("ValueReference")
|
|
172
|
-
@tag_registry.register(
|
|
173
|
-
"PropertyName", hidden=True
|
|
174
|
-
) # FES 1.0 name that old clients still use.
|
|
167
|
+
@tag_registry.register("PropertyName", hidden=True) # FES 1.0 name that old clients still use.
|
|
175
168
|
class ValueReference(Expression):
|
|
176
169
|
"""The <fes:ValueReference> element that holds an XPath string.
|
|
177
170
|
In the fes XSD, this is declared as a subclass of xsd:string.
|
|
@@ -42,11 +42,12 @@ class Filter:
|
|
|
42
42
|
if isinstance(text, str):
|
|
43
43
|
end_first = text.index(">")
|
|
44
44
|
first_tag = text[:end_first].lstrip()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
# Allow KVP requests without a namespace
|
|
46
|
+
# Both geoserver and mapserver support this.
|
|
47
|
+
if "xmlns" not in first_tag and (
|
|
48
|
+
first_tag == "<Filter" or first_tag.startswith("<Filter ")
|
|
49
|
+
):
|
|
50
|
+
text = f'{first_tag} xmlns="{FES20}" xmlns:gml="{GML32}"{text[end_first:]}'
|
|
50
51
|
|
|
51
52
|
try:
|
|
52
53
|
root_element = fromstring(text)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Functions to be callable from fes."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
4
6
|
from dataclasses import dataclass
|
|
5
|
-
from typing import
|
|
7
|
+
from typing import Union
|
|
6
8
|
|
|
7
9
|
from django.contrib.gis.db.models import functions as gis
|
|
8
10
|
from django.db import models
|
|
@@ -40,9 +42,7 @@ class FesFunction:
|
|
|
40
42
|
"""Build the query expression for the function."""
|
|
41
43
|
if len(expressions) != len(self.arguments):
|
|
42
44
|
# Avoid passing extra parameters to the function if those are not defined.
|
|
43
|
-
raise TypeError(
|
|
44
|
-
f'Invalid number of arguments for <fes:Function name="{self.name}">'
|
|
45
|
-
)
|
|
45
|
+
raise TypeError(f'Invalid number of arguments for <fes:Function name="{self.name}">')
|
|
46
46
|
|
|
47
47
|
# Keyword arguments are avoided, since some Django SQL functions had
|
|
48
48
|
# SQL-injection issues with those arguments. Only passing expressions for now.
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""These classes map to the FES 2.0 specification for operators.
|
|
2
2
|
The class names and attributes are identical to those in the FES spec.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
6
7
|
import operator
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from decimal import Decimal
|
|
9
10
|
from enum import Enum
|
|
10
|
-
from functools import reduce
|
|
11
|
+
from functools import cached_property, reduce
|
|
11
12
|
from itertools import groupby
|
|
12
13
|
from typing import Any, Union
|
|
13
14
|
from xml.etree.ElementTree import Element, QName
|
|
@@ -15,7 +16,6 @@ from xml.etree.ElementTree import Element, QName
|
|
|
15
16
|
from django.conf import settings
|
|
16
17
|
from django.contrib.gis import measure
|
|
17
18
|
from django.db.models import Q
|
|
18
|
-
from django.utils.functional import cached_property
|
|
19
19
|
|
|
20
20
|
from gisserver.exceptions import ExternalParsingError, OperationProcessingFailed
|
|
21
21
|
from gisserver.parsers import gml
|
|
@@ -39,8 +39,7 @@ except ImportError:
|
|
|
39
39
|
else:
|
|
40
40
|
|
|
41
41
|
class HasBuildRhs(Protocol):
|
|
42
|
-
def build_rhs(self, compiler) -> RhsTypes:
|
|
43
|
-
...
|
|
42
|
+
def build_rhs(self, compiler) -> RhsTypes: ...
|
|
44
43
|
|
|
45
44
|
|
|
46
45
|
if "django.contrib.postgres" in settings.INSTALLED_APPS:
|
|
@@ -169,9 +168,7 @@ class Operator(BaseNode):
|
|
|
169
168
|
xml_ns = FES20
|
|
170
169
|
|
|
171
170
|
def build_query(self, compiler: CompiledQuery) -> Q | None:
|
|
172
|
-
raise NotImplementedError(
|
|
173
|
-
f"Using {self.__class__.__name__} is not supported yet."
|
|
174
|
-
)
|
|
171
|
+
raise NotImplementedError(f"Using {self.__class__.__name__} is not supported yet.")
|
|
175
172
|
|
|
176
173
|
|
|
177
174
|
@dataclass
|
|
@@ -183,9 +180,7 @@ class IdOperator(Operator):
|
|
|
183
180
|
@property
|
|
184
181
|
def type_names(self) -> list[str]:
|
|
185
182
|
"""Provide a list of all type names accessed by this operator"""
|
|
186
|
-
return [
|
|
187
|
-
type_name for type_name in self.grouped_ids.keys() if type_name is not None
|
|
188
|
-
]
|
|
183
|
+
return [type_name for type_name in self.grouped_ids if type_name is not None]
|
|
189
184
|
|
|
190
185
|
@cached_property
|
|
191
186
|
def grouped_ids(self) -> dict[str, list[Id]]:
|
|
@@ -209,9 +204,7 @@ class IdOperator(Operator):
|
|
|
209
204
|
return
|
|
210
205
|
|
|
211
206
|
for type_name, items in self.grouped_ids.items():
|
|
212
|
-
ids_subset = reduce(
|
|
213
|
-
operator.or_, [id.build_query(compiler=None) for id in items]
|
|
214
|
-
)
|
|
207
|
+
ids_subset = reduce(operator.or_, [id.build_query(compiler=None) for id in items])
|
|
215
208
|
compiler.add_lookups(ids_subset, type_name=type_name)
|
|
216
209
|
|
|
217
210
|
|
|
@@ -349,14 +342,10 @@ class DistanceOperator(SpatialOperator):
|
|
|
349
342
|
if not geometries:
|
|
350
343
|
raise ExternalParsingError(f"Missing gml element in <{element.tag}>")
|
|
351
344
|
elif len(geometries) > 1:
|
|
352
|
-
raise ExternalParsingError(
|
|
353
|
-
f"Multiple gml elements found in <{element.tag}>"
|
|
354
|
-
)
|
|
345
|
+
raise ExternalParsingError(f"Multiple gml elements found in <{element.tag}>")
|
|
355
346
|
|
|
356
347
|
return cls(
|
|
357
|
-
valueReference=ValueReference.from_xml(
|
|
358
|
-
get_child(element, FES20, "ValueReference")
|
|
359
|
-
),
|
|
348
|
+
valueReference=ValueReference.from_xml(get_child(element, FES20, "ValueReference")),
|
|
360
349
|
operatorType=DistanceOperatorName.from_xml(element),
|
|
361
350
|
geometry=gml.parse_gml_node(geometries[0]),
|
|
362
351
|
distance=Measure.from_xml(get_child(element, FES20, "Distance")),
|
|
@@ -470,17 +459,13 @@ class BinaryComparisonOperator(ComparisonOperator):
|
|
|
470
459
|
Expression.from_child_xml(element[1]),
|
|
471
460
|
),
|
|
472
461
|
matchCase=element.get("matchCase", True),
|
|
473
|
-
matchAction=MatchAction(
|
|
474
|
-
element.get("matchAction", default=MatchAction.Any)
|
|
475
|
-
),
|
|
462
|
+
matchAction=MatchAction(element.get("matchAction", default=MatchAction.Any)),
|
|
476
463
|
_source=element.tag,
|
|
477
464
|
)
|
|
478
465
|
|
|
479
466
|
def build_query(self, compiler: CompiledQuery) -> Q:
|
|
480
467
|
lhs, rhs = self.expression
|
|
481
|
-
return self.build_compare(
|
|
482
|
-
compiler, lhs=lhs, lookup=self.operatorType.value, rhs=rhs
|
|
483
|
-
)
|
|
468
|
+
return self.build_compare(compiler, lhs=lhs, lookup=self.operatorType.value, rhs=rhs)
|
|
484
469
|
|
|
485
470
|
|
|
486
471
|
@dataclass
|
|
@@ -508,13 +493,9 @@ class BetweenComparisonOperator(ComparisonOperator):
|
|
|
508
493
|
upper = get_child(element, FES20, "UpperBoundary")
|
|
509
494
|
|
|
510
495
|
if len(lower) != 1:
|
|
511
|
-
raise ExternalParsingError(
|
|
512
|
-
f"{lower.tag} should have 1 expression child node"
|
|
513
|
-
)
|
|
496
|
+
raise ExternalParsingError(f"{lower.tag} should have 1 expression child node")
|
|
514
497
|
if len(upper) != 1:
|
|
515
|
-
raise ExternalParsingError(
|
|
516
|
-
f"{upper.tag} should have 1 expression child node"
|
|
517
|
-
)
|
|
498
|
+
raise ExternalParsingError(f"{upper.tag} should have 1 expression child node")
|
|
518
499
|
|
|
519
500
|
return cls(
|
|
520
501
|
expression=Expression.from_child_xml(element[0]),
|
|
@@ -573,9 +554,7 @@ class LikeOperator(ComparisonOperator):
|
|
|
573
554
|
|
|
574
555
|
rhs = Literal(raw_value=value)
|
|
575
556
|
else:
|
|
576
|
-
raise ExternalParsingError(
|
|
577
|
-
f"Expected a literal value for the {self.tag} operator."
|
|
578
|
-
)
|
|
557
|
+
raise ExternalParsingError(f"Expected a literal value for the {self.tag} operator.")
|
|
579
558
|
|
|
580
559
|
# Use the FesLike lookup
|
|
581
560
|
return self.build_compare(compiler, lhs=lhs, lookup="fes_like", rhs=rhs)
|
|
@@ -603,9 +582,7 @@ class NilOperator(ComparisonOperator):
|
|
|
603
582
|
|
|
604
583
|
def build_query(self, compiler: CompiledQuery) -> Q:
|
|
605
584
|
# Any value that evaluates to None is returned as 'xs:nil' in our output.
|
|
606
|
-
return self.build_compare(
|
|
607
|
-
compiler, lhs=self.expression, lookup="isnull", rhs=True
|
|
608
|
-
)
|
|
585
|
+
return self.build_compare(compiler, lhs=self.expression, lookup="isnull", rhs=True)
|
|
609
586
|
|
|
610
587
|
|
|
611
588
|
@dataclass
|
|
@@ -621,18 +598,14 @@ class NullOperator(ComparisonOperator):
|
|
|
621
598
|
@classmethod
|
|
622
599
|
@expect_children(1, Expression)
|
|
623
600
|
def from_xml(cls, element: Element):
|
|
624
|
-
return cls(
|
|
625
|
-
expression=Expression.from_child_xml(element[0]), _source=element.tag
|
|
626
|
-
)
|
|
601
|
+
return cls(expression=Expression.from_child_xml(element[0]), _source=element.tag)
|
|
627
602
|
|
|
628
603
|
def build_query(self, compiler: CompiledQuery) -> Q:
|
|
629
604
|
# For now, the implementation is identical to PropertyIsNil.
|
|
630
605
|
# According to the WFS spec, this should only be true when the element
|
|
631
606
|
# is not returned at all (minOccurs=0).
|
|
632
607
|
# TODO: this happens for maxOccurs=unbounded with a null value.
|
|
633
|
-
return self.build_compare(
|
|
634
|
-
compiler, lhs=self.expression, lookup="isnull", rhs=True
|
|
635
|
-
)
|
|
608
|
+
return self.build_compare(compiler, lhs=self.expression, lookup="isnull", rhs=True)
|
|
636
609
|
|
|
637
610
|
|
|
638
611
|
class LogicalOperator(NonIdOperator):
|
gisserver/parsers/fes20/query.py
CHANGED
|
@@ -115,9 +115,7 @@ class CompiledQuery:
|
|
|
115
115
|
"""Mark as returning no results."""
|
|
116
116
|
self.is_empty = True
|
|
117
117
|
|
|
118
|
-
def filter_queryset(
|
|
119
|
-
self, queryset: QuerySet, feature_type: FeatureType
|
|
120
|
-
) -> QuerySet:
|
|
118
|
+
def filter_queryset(self, queryset: QuerySet, feature_type: FeatureType) -> QuerySet:
|
|
121
119
|
"""Apply the filters and lookups to the queryset.
|
|
122
120
|
|
|
123
121
|
:param queryset: The queryset to filter.
|
|
@@ -20,9 +20,7 @@ class SortOrder(Enum):
|
|
|
20
20
|
try:
|
|
21
21
|
return cls[direction]
|
|
22
22
|
except KeyError:
|
|
23
|
-
raise InvalidParameterValue(
|
|
24
|
-
"sortby", "Expect ASC/DESC ordering direction"
|
|
25
|
-
) from None
|
|
23
|
+
raise InvalidParameterValue("sortby", "Expect ASC/DESC ordering direction") from None
|
|
26
24
|
|
|
27
25
|
|
|
28
26
|
@dataclass
|
gisserver/parsers/gml/base.py
CHANGED
gisserver/parsers/values.py
CHANGED
|
@@ -27,9 +27,7 @@ def auto_cast(value: str):
|
|
|
27
27
|
def parse_iso_datetime(raw_value: str) -> datetime:
|
|
28
28
|
value = parse_datetime(raw_value)
|
|
29
29
|
if value is None:
|
|
30
|
-
raise ExternalParsingError(
|
|
31
|
-
"Date must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format."
|
|
32
|
-
)
|
|
30
|
+
raise ExternalParsingError("Date must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format.")
|
|
33
31
|
return value
|
|
34
32
|
|
|
35
33
|
|