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:
Daniel Wiesmann 2016-04-06 12:50:25 +01:00 committed by Tim Graham
parent f6ca63a9f8
commit c12a00e554
14 changed files with 121 additions and 17 deletions

View File

@ -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.

View File

@ -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

View File

@ -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',
} }

View File

@ -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',
} }

View File

@ -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'

View File

@ -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):

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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``
=========== ===========

View File

@ -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``

View File

@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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')

View File

@ -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."