From fcf494b48fea7c0c55ea29721ba0b2d250351ff8 Mon Sep 17 00:00:00 2001 From: Jani Tiainen Date: Sun, 20 Sep 2015 13:41:25 +0300 Subject: [PATCH] Fixed #24688 -- Added Oracle support for new-style GIS functions. --- .../contrib/gis/db/backends/oracle/adapter.py | 47 ++++++++++++ .../gis/db/backends/oracle/operations.py | 25 ++++++- django/contrib/gis/db/models/functions.py | 71 ++++++++++++------- .../contrib/gis/db/models/sql/conversion.py | 3 + tests/gis_tests/geoapp/models.py | 8 +++ tests/gis_tests/geoapp/tests.py | 2 +- 6 files changed, 128 insertions(+), 28 deletions(-) diff --git a/django/contrib/gis/db/backends/oracle/adapter.py b/django/contrib/gis/db/backends/oracle/adapter.py index 60961af817..11eb0424aa 100644 --- a/django/contrib/gis/db/backends/oracle/adapter.py +++ b/django/contrib/gis/db/backends/oracle/adapter.py @@ -1,7 +1,54 @@ from cx_Oracle import CLOB from django.contrib.gis.db.backends.base.adapter import WKTAdapter +from django.contrib.gis.geos import GeometryCollection, Polygon +from django.utils.six.moves import range class OracleSpatialAdapter(WKTAdapter): input_size = CLOB + + def __init__(self, geom): + """ + Oracle requires that polygon rings are in proper orientation. This + affects spatial operations and an invalid orientation may cause + failures. Correct orientations are: + * Outer ring - counter clockwise + * Inner ring(s) - clockwise + """ + if isinstance(geom, Polygon): + self._fix_polygon(geom) + elif isinstance(geom, GeometryCollection): + self._fix_geometry_collection(geom) + + self.wkt = geom.wkt + self.srid = geom.srid + + def _fix_polygon(self, poly): + # Fix single polygon orientation as described in __init__() + if self._isClockwise(poly.exterior_ring): + poly.exterior_ring = list(reversed(poly.exterior_ring)) + + for i in range(1, len(poly)): + if not self._isClockwise(poly[i]): + poly[i] = list(reversed(poly[i])) + + return poly + + def _fix_geometry_collection(self, coll): + # Fix polygon orientations in geometry collections as described in + # __init__() + for i, geom in enumerate(coll): + if isinstance(geom, Polygon): + coll[i] = self._fix_polygon(geom) + + def _isClockwise(self, coords): + # A modified shoelace algorithm to determine polygon orientation. + # See https://en.wikipedia.org/wiki/Shoelace_formula + n = len(coords) + area = 0.0 + for i in range(n): + j = (i + 1) % n + area += coords[i][0] * coords[j][1] + area -= coords[j][0] * coords[i][1] + return area < 0.0 diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index 8709202cd2..30e87e63a1 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -70,7 +70,6 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations): extent = 'SDO_AGGR_MBR' intersection = 'SDO_GEOM.SDO_INTERSECTION' length = 'SDO_GEOM.SDO_LENGTH' - num_geom = 'SDO_UTIL.GETNUMELEM' num_points = 'SDO_UTIL.GETNUMVERTICES' perimeter = length point_on_surface = 'SDO_GEOM.SDO_POINTONSURFACE' @@ -80,6 +79,23 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations): union = 'SDO_GEOM.SDO_UNION' unionagg = 'SDO_AGGR_UNION' + function_names = { + 'Area': 'SDO_GEOM.SDO_AREA', + 'Centroid': 'SDO_GEOM.SDO_CENTROID', + 'Difference': 'SDO_GEOM.SDO_DIFFERENCE', + 'Distance': 'SDO_GEOM.SDO_DISTANCE', + 'Intersection': 'SDO_GEOM.SDO_INTERSECTION', + 'Length': 'SDO_GEOM.SDO_LENGTH', + 'NumGeometries': 'SDO_UTIL.GETNUMELEM', + 'NumPoints': 'SDO_UTIL.GETNUMVERTICES', + 'Perimeter': 'SDO_GEOM.SDO_LENGTH', + 'PointOnSurface': 'SDO_GEOM.SDO_POINTONSURFACE', + 'Reverse': 'SDO_UTIL.REVERSE_LINESTRING', + 'SymDifference': 'SDO_GEOM.SDO_XOR', + 'Transform': 'SDO_CS.TRANSFORM', + 'Union': 'SDO_GEOM.SDO_UNION', + } + # We want to get SDO Geometries as WKT because it is much easier to # instantiate GEOS proxies from WKT than SDO_GEOMETRY(...) strings. # However, this adversely affects performance (i.e., Java is called @@ -109,6 +125,13 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations): truncate_params = {'relate': None} + unsupported_functions = { + 'AsGeoHash', 'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG', + 'BoundingCircle', 'Envelope', + 'ForceRHR', 'MemSize', 'Scale', + 'SnapToGrid', 'Translate', 'GeoHash', + } + def geo_quote_name(self, name): return super(OracleOperations, self).geo_quote_name(name).upper() diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py index 792edac929..55324a8eaa 100644 --- a/django/contrib/gis/db/models/functions.py +++ b/django/contrib/gis/db/models/functions.py @@ -85,6 +85,9 @@ class GeomValue(Value): def as_sqlite(self, compiler, connection): return 'GeomFromText(%%s, %s)' % self.srid, [connection.ops.Adapter(self.value)] + def as_oracle(self, compiler, connection): + return 'SDO_GEOMETRY(%%s, %s)' % self.srid, [connection.ops.Adapter(self.value)] + class GeoFuncWithGeoParam(GeoFunc): def __init__(self, expression, geom, *expressions, **extra): @@ -112,27 +115,38 @@ class SQLiteDecimalToFloatMixin(object): return super(SQLiteDecimalToFloatMixin, self).as_sql(compiler, connection) -class Area(GeoFunc): +class OracleToleranceMixin(object): + tolerance = 0.05 + + def as_oracle(self, compiler, connection): + tol = self.extra.get('tolerance', self.tolerance) + self.template = "%%(function)s(%%(expressions)s, %s)" % tol + return super(OracleToleranceMixin, self).as_sql(compiler, connection) + + +class Area(OracleToleranceMixin, GeoFunc): def as_sql(self, compiler, connection): - if connection.ops.oracle: - self.output_field = AreaField('sq_m') # Oracle returns area in units of meters. - else: - if connection.ops.geography: - # Geography fields support area calculation, returns square meters. - self.output_field = AreaField('sq_m') - elif not self.output_field.geodetic(connection): - # Getting the area units of the geographic field. - units = self.output_field.units_name(connection) - if units: - self.output_field = AreaField( - AreaMeasure.unit_attname(self.output_field.units_name(connection))) - else: - self.output_field = FloatField() + if connection.ops.geography: + # Geography fields support area calculation, returns square meters. + self.output_field = AreaField('sq_m') + elif not self.output_field.geodetic(connection): + # Getting the area units of the geographic field. + units = self.output_field.units_name(connection) + if units: + self.output_field = AreaField( + AreaMeasure.unit_attname(self.output_field.units_name(connection)) + ) else: - # TODO: Do we want to support raw number areas for geodetic fields? - raise NotImplementedError('Area on geodetic coordinate systems not supported.') + self.output_field = FloatField() + else: + # TODO: Do we want to support raw number areas for geodetic fields? + raise NotImplementedError('Area on geodetic coordinate systems not supported.') return super(Area, self).as_sql(compiler, connection) + def as_oracle(self, compiler, connection): + self.output_field = AreaField('sq_m') # Oracle returns area in units of meters. + return super(Area, self).as_oracle(compiler, connection) + class AsGeoJSON(GeoFunc): output_field_class = TextField @@ -189,11 +203,11 @@ class BoundingCircle(GeoFunc): super(BoundingCircle, self).__init__(*[expression, num_seg], **extra) -class Centroid(GeoFunc): +class Centroid(OracleToleranceMixin, GeoFunc): pass -class Difference(GeoFuncWithGeoParam): +class Difference(OracleToleranceMixin, GeoFuncWithGeoParam): pass @@ -215,7 +229,7 @@ class DistanceResultMixin(object): return value -class Distance(DistanceResultMixin, GeoFuncWithGeoParam): +class Distance(DistanceResultMixin, OracleToleranceMixin, GeoFuncWithGeoParam): output_field_class = FloatField spheroid = None @@ -246,6 +260,11 @@ class Distance(DistanceResultMixin, GeoFuncWithGeoParam): self.function = 'ST_Distance_Sphere' return super(Distance, self).as_sql(compiler, connection) + def as_oracle(self, compiler, connection): + if self.spheroid: + self.source_expressions.pop(2) + return super(Distance, self).as_oracle(compiler, connection) + class Envelope(GeoFunc): pass @@ -265,11 +284,11 @@ class GeoHash(GeoFunc): super(GeoHash, self).__init__(*expressions, **extra) -class Intersection(GeoFuncWithGeoParam): +class Intersection(OracleToleranceMixin, GeoFuncWithGeoParam): pass -class Length(DistanceResultMixin, GeoFunc): +class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc): output_field_class = FloatField def __init__(self, expr1, spheroid=True, **extra): @@ -325,7 +344,7 @@ class NumPoints(GeoFunc): return super(NumPoints, self).as_sql(compiler, connection) -class Perimeter(DistanceResultMixin, GeoFunc): +class Perimeter(DistanceResultMixin, OracleToleranceMixin, GeoFunc): output_field_class = FloatField def as_postgresql(self, compiler, connection): @@ -335,7 +354,7 @@ class Perimeter(DistanceResultMixin, GeoFunc): return super(Perimeter, self).as_sql(compiler, connection) -class PointOnSurface(GeoFunc): +class PointOnSurface(OracleToleranceMixin, GeoFunc): pass @@ -376,7 +395,7 @@ class SnapToGrid(SQLiteDecimalToFloatMixin, GeoFunc): super(SnapToGrid, self).__init__(*expressions, **extra) -class SymDifference(GeoFuncWithGeoParam): +class SymDifference(OracleToleranceMixin, GeoFuncWithGeoParam): pass @@ -412,5 +431,5 @@ class Translate(Scale): return super(Translate, self).as_sqlite(compiler, connection) -class Union(GeoFuncWithGeoParam): +class Union(OracleToleranceMixin, GeoFuncWithGeoParam): pass diff --git a/django/contrib/gis/db/models/sql/conversion.py b/django/contrib/gis/db/models/sql/conversion.py index 28e601613e..dbbdb8b338 100644 --- a/django/contrib/gis/db/models/sql/conversion.py +++ b/django/contrib/gis/db/models/sql/conversion.py @@ -2,6 +2,7 @@ This module holds simple classes to convert geospatial values from the database. """ +from __future__ import unicode_literals from django.contrib.gis.db.models.fields import GeoSelectFormatMixin from django.contrib.gis.geometry.backend import Geometry @@ -24,6 +25,8 @@ class AreaField(BaseField): self.area_att = area_att def from_db_value(self, value, expression, connection, context): + if connection.features.interprets_empty_strings_as_nulls and value == '': + value = None if value is not None: value = Area(**{self.area_att: value}) return value diff --git a/tests/gis_tests/geoapp/models.py b/tests/gis_tests/geoapp/models.py index a819c17ada..d6ca4f5010 100644 --- a/tests/gis_tests/geoapp/models.py +++ b/tests/gis_tests/geoapp/models.py @@ -61,6 +61,14 @@ class MultiFields(NamedModel): point = models.PointField() poly = models.PolygonField() + class Meta: + required_db_features = ['gis_enabled'] + + +class UniqueTogetherModel(models.Model): + city = models.CharField(max_length=30) + point = models.PointField() + class Meta: unique_together = ('city', 'point') required_db_features = ['gis_enabled', 'supports_geometry_field_unique_index'] diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py index 64af08aa77..0d6ee0362f 100644 --- a/tests/gis_tests/geoapp/tests.py +++ b/tests/gis_tests/geoapp/tests.py @@ -615,7 +615,7 @@ class GeoQuerySetTest(TestCase): if oracle: # No precision parameter for Oracle :-/ gml_regex = re.compile( - r'^' + r'^' r'-104.60925\d+,38.25500\d+ ' r'' )