summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Wiesmann <daniel.wiesmann@gmail.com>2016-04-06 12:50:25 +0100
committerTim Graham <timograham@gmail.com>2016-04-09 09:22:30 -0400
commitc12a00e554c8b4b93931b520bb94a479c5ba8706 (patch)
tree235f37a8c8ce8e0e4cc2a8736bcc9b7b14244fdd
parentf6ca63a9f8b3d030097135e096c1041e09c29fd9 (diff)
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.
-rw-r--r--django/contrib/gis/db/backends/base/features.py4
-rw-r--r--django/contrib/gis/db/backends/base/operations.py8
-rw-r--r--django/contrib/gis/db/backends/mysql/operations.py2
-rw-r--r--django/contrib/gis/db/backends/oracle/operations.py2
-rw-r--r--django/contrib/gis/db/backends/postgis/operations.py3
-rw-r--r--django/contrib/gis/db/backends/spatialite/operations.py2
-rw-r--r--django/contrib/gis/db/models/fields.py2
-rw-r--r--django/contrib/gis/db/models/functions.py10
-rw-r--r--django/contrib/gis/db/models/lookups.py15
-rw-r--r--docs/ref/contrib/gis/functions.txt39
-rw-r--r--docs/ref/contrib/gis/geoquerysets.txt19
-rw-r--r--docs/releases/1.10.txt6
-rw-r--r--tests/gis_tests/geoapp/test_functions.py19
-rw-r--r--tests/gis_tests/geoapp/tests.py7
14 files changed, 121 insertions, 17 deletions
diff --git a/django/contrib/gis/db/backends/base/features.py b/django/contrib/gis/db/backends/base/features.py
index 027baa5575..451b515625 100644
--- a/django/contrib/gis/db/backends/base/features.py
+++ b/django/contrib/gis/db/backends/base/features.py
@@ -64,6 +64,10 @@ class BaseSpatialFeatures(object):
def supports_relate_lookup(self):
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
# `has_<name>_method` (defined in __init__) which accesses connection.ops
# to determine GIS method availability.
diff --git a/django/contrib/gis/db/backends/base/operations.py b/django/contrib/gis/db/backends/base/operations.py
index 5dafae760d..8fc51da84a 100644
--- a/django/contrib/gis/db/backends/base/operations.py
+++ b/django/contrib/gis/db/backends/base/operations.py
@@ -58,10 +58,10 @@ class BaseSpatialOperations(object):
unsupported_functions = {
'Area', 'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
'BoundingCircle', 'Centroid', 'Difference', 'Distance', 'Envelope',
- 'ForceRHR', 'GeoHash', 'Intersection', 'Length', 'MemSize', 'NumGeometries',
- 'NumPoints', 'Perimeter', 'PointOnSurface', 'Reverse', 'Scale',
- 'SnapToGrid', 'SymDifference', 'Transform', 'Translate',
- 'Union',
+ 'ForceRHR', 'GeoHash', 'Intersection', 'IsValid', 'Length', 'MakeValid',
+ 'MemSize', 'NumGeometries', 'NumPoints', 'Perimeter', 'PointOnSurface',
+ 'Reverse', 'Scale', 'SnapToGrid', 'SymDifference', 'Transform',
+ 'Translate', 'Union',
}
# Serialization
diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py
index 3aedc24040..df4ba36b49 100644
--- a/django/contrib/gis/db/backends/mysql/operations.py
+++ b/django/contrib/gis/db/backends/mysql/operations.py
@@ -67,7 +67,7 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations):
def unsupported_functions(self):
unsupported = {
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG', 'BoundingCircle',
- 'ForceRHR', 'GeoHash', 'MemSize',
+ 'ForceRHR', 'GeoHash', 'IsValid', 'MakeValid', 'MemSize',
'Perimeter', 'PointOnSurface', 'Reverse', 'Scale', 'SnapToGrid',
'Transform', 'Translate',
}
diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py
index 2404cf9bc4..2a166f23a1 100644
--- a/django/contrib/gis/db/backends/oracle/operations.py
+++ b/django/contrib/gis/db/backends/oracle/operations.py
@@ -127,7 +127,7 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations):
unsupported_functions = {
'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
'BoundingCircle', 'Envelope',
- 'ForceRHR', 'GeoHash', 'MemSize', 'Scale',
+ 'ForceRHR', 'GeoHash', 'IsValid', 'MakeValid', 'MemSize', 'Scale',
'SnapToGrid', 'Translate',
}
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index 4c3ba8ca50..89ddc11127 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -79,6 +79,7 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
'disjoint': PostGISOperator(func='ST_Disjoint'),
'equals': PostGISOperator(func='ST_Equals'),
'intersects': PostGISOperator(func='ST_Intersects', geography=True),
+ 'isvalid': PostGISOperator(func='ST_IsValid'),
'overlaps': PostGISOperator(func='ST_Overlaps'),
'relate': PostGISOperator(func='ST_Relate'),
'touches': PostGISOperator(func='ST_Touches'),
@@ -118,11 +119,13 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
self.geojson = prefix + 'AsGeoJson'
self.gml = prefix + 'AsGML'
self.intersection = prefix + 'Intersection'
+ self.isvalid = prefix + 'IsValid'
self.kml = prefix + 'AsKML'
self.length = prefix + 'Length'
self.length3d = prefix + '3DLength'
self.length_spheroid = prefix + 'length_spheroid'
self.makeline = prefix + 'MakeLine'
+ self.makevalid = prefix + 'MakeValid'
self.mem_size = prefix + 'mem_size'
self.num_geom = prefix + 'NumGeometries'
self.num_points = prefix + 'npoints'
diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py
index f551b67bc6..f2f516109a 100644
--- a/django/contrib/gis/db/backends/spatialite/operations.py
+++ b/django/contrib/gis/db/backends/spatialite/operations.py
@@ -96,7 +96,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
@cached_property
def unsupported_functions(self):
- unsupported = {'BoundingCircle', 'ForceRHR', 'MemSize'}
+ unsupported = {'BoundingCircle', 'ForceRHR', 'IsValid', 'MakeValid', 'MemSize'}
if self.spatial_version < (3, 1, 0):
unsupported.add('SnapToGrid')
if self.spatial_version < (4, 0, 0):
diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py
index 80fe8628ce..49d86f7618 100644
--- a/django/contrib/gis/db/models/fields.py
+++ b/django/contrib/gis/db/models/fields.py
@@ -225,7 +225,7 @@ class GeometryField(GeoSelectFormatMixin, BaseSpatialField):
returning to the caller.
"""
value = super(GeometryField, self).get_prep_value(value)
- if isinstance(value, Expression):
+ if isinstance(value, (Expression, bool)):
return value
elif isinstance(value, (tuple, list)):
geom = value[0]
diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py
index 4f76c155f2..7355bbcbfc 100644
--- a/django/contrib/gis/db/models/functions.py
+++ b/django/contrib/gis/db/models/functions.py
@@ -6,7 +6,7 @@ from django.contrib.gis.measure import (
Area as AreaMeasure, Distance as DistanceMeasure,
)
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.utils import six
@@ -282,6 +282,10 @@ class Intersection(OracleToleranceMixin, GeoFuncWithGeoParam):
arity = 2
+class IsValid(GeoFunc):
+ output_field_class = BooleanField
+
+
class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
output_field_class = FloatField
@@ -319,6 +323,10 @@ class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
return super(Length, self).as_sql(compiler, connection)
+class MakeValid(GeoFunc):
+ pass
+
+
class MemSize(GeoFunc):
output_field_class = IntegerField
arity = 1
diff --git a/django/contrib/gis/db/models/lookups.py b/django/contrib/gis/db/models/lookups.py
index a1c85b9bfe..158fb5cfa1 100644
--- a/django/contrib/gis/db/models/lookups.py
+++ b/django/contrib/gis/db/models/lookups.py
@@ -5,7 +5,7 @@ import re
from django.core.exceptions import FieldDoesNotExist
from django.db.models.constants import LOOKUP_SEP
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
gis_lookups = {}
@@ -270,6 +270,19 @@ class IntersectsLookup(GISLookup):
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):
lookup_name = 'overlaps'
gis_lookups['overlaps'] = OverlapsLookup
diff --git a/docs/ref/contrib/gis/functions.txt b/docs/ref/contrib/gis/functions.txt
index 04da9e78af..00dee42c57 100644
--- a/docs/ref/contrib/gis/functions.txt
+++ b/docs/ref/contrib/gis/functions.txt
@@ -25,13 +25,12 @@ Function's summary:
================== ======================= ====================== =================== ================== =====================
Measurement Relationships Operations Editors Output format Miscellaneous
================== ======================= ====================== =================== ================== =====================
-:class:`Area` :class:`BoundingCircle` :class:`Difference` :class:`ForceRHR` :class:`AsGeoJSON` :class:`MemSize`
-:class:`Distance` :class:`Centroid` :class:`Intersection` :class:`Reverse` :class:`AsGML` :class:`NumGeometries`
-:class:`Length` :class:`Envelope` :class:`SymDifference` :class:`Scale` :class:`AsKML` :class:`NumPoints`
-:class:`Perimeter` :class:`PointOnSurface` :class:`Union` :class:`SnapToGrid` :class:`AsSVG`
-
- :class:`Transform` :class:`GeoHash`
-
+:class:`Area` :class:`BoundingCircle` :class:`Difference` :class:`ForceRHR` :class:`AsGeoJSON` :class:`IsValid`
+:class:`Distance` :class:`Centroid` :class:`Intersection` :class:`MakeValid` :class:`AsGML` :class:`MemSize`
+:class:`Length` :class:`Envelope` :class:`SymDifference` :class:`Reverse` :class:`AsKML` :class:`NumGeometries`
+:class:`Perimeter` :class:`PointOnSurface` :class:`Union` :class:`Scale` :class:`AsSVG` :class:`NumPoints`
+ :class:`SnapToGrid` :class:`GeoHash`
+ :class:`Transform`
:class:`Translate`
================== ======================= ====================== =================== ================== =====================
@@ -291,6 +290,18 @@ intersection between them.
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``
==========
@@ -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
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``
===========
diff --git a/docs/ref/contrib/gis/geoquerysets.txt b/docs/ref/contrib/gis/geoquerysets.txt
index ed142d72b8..6b12c07a57 100644
--- a/docs/ref/contrib/gis/geoquerysets.txt
+++ b/docs/ref/contrib/gis/geoquerysets.txt
@@ -247,6 +247,25 @@ MySQL ``MBRIntersects(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
``overlaps``
diff --git a/docs/releases/1.10.txt b/docs/releases/1.10.txt
index 2e97ff3d54..490aa30fb7 100644
--- a/docs/releases/1.10.txt
+++ b/docs/releases/1.10.txt
@@ -147,6 +147,12 @@ Minor features
<django.contrib.gis.gdal.GDALBand.data>` method was added. Band data can
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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py
index 344fa03e61..c42ed95a86 100644
--- a/tests/gis_tests/geoapp/test_functions.py
+++ b/tests/gis_tests/geoapp/test_functions.py
@@ -233,6 +233,17 @@ class GISFunctionsTests(TestCase):
expected = c.mpoly.intersection(geom)
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")
def test_area_with_regular_aggregate(self):
# Create projected country objects, for this test to work on all backends.
@@ -249,6 +260,14 @@ class GISFunctionsTests(TestCase):
result = result.sq_m
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")
def test_memsize(self):
ptown = City.objects.annotate(size=functions.MemSize('point')).get(name='Pueblo')
diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py
index f0b6f7ba65..2b215f80a9 100644
--- a/tests/gis_tests/geoapp/tests.py
+++ b/tests/gis_tests/geoapp/tests.py
@@ -300,6 +300,13 @@ class GeoLookupTest(TestCase):
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")
def test_left_right_lookups(self):
"Testing the 'left' and 'right' lookup types."