Fixed #26455 -- Allowed filtering and repairing invalid geometries.
Added the IsValid and MakeValid database functions, and the isvalid lookup, all for PostGIS. Thanks Tim Graham for the review.
This commit is contained in:
parent
f6ca63a9f8
commit
c12a00e554
|
@ -64,6 +64,10 @@ class BaseSpatialFeatures(object):
|
||||||
def supports_relate_lookup(self):
|
def supports_relate_lookup(self):
|
||||||
return 'relate' in self.connection.ops.gis_operators
|
return 'relate' in self.connection.ops.gis_operators
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_isvalid_lookup(self):
|
||||||
|
return 'isvalid' in self.connection.ops.gis_operators
|
||||||
|
|
||||||
# For each of those methods, the class will have a property named
|
# For each of those methods, the class will have a property named
|
||||||
# `has_<name>_method` (defined in __init__) which accesses connection.ops
|
# `has_<name>_method` (defined in __init__) which accesses connection.ops
|
||||||
# to determine GIS method availability.
|
# to determine GIS method availability.
|
||||||
|
|
|
@ -58,10 +58,10 @@ class BaseSpatialOperations(object):
|
||||||
unsupported_functions = {
|
unsupported_functions = {
|
||||||
'Area', 'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
|
'Area', 'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
|
||||||
'BoundingCircle', 'Centroid', 'Difference', 'Distance', 'Envelope',
|
'BoundingCircle', 'Centroid', 'Difference', 'Distance', 'Envelope',
|
||||||
'ForceRHR', 'GeoHash', 'Intersection', 'Length', 'MemSize', 'NumGeometries',
|
'ForceRHR', 'GeoHash', 'Intersection', 'IsValid', 'Length', 'MakeValid',
|
||||||
'NumPoints', 'Perimeter', 'PointOnSurface', 'Reverse', 'Scale',
|
'MemSize', 'NumGeometries', 'NumPoints', 'Perimeter', 'PointOnSurface',
|
||||||
'SnapToGrid', 'SymDifference', 'Transform', 'Translate',
|
'Reverse', 'Scale', 'SnapToGrid', 'SymDifference', 'Transform',
|
||||||
'Union',
|
'Translate', 'Union',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
|
|
|
@ -67,7 +67,7 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations):
|
||||||
def unsupported_functions(self):
|
def unsupported_functions(self):
|
||||||
unsupported = {
|
unsupported = {
|
||||||
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG', 'BoundingCircle',
|
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG', 'BoundingCircle',
|
||||||
'ForceRHR', 'GeoHash', 'MemSize',
|
'ForceRHR', 'GeoHash', 'IsValid', 'MakeValid', 'MemSize',
|
||||||
'Perimeter', 'PointOnSurface', 'Reverse', 'Scale', 'SnapToGrid',
|
'Perimeter', 'PointOnSurface', 'Reverse', 'Scale', 'SnapToGrid',
|
||||||
'Transform', 'Translate',
|
'Transform', 'Translate',
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,7 +127,7 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations):
|
||||||
unsupported_functions = {
|
unsupported_functions = {
|
||||||
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
|
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
|
||||||
'BoundingCircle', 'Envelope',
|
'BoundingCircle', 'Envelope',
|
||||||
'ForceRHR', 'GeoHash', 'MemSize', 'Scale',
|
'ForceRHR', 'GeoHash', 'IsValid', 'MakeValid', 'MemSize', 'Scale',
|
||||||
'SnapToGrid', 'Translate',
|
'SnapToGrid', 'Translate',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -79,6 +79,7 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
|
||||||
'disjoint': PostGISOperator(func='ST_Disjoint'),
|
'disjoint': PostGISOperator(func='ST_Disjoint'),
|
||||||
'equals': PostGISOperator(func='ST_Equals'),
|
'equals': PostGISOperator(func='ST_Equals'),
|
||||||
'intersects': PostGISOperator(func='ST_Intersects', geography=True),
|
'intersects': PostGISOperator(func='ST_Intersects', geography=True),
|
||||||
|
'isvalid': PostGISOperator(func='ST_IsValid'),
|
||||||
'overlaps': PostGISOperator(func='ST_Overlaps'),
|
'overlaps': PostGISOperator(func='ST_Overlaps'),
|
||||||
'relate': PostGISOperator(func='ST_Relate'),
|
'relate': PostGISOperator(func='ST_Relate'),
|
||||||
'touches': PostGISOperator(func='ST_Touches'),
|
'touches': PostGISOperator(func='ST_Touches'),
|
||||||
|
@ -118,11 +119,13 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
|
||||||
self.geojson = prefix + 'AsGeoJson'
|
self.geojson = prefix + 'AsGeoJson'
|
||||||
self.gml = prefix + 'AsGML'
|
self.gml = prefix + 'AsGML'
|
||||||
self.intersection = prefix + 'Intersection'
|
self.intersection = prefix + 'Intersection'
|
||||||
|
self.isvalid = prefix + 'IsValid'
|
||||||
self.kml = prefix + 'AsKML'
|
self.kml = prefix + 'AsKML'
|
||||||
self.length = prefix + 'Length'
|
self.length = prefix + 'Length'
|
||||||
self.length3d = prefix + '3DLength'
|
self.length3d = prefix + '3DLength'
|
||||||
self.length_spheroid = prefix + 'length_spheroid'
|
self.length_spheroid = prefix + 'length_spheroid'
|
||||||
self.makeline = prefix + 'MakeLine'
|
self.makeline = prefix + 'MakeLine'
|
||||||
|
self.makevalid = prefix + 'MakeValid'
|
||||||
self.mem_size = prefix + 'mem_size'
|
self.mem_size = prefix + 'mem_size'
|
||||||
self.num_geom = prefix + 'NumGeometries'
|
self.num_geom = prefix + 'NumGeometries'
|
||||||
self.num_points = prefix + 'npoints'
|
self.num_points = prefix + 'npoints'
|
||||||
|
|
|
@ -96,7 +96,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def unsupported_functions(self):
|
def unsupported_functions(self):
|
||||||
unsupported = {'BoundingCircle', 'ForceRHR', 'MemSize'}
|
unsupported = {'BoundingCircle', 'ForceRHR', 'IsValid', 'MakeValid', 'MemSize'}
|
||||||
if self.spatial_version < (3, 1, 0):
|
if self.spatial_version < (3, 1, 0):
|
||||||
unsupported.add('SnapToGrid')
|
unsupported.add('SnapToGrid')
|
||||||
if self.spatial_version < (4, 0, 0):
|
if self.spatial_version < (4, 0, 0):
|
||||||
|
|
|
@ -225,7 +225,7 @@ class GeometryField(GeoSelectFormatMixin, BaseSpatialField):
|
||||||
returning to the caller.
|
returning to the caller.
|
||||||
"""
|
"""
|
||||||
value = super(GeometryField, self).get_prep_value(value)
|
value = super(GeometryField, self).get_prep_value(value)
|
||||||
if isinstance(value, Expression):
|
if isinstance(value, (Expression, bool)):
|
||||||
return value
|
return value
|
||||||
elif isinstance(value, (tuple, list)):
|
elif isinstance(value, (tuple, list)):
|
||||||
geom = value[0]
|
geom = value[0]
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.contrib.gis.measure import (
|
||||||
Area as AreaMeasure, Distance as DistanceMeasure,
|
Area as AreaMeasure, Distance as DistanceMeasure,
|
||||||
)
|
)
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.db.models import FloatField, IntegerField, TextField
|
from django.db.models import BooleanField, FloatField, IntegerField, TextField
|
||||||
from django.db.models.expressions import Func, Value
|
from django.db.models.expressions import Func, Value
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
|
||||||
|
@ -282,6 +282,10 @@ class Intersection(OracleToleranceMixin, GeoFuncWithGeoParam):
|
||||||
arity = 2
|
arity = 2
|
||||||
|
|
||||||
|
|
||||||
|
class IsValid(GeoFunc):
|
||||||
|
output_field_class = BooleanField
|
||||||
|
|
||||||
|
|
||||||
class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
|
class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
|
||||||
output_field_class = FloatField
|
output_field_class = FloatField
|
||||||
|
|
||||||
|
@ -319,6 +323,10 @@ class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
|
||||||
return super(Length, self).as_sql(compiler, connection)
|
return super(Length, self).as_sql(compiler, connection)
|
||||||
|
|
||||||
|
|
||||||
|
class MakeValid(GeoFunc):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MemSize(GeoFunc):
|
class MemSize(GeoFunc):
|
||||||
output_field_class = IntegerField
|
output_field_class = IntegerField
|
||||||
arity = 1
|
arity = 1
|
||||||
|
|
|
@ -5,7 +5,7 @@ import re
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.db.models.constants import LOOKUP_SEP
|
from django.db.models.constants import LOOKUP_SEP
|
||||||
from django.db.models.expressions import Col, Expression
|
from django.db.models.expressions import Col, Expression
|
||||||
from django.db.models.lookups import Lookup
|
from django.db.models.lookups import BuiltinLookup, Lookup
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
|
||||||
gis_lookups = {}
|
gis_lookups = {}
|
||||||
|
@ -270,6 +270,19 @@ class IntersectsLookup(GISLookup):
|
||||||
gis_lookups['intersects'] = IntersectsLookup
|
gis_lookups['intersects'] = IntersectsLookup
|
||||||
|
|
||||||
|
|
||||||
|
class IsValidLookup(BuiltinLookup):
|
||||||
|
lookup_name = 'isvalid'
|
||||||
|
|
||||||
|
def as_sql(self, compiler, connection):
|
||||||
|
gis_op = connection.ops.gis_operators[self.lookup_name]
|
||||||
|
sql, params = self.process_lhs(compiler, connection)
|
||||||
|
sql = '%(func)s(%(lhs)s)' % {'func': gis_op.func, 'lhs': sql}
|
||||||
|
if not self.rhs:
|
||||||
|
sql = 'NOT ' + sql
|
||||||
|
return sql, params
|
||||||
|
gis_lookups['isvalid'] = IsValidLookup
|
||||||
|
|
||||||
|
|
||||||
class OverlapsLookup(GISLookup):
|
class OverlapsLookup(GISLookup):
|
||||||
lookup_name = 'overlaps'
|
lookup_name = 'overlaps'
|
||||||
gis_lookups['overlaps'] = OverlapsLookup
|
gis_lookups['overlaps'] = OverlapsLookup
|
||||||
|
|
|
@ -25,13 +25,12 @@ Function's summary:
|
||||||
================== ======================= ====================== =================== ================== =====================
|
================== ======================= ====================== =================== ================== =====================
|
||||||
Measurement Relationships Operations Editors Output format Miscellaneous
|
Measurement Relationships Operations Editors Output format Miscellaneous
|
||||||
================== ======================= ====================== =================== ================== =====================
|
================== ======================= ====================== =================== ================== =====================
|
||||||
:class:`Area` :class:`BoundingCircle` :class:`Difference` :class:`ForceRHR` :class:`AsGeoJSON` :class:`MemSize`
|
:class:`Area` :class:`BoundingCircle` :class:`Difference` :class:`ForceRHR` :class:`AsGeoJSON` :class:`IsValid`
|
||||||
:class:`Distance` :class:`Centroid` :class:`Intersection` :class:`Reverse` :class:`AsGML` :class:`NumGeometries`
|
:class:`Distance` :class:`Centroid` :class:`Intersection` :class:`MakeValid` :class:`AsGML` :class:`MemSize`
|
||||||
:class:`Length` :class:`Envelope` :class:`SymDifference` :class:`Scale` :class:`AsKML` :class:`NumPoints`
|
:class:`Length` :class:`Envelope` :class:`SymDifference` :class:`Reverse` :class:`AsKML` :class:`NumGeometries`
|
||||||
:class:`Perimeter` :class:`PointOnSurface` :class:`Union` :class:`SnapToGrid` :class:`AsSVG`
|
:class:`Perimeter` :class:`PointOnSurface` :class:`Union` :class:`Scale` :class:`AsSVG` :class:`NumPoints`
|
||||||
|
:class:`SnapToGrid` :class:`GeoHash`
|
||||||
:class:`Transform` :class:`GeoHash`
|
:class:`Transform`
|
||||||
|
|
||||||
:class:`Translate`
|
:class:`Translate`
|
||||||
================== ======================= ====================== =================== ================== =====================
|
================== ======================= ====================== =================== ================== =====================
|
||||||
|
|
||||||
|
@ -291,6 +290,18 @@ intersection between them.
|
||||||
|
|
||||||
MySQL support was added.
|
MySQL support was added.
|
||||||
|
|
||||||
|
``IsValid``
|
||||||
|
===========
|
||||||
|
|
||||||
|
.. class:: IsValid(expr)
|
||||||
|
|
||||||
|
.. versionadded:: 1.10
|
||||||
|
|
||||||
|
*Availability*: PostGIS
|
||||||
|
|
||||||
|
Accepts a geographic field or expression and tests if the value is well formed.
|
||||||
|
Returns ``True`` if its value is a valid geometry and ``False`` otherwise.
|
||||||
|
|
||||||
``Length``
|
``Length``
|
||||||
==========
|
==========
|
||||||
|
|
||||||
|
@ -308,6 +319,20 @@ specify if the calculation should be based on a simple sphere (less
|
||||||
accurate, less resource-intensive) or on a spheroid (more accurate, more
|
accurate, less resource-intensive) or on a spheroid (more accurate, more
|
||||||
resource-intensive) with the ``spheroid`` keyword argument.
|
resource-intensive) with the ``spheroid`` keyword argument.
|
||||||
|
|
||||||
|
``MakeValid``
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. class:: MakeValid(expr)
|
||||||
|
|
||||||
|
.. versionadded:: 1.10
|
||||||
|
|
||||||
|
*Availability*: PostGIS
|
||||||
|
|
||||||
|
Accepts a geographic field or expression and attempts to convert the value into
|
||||||
|
a valid geometry without losing any of the input vertices. Geometries that are
|
||||||
|
already valid are returned without changes. Simple polygons might become a
|
||||||
|
multipolygon and the result might be of lower dimension than the input.
|
||||||
|
|
||||||
``MemSize``
|
``MemSize``
|
||||||
===========
|
===========
|
||||||
|
|
||||||
|
|
|
@ -247,6 +247,25 @@ MySQL ``MBRIntersects(poly, geom)``
|
||||||
SpatiaLite ``Intersects(poly, geom)``
|
SpatiaLite ``Intersects(poly, geom)``
|
||||||
========== =================================================
|
========== =================================================
|
||||||
|
|
||||||
|
.. fieldlookup:: isvalid
|
||||||
|
|
||||||
|
``isvalid``
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. versionadded:: 1.10
|
||||||
|
|
||||||
|
*Availability*: PostGIS
|
||||||
|
|
||||||
|
Tests if the geometry is valid.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
Zipcode.objects.filter(poly__isvalid=True)
|
||||||
|
|
||||||
|
PostGIS equivalent::
|
||||||
|
|
||||||
|
SELECT ... WHERE ST_IsValid(poly)
|
||||||
|
|
||||||
.. fieldlookup:: overlaps
|
.. fieldlookup:: overlaps
|
||||||
|
|
||||||
``overlaps``
|
``overlaps``
|
||||||
|
|
|
@ -147,6 +147,12 @@ Minor features
|
||||||
<django.contrib.gis.gdal.GDALBand.data>` method was added. Band data can
|
<django.contrib.gis.gdal.GDALBand.data>` method was added. Band data can
|
||||||
now be updated with repeated values efficiently.
|
now be updated with repeated values efficiently.
|
||||||
|
|
||||||
|
* Added database functions
|
||||||
|
:class:`~django.contrib.gis.db.models.functions.IsValid` and
|
||||||
|
:class:`~django.contrib.gis.db.models.functions.MakeValid`, as well as the
|
||||||
|
:lookup:`isvalid` lookup, all for PostGIS. This allows filtering and
|
||||||
|
repairing invalid geometries on the database side.
|
||||||
|
|
||||||
:mod:`django.contrib.messages`
|
:mod:`django.contrib.messages`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -233,6 +233,17 @@ class GISFunctionsTests(TestCase):
|
||||||
expected = c.mpoly.intersection(geom)
|
expected = c.mpoly.intersection(geom)
|
||||||
self.assertEqual(c.inter, expected)
|
self.assertEqual(c.inter, expected)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("has_IsValid_function")
|
||||||
|
def test_isvalid(self):
|
||||||
|
valid_geom = fromstr('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')
|
||||||
|
invalid_geom = fromstr('POLYGON((0 0, 0 1, 1 1, 1 0, 1 1, 1 0, 0 0))')
|
||||||
|
State.objects.create(name='valid', poly=valid_geom)
|
||||||
|
State.objects.create(name='invalid', poly=invalid_geom)
|
||||||
|
valid = State.objects.filter(name='valid').annotate(isvalid=functions.IsValid('poly')).first()
|
||||||
|
invalid = State.objects.filter(name='invalid').annotate(isvalid=functions.IsValid('poly')).first()
|
||||||
|
self.assertEqual(valid.isvalid, True)
|
||||||
|
self.assertEqual(invalid.isvalid, False)
|
||||||
|
|
||||||
@skipUnlessDBFeature("has_Area_function")
|
@skipUnlessDBFeature("has_Area_function")
|
||||||
def test_area_with_regular_aggregate(self):
|
def test_area_with_regular_aggregate(self):
|
||||||
# Create projected country objects, for this test to work on all backends.
|
# Create projected country objects, for this test to work on all backends.
|
||||||
|
@ -249,6 +260,14 @@ class GISFunctionsTests(TestCase):
|
||||||
result = result.sq_m
|
result = result.sq_m
|
||||||
self.assertAlmostEqual((result - c.mpoly.area) / c.mpoly.area, 0)
|
self.assertAlmostEqual((result - c.mpoly.area) / c.mpoly.area, 0)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("has_MakeValid_function")
|
||||||
|
def test_make_valid(self):
|
||||||
|
invalid_geom = fromstr('POLYGON((0 0, 0 1, 1 1, 1 0, 1 1, 1 0, 0 0))')
|
||||||
|
State.objects.create(name='invalid', poly=invalid_geom)
|
||||||
|
invalid = State.objects.filter(name='invalid').annotate(repaired=functions.MakeValid('poly')).first()
|
||||||
|
self.assertEqual(invalid.repaired.valid, True)
|
||||||
|
self.assertEqual(invalid.repaired, fromstr('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'))
|
||||||
|
|
||||||
@skipUnlessDBFeature("has_MemSize_function")
|
@skipUnlessDBFeature("has_MemSize_function")
|
||||||
def test_memsize(self):
|
def test_memsize(self):
|
||||||
ptown = City.objects.annotate(size=functions.MemSize('point')).get(name='Pueblo')
|
ptown = City.objects.annotate(size=functions.MemSize('point')).get(name='Pueblo')
|
||||||
|
|
|
@ -300,6 +300,13 @@ class GeoLookupTest(TestCase):
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature("supports_isvalid_lookup")
|
||||||
|
def test_isvalid_lookup(self):
|
||||||
|
invalid_geom = fromstr('POLYGON((0 0, 0 1, 1 1, 1 0, 1 1, 1 0, 0 0))')
|
||||||
|
State.objects.create(name='invalid', poly=invalid_geom)
|
||||||
|
self.assertEqual(State.objects.filter(poly__isvalid=False).count(), 1)
|
||||||
|
self.assertEqual(State.objects.filter(poly__isvalid=True).count(), State.objects.count() - 1)
|
||||||
|
|
||||||
@skipUnlessDBFeature("supports_left_right_lookups")
|
@skipUnlessDBFeature("supports_left_right_lookups")
|
||||||
def test_left_right_lookups(self):
|
def test_left_right_lookups(self):
|
||||||
"Testing the 'left' and 'right' lookup types."
|
"Testing the 'left' and 'right' lookup types."
|
||||||
|
|
Loading…
Reference in New Issue