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
gisserver/output/gml32.py CHANGED
@@ -30,11 +30,12 @@ from gisserver.db import (
30
30
  get_db_geometry_target,
31
31
  get_geometries_union,
32
32
  )
33
- from gisserver.exceptions import NotFound
34
- from gisserver.features import FeatureRelation, FeatureType
33
+ from gisserver.exceptions import NotFound, WFSException
34
+ from gisserver.features import FeatureType
35
35
  from gisserver.geometries import CRS
36
36
  from gisserver.parsers.fes20 import ValueReference
37
- from gisserver.types import XsdComplexType, XsdElement, XsdNode
37
+ from gisserver.queries import FeatureProjection, FeatureRelation
38
+ from gisserver.types import XsdElement, XsdNode
38
39
 
39
40
  from .base import OutputRenderer
40
41
  from .results import SimpleFeatureCollection
@@ -92,6 +93,7 @@ class GML32Renderer(OutputRenderer):
92
93
 
93
94
  content_type = "text/xml; charset=utf-8"
94
95
  xml_collection_tag = "FeatureCollection"
96
+ xml_sub_collection_tag = "FeatureCollection" # Mapserver does not use SimpleFeatureCollection
95
97
  chunk_size = 40_000
96
98
  gml_seq = 0
97
99
 
@@ -127,7 +129,7 @@ class GML32Renderer(OutputRenderer):
127
129
  """Default behavior for standalone response is writing a feature (can be changed by GetPropertyValue)"""
128
130
  self._write('<?xml version="1.0" encoding="UTF-8"?>\n')
129
131
  self.write_feature(
130
- feature_type=sub_collection.feature_type,
132
+ projection=sub_collection.projection,
131
133
  instance=instance,
132
134
  extra_xmlns=extra_xmlns,
133
135
  )
@@ -163,6 +165,36 @@ class GML32Renderer(OutputRenderer):
163
165
  ' xmlns:gml="http://www.opengis.net/gml/3.2"'
164
166
  )
165
167
 
168
+ def render_exception(self, exception: Exception):
169
+ """Render the exception in a format that fits with the output.
170
+
171
+ The WSF XSD spec has a hidden gem: an <wfs:truncatedResponse> element
172
+ can be rendered at the end of a feature collection
173
+ to inform the client an error happened during rendering.
174
+ """
175
+ message = super().render_exception(exception)
176
+ buffer = self.output.getvalue()
177
+
178
+ # Wrap into <ows:ExceptionReport> tag.
179
+ if not isinstance(exception, WFSException):
180
+ exception = WFSException(message, code=exception.__class__.__name__, status_code=500)
181
+ exception.debug_hint = False
182
+
183
+ # Only at the top-level, an exception report can be rendered
184
+ closing_child = (
185
+ f"</wfs:{self.xml_sub_collection_tag}></wfs:member>\n"
186
+ if len(self.collection.results) > 1
187
+ else ""
188
+ )
189
+ return (
190
+ f"{buffer}"
191
+ f"{closing_child}"
192
+ " <wfs:truncatedResponse>"
193
+ f"{exception.as_xml()}"
194
+ " </wfs:truncatedResponse>\n"
195
+ f"</wfs:{self.xml_collection_tag}>\n"
196
+ )
197
+
166
198
  def render_stream(self):
167
199
  """Render the XML as streaming content.
168
200
  This renders the standard <wfs:FeatureCollection> / <wfs:ValueCollection>
@@ -193,11 +225,12 @@ class GML32Renderer(OutputRenderer):
193
225
  has_multiple_collections = len(collection.results) > 1
194
226
 
195
227
  for sub_collection in collection.results:
228
+ projection = sub_collection.projection
196
229
  self.start_collection(sub_collection)
197
230
  if has_multiple_collections:
198
231
  self._write(
199
232
  f"<wfs:member>\n"
200
- f"<wfs:{self.xml_collection_tag}"
233
+ f"<wfs:{self.xml_sub_collection_tag}"
201
234
  f' timeStamp="{collection.timestamp}"'
202
235
  f' numberMatched="{int(sub_collection.number_matched)}"'
203
236
  f' numberReturned="{int(sub_collection.number_returned)}">\n'
@@ -206,7 +239,7 @@ class GML32Renderer(OutputRenderer):
206
239
  for instance in sub_collection:
207
240
  self.gml_seq = 0 # need to increment this between write_xml_field calls
208
241
  self._write("<wfs:member>\n")
209
- self.write_feature(sub_collection.feature_type, instance)
242
+ self.write_feature(projection, instance)
210
243
  self._write("</wfs:member>\n")
211
244
 
212
245
  # Only perform a 'yield' every once in a while,
@@ -218,7 +251,7 @@ class GML32Renderer(OutputRenderer):
218
251
  yield xml_chunk
219
252
 
220
253
  if has_multiple_collections:
221
- self._write(f"</wfs:{self.xml_collection_tag}>\n</wfs:member>\n")
254
+ self._write(f"</wfs:{self.xml_sub_collection_tag}>\n</wfs:member>\n")
222
255
 
223
256
  self._write(f"</wfs:{self.xml_collection_tag}>\n")
224
257
  yield output.getvalue()
@@ -227,43 +260,45 @@ class GML32Renderer(OutputRenderer):
227
260
  """Hook to allow initialization per feature type"""
228
261
 
229
262
  def write_feature(
230
- self, feature_type: FeatureType, instance: models.Model, extra_xmlns=""
263
+ self, projection: FeatureProjection, instance: models.Model, extra_xmlns=""
231
264
  ) -> None:
232
265
  """Write the contents of the object value.
233
266
 
234
267
  This output is typically wrapped in <wfs:member> tags
235
268
  unless it's used for a GetPropertyById response.
236
269
  """
270
+ feature_type = projection.feature_type
271
+
237
272
  # Write <app:FeatureTypeName> start node
238
273
  pk = _tag_escape(str(instance.pk))
239
274
  self._write(f'<{feature_type.xml_name} gml:id="{feature_type.name}.{pk}"{extra_xmlns}>\n')
240
-
241
275
  # Write all fields, both base class and local elements.
242
- for xsd_element in feature_type.xsd_type.all_elements:
276
+ for xsd_element in projection.xsd_root_elements:
243
277
  # Note that writing 5000 features with 30 tags means this code make 150.000 method calls.
244
278
  # Hence, branching to different rendering styles is branched here instead of making such
245
279
  # call into a generic "write_field()" function and stepping out from there.
280
+
246
281
  if xsd_element.is_geometry:
247
282
  if xsd_element.xml_name == "gml:boundedBy":
248
283
  # Special case for <gml:boundedBy>, so it will render with
249
284
  # the output CRS and can be overwritten with DB-rendered GML.
250
- self.write_bounds(feature_type, instance)
285
+ self.write_bounds(projection, instance)
251
286
  else:
252
287
  # Separate call which can be optimized (no need to overload write_xml_field() for all calls).
253
288
  self.write_gml_field(feature_type, xsd_element, instance)
254
289
  else:
255
290
  value = xsd_element.get_value(instance)
256
291
  if xsd_element.is_many:
257
- self.write_many(feature_type, xsd_element, value)
292
+ self.write_many(projection, xsd_element, value)
258
293
  else:
259
294
  # e.g. <gml:name>, or all other <app:...> nodes.
260
- self.write_xml_field(feature_type, xsd_element, value)
295
+ self.write_xml_field(projection, xsd_element, value)
261
296
 
262
297
  self._write(f"</{feature_type.xml_name}>\n")
263
298
 
264
- def write_bounds(self, feature_type, instance) -> None:
299
+ def write_bounds(self, projection, instance) -> None:
265
300
  """Render the GML bounds for the complete instance"""
266
- envelope = feature_type.get_envelope(instance, self.output_crs)
301
+ envelope = projection.feature_type.get_envelope(instance, self.output_crs)
267
302
  if envelope is not None:
268
303
  lower = " ".join(map(str, envelope.lower_corner))
269
304
  upper = " ".join(map(str, envelope.upper_corner))
@@ -274,7 +309,7 @@ class GML32Renderer(OutputRenderer):
274
309
  </gml:Envelope></gml:boundedBy>\n"""
275
310
  )
276
311
 
277
- def write_many(self, feature_type: FeatureType, xsd_element: XsdElement, value) -> None:
312
+ def write_many(self, projection: FeatureProjection, xsd_element: XsdElement, value) -> None:
278
313
  """Write a node that has multiple values (e.g. array or queryset)."""
279
314
  # some <app:...> node that has multiple values
280
315
  if value is None:
@@ -282,17 +317,11 @@ class GML32Renderer(OutputRenderer):
282
317
  if xsd_element.min_occurs:
283
318
  self._write(f'<{xsd_element.xml_name} xsi:nil="true"/>\n')
284
319
  else:
285
- # Render the tag multiple times
286
- if xsd_element.type.is_complex_type:
287
- # If the retrieved QuerySet was not filtered yet, do so now. This can't
288
- # be done in get_value() because the FeatureType is not known there.
289
- value = feature_type.filter_related_queryset(value)
290
-
291
320
  for item in value:
292
- self.write_xml_field(feature_type, xsd_element, value=item)
321
+ self.write_xml_field(projection, xsd_element, value=item)
293
322
 
294
323
  def write_xml_field(
295
- self, feature_type: FeatureType, xsd_element: XsdElement, value, extra_xmlns=""
324
+ self, projection: FeatureProjection, xsd_element: XsdElement, value, extra_xmlns=""
296
325
  ):
297
326
  """Write the value of a single field."""
298
327
  xml_name = xsd_element.xml_name
@@ -300,7 +329,7 @@ class GML32Renderer(OutputRenderer):
300
329
  self._write(f'<{xml_name} xsi:nil="true"{extra_xmlns}/>\n')
301
330
  elif xsd_element.type.is_complex_type:
302
331
  # Expanded foreign relation / dictionary
303
- self.write_xml_complex_type(feature_type, xsd_element, value, extra_xmlns=extra_xmlns)
332
+ self.write_xml_complex_type(projection, xsd_element, value, extra_xmlns=extra_xmlns)
304
333
  else:
305
334
  # As this is likely called 150.000 times during a request, this is optimized.
306
335
  # Avoided a separate call to _value_to_xml_string() and avoided isinstance() here.
@@ -324,16 +353,17 @@ class GML32Renderer(OutputRenderer):
324
353
 
325
354
  self._write(f"<{xml_name}{extra_xmlns}>{value}</{xml_name}>\n")
326
355
 
327
- def write_xml_complex_type(self, feature_type, xsd_element, value, extra_xmlns="") -> None:
356
+ def write_xml_complex_type(
357
+ self, projection: FeatureProjection, xsd_element: XsdElement, value, extra_xmlns=""
358
+ ) -> None:
328
359
  """Write a single field, that consists of sub elements"""
329
- xsd_type = cast(XsdComplexType, xsd_element.type)
330
360
  self._write(f"<{xsd_element.xml_name}{extra_xmlns}>\n")
331
- for sub_element in xsd_type.elements:
361
+ for sub_element in projection.xsd_child_nodes[xsd_element]:
332
362
  sub_value = sub_element.get_value(value)
333
363
  if sub_element.is_many:
334
- self.write_many(feature_type, sub_element, sub_value)
364
+ self.write_many(projection, sub_element, sub_value)
335
365
  else:
336
- self.write_xml_field(feature_type, sub_element, sub_value)
366
+ self.write_xml_field(projection, sub_element, sub_value)
337
367
  self._write(f"</{xsd_element.xml_name}>\n")
338
368
 
339
369
  def write_gml_field(
@@ -482,23 +512,22 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
482
512
  @classmethod
483
513
  def decorate_queryset(
484
514
  cls,
485
- feature_type: FeatureType,
515
+ projection: FeatureProjection,
486
516
  queryset: models.QuerySet,
487
517
  output_crs: CRS,
488
518
  **params,
489
519
  ):
490
520
  """Update the queryset to let the database render the GML output.
491
- This is far more efficient then GeoDjango's logic, which performs a
521
+ This is far more efficient than GeoDjango's logic, which performs a
492
522
  C-API call for every single coordinate of a geometry.
493
523
  """
494
- queryset = super().decorate_queryset(feature_type, queryset, output_crs, **params)
524
+ queryset = super().decorate_queryset(projection, queryset, output_crs, **params)
495
525
 
496
526
  # Retrieve geometries as pre-rendered instead.
497
- gml_elements = feature_type.xsd_type.geometry_elements
498
- geo_selects = get_db_geometry_selects(gml_elements, output_crs)
527
+ geo_selects = get_db_geometry_selects(projection.geometry_elements, output_crs)
499
528
  if geo_selects:
500
529
  queryset = queryset.defer(*geo_selects.keys()).annotate(
501
- _as_envelope_gml=cls.get_db_envelope_as_gml(feature_type, queryset, output_crs),
530
+ _as_envelope_gml=cls.get_db_envelope_as_gml(projection, queryset, output_crs),
502
531
  **build_db_annotations(geo_selects, "_as_gml_{name}", AsGML),
503
532
  )
504
533
 
@@ -507,56 +536,46 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
507
536
  @classmethod
508
537
  def get_prefetch_queryset(
509
538
  cls,
510
- feature_type: FeatureType,
539
+ projection: FeatureProjection,
511
540
  feature_relation: FeatureRelation,
512
541
  output_crs: CRS,
513
542
  ) -> models.QuerySet | None:
514
543
  """Perform DB annotations for prefetched relations too."""
515
- base = super().get_prefetch_queryset(feature_type, feature_relation, output_crs)
544
+ base = super().get_prefetch_queryset(projection, feature_relation, output_crs)
516
545
  if base is None:
517
546
  return None
518
547
 
519
548
  # Find which fields are GML elements
520
- gml_elements = []
521
- for e in feature_relation.xsd_elements:
522
- if e.is_geometry:
523
- # Prefetching a flattened relation
524
- gml_elements.append(e)
525
- elif e.type.is_complex_type:
526
- # Prefetching a complex type
527
- xsd_type: XsdComplexType = cast(XsdComplexType, e.type)
528
- gml_elements.extend(xsd_type.geometry_elements)
529
-
530
- geometries = get_db_geometry_selects(gml_elements, output_crs)
549
+ geometries = get_db_geometry_selects(feature_relation.geometry_elements, output_crs)
531
550
  if geometries:
532
551
  # Exclude geometries from the fields, fetch them as pre-rendered annotations instead.
533
- return base.defer(geometries.keys()).annotate(
552
+ return base.defer(*geometries.keys()).annotate(
534
553
  **build_db_annotations(geometries, "_as_gml_{name}", AsGML),
535
554
  )
536
555
  else:
537
556
  return base
538
557
 
539
558
  @classmethod
540
- def get_db_envelope_as_gml(cls, feature_type, queryset, output_crs) -> AsGML:
559
+ def get_db_envelope_as_gml(cls, projection: FeatureProjection, queryset, output_crs) -> AsGML:
541
560
  """Offload the GML rendering of the envelope to the database.
542
561
 
543
562
  This also avoids offloads the geometry union calculation to the DB.
544
563
  """
545
- geo_fields_union = cls._get_geometries_union(feature_type, queryset, output_crs)
564
+ geo_fields_union = cls._get_geometries_union(projection, queryset, output_crs)
546
565
  return AsGML(geo_fields_union, envelope=True)
547
566
 
548
567
  @classmethod
549
- def _get_geometries_union(cls, feature_type: FeatureType, queryset, output_crs):
568
+ def _get_geometries_union(cls, projection: FeatureProjection, queryset, output_crs):
550
569
  """Combine all geometries of the model in a single SQL function."""
551
570
  # Apply transforms where needed, in case some geometries use a different SRID.
552
571
  return get_geometries_union(
553
572
  [
554
573
  conditional_transform(
555
- model_field.name,
556
- model_field.srid,
574
+ gml_element.orm_path,
575
+ gml_element.source.srid,
557
576
  output_srid=output_crs.srid,
558
577
  )
559
- for model_field in feature_type.geometry_fields
578
+ for gml_element in projection.geometry_elements
560
579
  ],
561
580
  using=queryset.db,
562
581
  )
@@ -579,8 +598,8 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
579
598
  gml = self.render_gml_value(gml_id, value, extra_xmlns=extra_xmlns)
580
599
  self._write(f"<{xml_name}{extra_xmlns}>{gml}</{xml_name}>\n")
581
600
 
582
- def write_bounds(self, feature_type, instance) -> None:
583
- """Generate the <gml:boundedBy> from DB prerendering."""
601
+ def write_bounds(self, projection, instance) -> None:
602
+ """Generate the <gml:boundedBy> from DB pre-rendering."""
584
603
  gml = instance._as_envelope_gml
585
604
  if gml is not None:
586
605
  self._write(f"<gml:boundedBy>{gml}</gml:boundedBy>\n")
@@ -597,6 +616,7 @@ class GML32ValueRenderer(GML32Renderer):
597
616
  content_type = "text/xml; charset=utf-8"
598
617
  content_type_plain = "text/plain; charset=utf-8"
599
618
  xml_collection_tag = "ValueCollection"
619
+ xml_sub_collection_tag = "ValueCollection"
600
620
  _escape_value = staticmethod(_value_to_xml_string)
601
621
  gml_value_getter = itemgetter("member")
602
622
 
@@ -608,7 +628,7 @@ class GML32ValueRenderer(GML32Renderer):
608
628
  @classmethod
609
629
  def decorate_queryset(
610
630
  cls,
611
- feature_type: FeatureType,
631
+ projection: FeatureProjection,
612
632
  queryset: models.QuerySet,
613
633
  output_crs: CRS,
614
634
  **params,
@@ -634,15 +654,15 @@ class GML32ValueRenderer(GML32Renderer):
634
654
  self._write('<?xml version="1.0" encoding="UTF-8"?>\n')
635
655
 
636
656
  # Write the single tag, no <wfs:member> around it.
637
- self.write_feature(sub_collection.feature_type, instance, extra_xmlns=extra_xmlns)
657
+ self.write_feature(sub_collection.projection, instance, extra_xmlns=extra_xmlns)
638
658
 
639
- def write_feature(self, feature_type: FeatureType, instance: dict, extra_xmlns="") -> None:
659
+ def write_feature(self, projection: FeatureProjection, instance: dict, extra_xmlns="") -> None:
640
660
  """Write the XML for a single object.
641
661
  In this case, it's only a single XML tag.
642
662
  """
643
663
  if self.xsd_node.is_geometry:
644
664
  self.write_gml_field(
645
- feature_type,
665
+ projection.feature_type,
646
666
  cast(XsdElement, self.xsd_node),
647
667
  instance,
648
668
  extra_xmlns=extra_xmlns,
@@ -668,10 +688,10 @@ class GML32ValueRenderer(GML32Renderer):
668
688
  first = False
669
689
  elif self.xsd_node.type.is_complex_type:
670
690
  raise NotImplementedError("GetPropertyValue with complex types is not implemented")
671
- # self.write_xml_complex_type(feature_type, self.xsd_node, instance['member'])
691
+ # self.write_xml_complex_type(projection, self.xsd_node, instance['member'])
672
692
  else:
673
693
  self.write_xml_field(
674
- feature_type,
694
+ projection,
675
695
  cast(XsdElement, self.xsd_node),
676
696
  value=instance["member"],
677
697
  extra_xmlns=extra_xmlns,
@@ -699,14 +719,15 @@ class DBGML32ValueRenderer(DBGMLRenderingMixin, GML32ValueRenderer):
699
719
  gml_value_getter = itemgetter("gml_member")
700
720
 
701
721
  @classmethod
702
- def decorate_queryset(cls, feature_type: FeatureType, queryset, output_crs, **params):
722
+ def decorate_queryset(cls, projection: FeatureProjection, queryset, output_crs, **params):
703
723
  """Update the queryset to let the database render the GML output."""
704
724
  value_reference = params["valueReference"]
705
- match = feature_type.resolve_element(value_reference.xpath)
725
+ match = projection.feature_type.resolve_element(value_reference.xpath)
706
726
  if match.child.is_geometry:
707
727
  # Add 'gml_member' to point to the pre-rendered GML version.
728
+ gml_element = cast(XsdElement, match.child)
708
729
  return queryset.values(
709
- "pk", gml_member=AsGML(get_db_geometry_target(match, output_crs))
730
+ "pk", gml_member=AsGML(get_db_geometry_target(gml_element, output_crs))
710
731
  )
711
732
  else:
712
733
  return queryset
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import math
10
10
  import operator
11
+ import typing
11
12
  from collections.abc import Iterable
12
13
  from datetime import timezone
13
14
  from functools import cached_property, reduce
@@ -21,6 +22,9 @@ from gisserver.geometries import BoundingBox
21
22
 
22
23
  from .utils import ChunkedQuerySetIterator, CountingIterator
23
24
 
25
+ if typing.TYPE_CHECKING:
26
+ from gisserver.queries import FeatureProjection, QueryExpression
27
+
24
28
 
25
29
  class SimpleFeatureCollection:
26
30
  """Wrapper to read a result set.
@@ -31,15 +35,18 @@ class SimpleFeatureCollection:
31
35
 
32
36
  def __init__(
33
37
  self,
38
+ source_query: QueryExpression,
34
39
  feature_type: FeatureType,
35
40
  queryset: models.QuerySet,
36
41
  start: int,
37
42
  stop: int,
38
43
  ):
44
+ self.source_query = source_query
39
45
  self.feature_type = feature_type
40
46
  self.queryset = queryset
41
47
  self.start = start
42
48
  self.stop = stop
49
+
43
50
  self._result_cache = None
44
51
  self._result_iterator = None
45
52
  self._has_more = None
@@ -67,7 +74,7 @@ class SimpleFeatureCollection:
67
74
  def iterator(self):
68
75
  """Explicitly request the results to be streamed.
69
76
 
70
- This can be used by output formats that stream may results, and don't
77
+ This can be used by output formats that stream results, and don't
71
78
  access `number_returned`. Note this is not compatible with prefetch_related().
72
79
  """
73
80
  if self._result_iterator is not None:
@@ -170,7 +177,7 @@ class SimpleFeatureCollection:
170
177
  # When requesting the data after the fact, results are counted.
171
178
  return self._result_iterator.number_returned
172
179
  else:
173
- # Count by fetching all data. Otherwise the results are queried twice.
180
+ # Count by fetching all data. Otherwise, the results are queried twice.
174
181
  # For GML/XML, it's not possible the stream the queryset results
175
182
  # as the first tag needs to describe the number of results.
176
183
  self.fetch_results()
@@ -187,7 +194,7 @@ class SimpleFeatureCollection:
187
194
  qs = self.queryset
188
195
  clean_annotations = {
189
196
  # HACK: remove database optimizations from output renderer.
190
- # Otherwise it becomes SELECT COUNT(*) FROM (SELECT AsGML(..), ...)
197
+ # Otherwise, it becomes SELECT COUNT(*) FROM (SELECT AsGML(..), ...)
191
198
  key: value
192
199
  for key, value in qs.query.annotations.items()
193
200
  if not key.startswith("_as_")
@@ -229,11 +236,18 @@ class SimpleFeatureCollection:
229
236
  elif self._has_more is not None:
230
237
  return self._has_more # did page+1 record check, answer is known.
231
238
  elif self._is_surely_last_page:
232
- return False # Less results then expected, answer is known.
239
+ return False # Fewer results than expected, answer is known.
233
240
 
234
241
  # This will perform an slow COUNT() query...
235
242
  return self.stop < self.number_matched
236
243
 
244
+ @cached_property
245
+ def projection(self) -> FeatureProjection:
246
+ """Provide the projection to render these results with."""
247
+ # Note this attribute would technically be part of the 'query' object,
248
+ # but since the projection needs to be mapped per feature type, it's stored here for convenience.
249
+ return self.source_query.get_projection(self.feature_type)
250
+
237
251
  def get_bounding_box(self) -> BoundingBox:
238
252
  """Determine bounding box of all items."""
239
253
  self.fetch_results() # Avoid querying results twice
@@ -245,11 +259,11 @@ class SimpleFeatureCollection:
245
259
  self.feature_type.geometry_field.name
246
260
  ).child
247
261
  for instance in self:
248
- geomery_value = geometry_field.get_value(instance)
249
- if geomery_value is None:
262
+ geometry_value = geometry_field.get_value(instance)
263
+ if geometry_value is None:
250
264
  continue
251
265
 
252
- bbox.extend_to_geometry(geomery_value)
266
+ bbox.extend_to_geometry(geometry_value)
253
267
 
254
268
  return bbox
255
269
 
@@ -270,6 +284,7 @@ class FeatureCollection:
270
284
  previous: str | None = None,
271
285
  ):
272
286
  """
287
+ :param source_query: The query that generated this output.
273
288
  :param results: All retrieved feature collections (one per FeatureType)
274
289
  :param number_matched: Total number of features across all pages
275
290
  :param next: URL of the next page
@@ -305,7 +320,7 @@ class FeatureCollection:
305
320
  conf.GISSERVER_COUNT_NUMBER_MATCHED == 2 and self.results[0].start > 0
306
321
  ):
307
322
  # 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.
323
+ # Most clients don't need this metadata, and thus we avoid a COUNT query.
309
324
  return None
310
325
 
311
326
  return sum(c.number_matched for c in self.results)
gisserver/output/utils.py CHANGED
@@ -5,7 +5,7 @@ from collections.abc import Iterable
5
5
  from itertools import islice
6
6
  from typing import TypeVar
7
7
 
8
- from django.db import models
8
+ from django.db import connections, models
9
9
  from lru import LRU
10
10
 
11
11
  M = TypeVar("M", bound=models.Model)
@@ -89,7 +89,7 @@ class ChunkedQuerySetIterator(Iterable[M]):
89
89
  self._number_returned = 0
90
90
  self._in_iterator = True
91
91
  try:
92
- qs_iter = iter(self.queryset.iterator(chunk_size=self.sql_chunk_size))
92
+ qs_iter = iter(self._get_queryset_iterator())
93
93
 
94
94
  # Keep fetching chunks
95
95
  while True:
@@ -107,6 +107,22 @@ class ChunkedQuerySetIterator(Iterable[M]):
107
107
  finally:
108
108
  self._in_iterator = False
109
109
 
110
+ def _get_queryset_iterator(self) -> Iterable:
111
+ """The body of queryset.iterator(), while circumventing prefetching."""
112
+ # The old code did return `self.queryset.iterator(chunk_size=self.sql_chunk_size)`
113
+ # However, Django 4 supports using prefetch_related() with iterator() in that scenario.
114
+ #
115
+ # This code is the core of Django's QuerySet.iterator() that only produces the
116
+ # old-style iteration, without any prefetches. Those are added by this class instead.
117
+ use_chunked_fetch = not connections[self.queryset.db].settings_dict.get(
118
+ "DISABLE_SERVER_SIDE_CURSORS"
119
+ )
120
+ iterable = self.queryset._iterable_class(
121
+ self.queryset, chunked_fetch=use_chunked_fetch, chunk_size=self.sql_chunk_size
122
+ )
123
+
124
+ yield from iterable
125
+
110
126
  @property
111
127
  def number_returned(self) -> int:
112
128
  """Tell how many objects the iterator processed"""
@@ -73,7 +73,7 @@ class XMLSchemaRenderer(OutputRenderer):
73
73
 
74
74
  # Next, the complexType is rendered that defines the element contents.
75
75
  # Next, the complexType(s) are rendered that defines the element contents.
76
- # In case any fields are expanded (hence become sub-types), these are also included.
76
+ # In case any fields are expanded (hence become subtypes), these are also included.
77
77
  output.write(self.render_complex_type(xsd_type))
78
78
  for complex_type in self._get_complex_types(xsd_type):
79
79
  output.write(self.render_complex_type(complex_type))
gisserver/parsers/base.py CHANGED
@@ -65,7 +65,7 @@ class TagRegistry:
65
65
  self.parsers = {}
66
66
 
67
67
  def register(self, name=None, namespace=None, hidden=False):
68
- """Decorator to register an class as XML node parser.
68
+ """Decorator to register a class as XML node parser.
69
69
 
70
70
  This registers the decorated class as the designated parser
71
71
  for a specific XML tag.
@@ -116,7 +116,7 @@ class TagRegistry:
116
116
  """Convert the element into a Python class.
117
117
 
118
118
  This locates the parser from the registered tag.
119
- It's assumed that the tag has an "from_xml()" method too.
119
+ It's assumed that the tag has a "from_xml()" method too.
120
120
  """
121
121
  try:
122
122
  real_cls = self.resolve_class(element.tag)
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from gisserver.exceptions import OperationParsingFailed
4
+
1
5
  from .expressions import ValueReference
2
6
  from .filters import Filter
3
7
  from .functions import function_registry
@@ -22,3 +26,17 @@ def parse_resource_id_kvp(value) -> IdOperator:
22
26
  This returns an IdOperator, as it needs to support multiple pairs.
23
27
  """
24
28
  return IdOperator([ResourceId(rid) for rid in value.split(",")])
29
+
30
+
31
+ def parse_property_name(value) -> list[ValueReference] | None:
32
+ """Parse the PROPERTYNAME parameter"""
33
+ if not value or value == "*":
34
+ return None # WFS 1 logic
35
+
36
+ if "(" in value:
37
+ raise OperationParsingFailed(
38
+ "Parameter lists to perform multiple queries are not supported yet.",
39
+ locator="propertyname",
40
+ )
41
+
42
+ return [ValueReference(x) for x in value.split(",")]
@@ -49,8 +49,8 @@ OUTPUT_FIELDS = {
49
49
  class BinaryOperatorType(TagNameEnum):
50
50
  """FES 1.0 Arithmetic operators.
51
51
 
52
- This are no longer part of the FES 2.0 spec, but clients (like QGis)
53
- still assume the server supports these. Hence these need to be included.
52
+ These are no longer part of the FES 2.0 spec, but clients (like QGis)
53
+ still assume the server supports these. Hence, these need to be included.
54
54
  """
55
55
 
56
56
  Add = operator.add
@@ -100,7 +100,7 @@ class Literal(Expression):
100
100
 
101
101
  @cached_property
102
102
  def value(self) -> ParsedValue: # officially <xsd:any>
103
- """Access the value of the element, casted to the appropriate data type."""
103
+ """Access the value of the element, cast to the appropriate data type."""
104
104
  if not isinstance(self.raw_value, str):
105
105
  return self.raw_value # GML element or None
106
106
  elif self.type:
@@ -197,7 +197,7 @@ class ValueReference(Expression):
197
197
  return match.build_rhs(compiler)
198
198
 
199
199
  def parse_xpath(self, feature_type=None) -> ORMPath:
200
- """Convert the XPath into a the required ORM query elements."""
200
+ """Convert the XPath into the required ORM query elements."""
201
201
  if feature_type is not None:
202
202
  # Can resolve against XSD paths, find the correct DB field name
203
203
  return feature_type.resolve_element(self.xpath)
@@ -209,12 +209,7 @@ class ValueReference(Expression):
209
209
  @cached_property
210
210
  def element_name(self):
211
211
  """Tell which element this reference points to."""
212
- try:
213
- pos = self.xpath.rindex("/")
214
- except ValueError:
215
- return self.xpath
216
- else:
217
- return self.xpath[pos + 1 :]
212
+ return self.xpath.rpartition("/")[2]
218
213
 
219
214
 
220
215
  @dataclass
@@ -245,8 +240,8 @@ class Function(Expression):
245
240
  class BinaryOperator(Expression):
246
241
  """Support for FES 1.0 arithmetic operators.
247
242
 
248
- This are no longer part of the FES 2.0 spec, but clients (like QGis)
249
- still assume the server supports these. Hence these need to be included.
243
+ These are no longer part of the FES 2.0 spec, but clients (like QGis)
244
+ still assume the server supports these. Hence, these need to be included.
250
245
  """
251
246
 
252
247
  _operatorType: BinaryOperatorType
@@ -97,7 +97,7 @@ class FesFunctionRegistry:
97
97
  return _wrapper
98
98
 
99
99
  def resolve_function(self, function_name) -> FesFunction:
100
- """Resole the function using it's name."""
100
+ """Resole the function using its name."""
101
101
  try:
102
102
  return self.functions[function_name]
103
103
  except KeyError: