summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorClaude Paroz <claude@2xlibre.net>2015-01-19 16:09:41 +0100
committerClaude Paroz <claude@2xlibre.net>2015-04-22 19:54:17 +0200
commitd9ff5ef36d3f714736d633435d45f03eac9c17b5 (patch)
tree49e1cdfd8d2df8a79e163e54bac43dead6286529 /django
parent1418f7530455abf3e6f743164ae15ee6b2566a3c (diff)
Fixed #24214 -- Added GIS functions to replace geoqueryset's methods
Thanks Simon Charette and Tim Graham for the reviews.
Diffstat (limited to 'django')
-rw-r--r--django/contrib/gis/db/backends/base/features.py19
-rw-r--r--django/contrib/gis/db/backends/base/operations.py25
-rw-r--r--django/contrib/gis/db/backends/postgis/adapter.py8
-rw-r--r--django/contrib/gis/db/backends/postgis/operations.py7
-rw-r--r--django/contrib/gis/db/models/functions.py351
5 files changed, 401 insertions, 9 deletions
diff --git a/django/contrib/gis/db/backends/base/features.py b/django/contrib/gis/db/backends/base/features.py
index 13da494814..1c8a472a4d 100644
--- a/django/contrib/gis/db/backends/base/features.py
+++ b/django/contrib/gis/db/backends/base/features.py
@@ -1,3 +1,4 @@
+import re
from functools import partial
from django.contrib.gis.db.models import aggregates
@@ -59,11 +60,11 @@ class BaseSpatialFeatures(object):
# `has_<name>_method` (defined in __init__) which accesses connection.ops
# to determine GIS method availability.
geoqueryset_methods = (
- 'area', 'centroid', 'difference', 'distance', 'distance_spheroid',
- 'envelope', 'force_rhr', 'geohash', 'gml', 'intersection', 'kml',
- 'length', 'num_geom', 'perimeter', 'point_on_surface', 'reverse',
- 'scale', 'snap_to_grid', 'svg', 'sym_difference', 'transform',
- 'translate', 'union', 'unionagg',
+ 'area', 'bounding_circle', 'centroid', 'difference', 'distance',
+ 'distance_spheroid', 'envelope', 'force_rhr', 'geohash', 'gml',
+ 'intersection', 'kml', 'length', 'mem_size', 'num_geom', 'num_points',
+ 'perimeter', 'point_on_surface', 'reverse', 'scale', 'snap_to_grid',
+ 'svg', 'sym_difference', 'transform', 'translate', 'union', 'unionagg',
)
# Specifies whether the Collect and Extent aggregates are supported by the database
@@ -86,5 +87,13 @@ class BaseSpatialFeatures(object):
setattr(self.__class__, 'has_%s_method' % method,
property(partial(BaseSpatialFeatures.has_ops_method, method=method)))
+ def __getattr__(self, name):
+ m = re.match(r'has_(\w*)_function$', name)
+ if m:
+ func_name = m.group(1)
+ if func_name not in self.connection.ops.unsupported_functions:
+ return True
+ return False
+
def has_ops_method(self, method):
return getattr(self.connection.ops, method, False)
diff --git a/django/contrib/gis/db/backends/base/operations.py b/django/contrib/gis/db/backends/base/operations.py
index 4759c86b89..a560768bff 100644
--- a/django/contrib/gis/db/backends/base/operations.py
+++ b/django/contrib/gis/db/backends/base/operations.py
@@ -22,6 +22,7 @@ class BaseSpatialOperations(object):
geometry = False
area = False
+ bounding_circle = False
centroid = False
difference = False
distance = False
@@ -30,7 +31,6 @@ class BaseSpatialOperations(object):
envelope = False
force_rhr = False
mem_size = False
- bounding_circle = False
num_geom = False
num_points = False
perimeter = False
@@ -48,6 +48,22 @@ class BaseSpatialOperations(object):
# Aggregates
disallowed_aggregates = ()
+ geom_func_prefix = ''
+
+ # Mapping between Django function names and backend names, when names do not
+ # match; used in spatial_function_name().
+ function_names = {}
+
+ # Blacklist/set of known unsupported functions of the backend
+ unsupported_functions = {
+ 'Area', 'AsGeoHash', 'AsGeoJSON', 'AsGML', 'AsKML', 'AsSVG',
+ 'BoundingCircle', 'Centroid', 'Difference', 'Distance', 'Envelope',
+ 'ForceRHR', 'Intersection', 'Length', 'MemSize', 'NumGeometries',
+ 'NumPoints', 'Perimeter', 'PointOnSurface', 'Reverse', 'Scale',
+ 'SnapToGrid', 'SymDifference', 'Transform', 'Translate',
+ 'Union',
+ }
+
# Serialization
geohash = False
geojson = False
@@ -108,9 +124,14 @@ class BaseSpatialOperations(object):
def spatial_aggregate_name(self, agg_name):
raise NotImplementedError('Aggregate support not implemented for this spatial backend.')
+ def spatial_function_name(self, func_name):
+ if func_name in self.unsupported_functions:
+ raise NotImplementedError("This backend doesn't support the %s function." % func_name)
+ return self.function_names.get(func_name, self.geom_func_prefix + func_name)
+
# Routines for getting the OGC-compliant models.
def geometry_columns(self):
- raise NotImplementedError('subclasses of BaseSpatialOperations must a provide geometry_columns() method')
+ raise NotImplementedError('Subclasses of BaseSpatialOperations must provide a geometry_columns() method.')
def spatial_ref_sys(self):
raise NotImplementedError('subclasses of BaseSpatialOperations must a provide spatial_ref_sys() method')
diff --git a/django/contrib/gis/db/backends/postgis/adapter.py b/django/contrib/gis/db/backends/postgis/adapter.py
index 57f43b667f..cb0d466a80 100644
--- a/django/contrib/gis/db/backends/postgis/adapter.py
+++ b/django/contrib/gis/db/backends/postgis/adapter.py
@@ -8,12 +8,13 @@ from psycopg2.extensions import ISQLQuote
class PostGISAdapter(object):
- def __init__(self, geom):
+ def __init__(self, geom, geography=False):
"Initializes on the geometry."
# Getting the WKB (in string form, to allow easy pickling of
# the adaptor) and the SRID from the geometry.
self.ewkb = bytes(geom.ewkb)
self.srid = geom.srid
+ self.geography = geography
self._adapter = Binary(self.ewkb)
def __conform__(self, proto):
@@ -44,4 +45,7 @@ class PostGISAdapter(object):
def getquoted(self):
"Returns a properly quoted string for use in PostgreSQL/PostGIS."
# psycopg will figure out whether to use E'\\000' or '\000'
- return str('ST_GeomFromEWKB(%s)' % self._adapter.getquoted().decode())
+ return str('%s(%s)' % (
+ 'ST_GeogFromWKB' if self.geography else 'ST_GeomFromEWKB',
+ self._adapter.getquoted().decode())
+ )
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index e0af0c1ecb..993a6cdbbf 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -88,6 +88,13 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
'distance_lte': PostGISDistanceOperator(func='ST_Distance', op='<=', geography=True),
}
+ unsupported_functions = set()
+ function_names = {
+ 'BoundingCircle': 'ST_MinimumBoundingCircle',
+ 'MemSize': 'ST_Mem_Size',
+ 'NumPoints': 'ST_NPoints',
+ }
+
def __init__(self, connection):
super(PostGISOperations, self).__init__(connection)
diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py
new file mode 100644
index 0000000000..3669c15360
--- /dev/null
+++ b/django/contrib/gis/db/models/functions.py
@@ -0,0 +1,351 @@
+from decimal import Decimal
+
+from django.contrib.gis.db.models.fields import GeometryField
+from django.contrib.gis.db.models.sql import AreaField
+from django.contrib.gis.geos.geometry import GEOSGeometry
+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.expressions import Func, Value
+from django.utils import six
+
+NUMERIC_TYPES = six.integer_types + (float, Decimal)
+
+
+class GeoFunc(Func):
+ function = None
+ output_field_class = None
+ geom_param_pos = 0
+
+ def __init__(self, *expressions, **extra):
+ if 'output_field' not in extra and self.output_field_class:
+ extra['output_field'] = self.output_field_class()
+ super(GeoFunc, self).__init__(*expressions, **extra)
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ @property
+ def srid(self):
+ expr = self.source_expressions[self.geom_param_pos]
+ if hasattr(expr, 'srid'):
+ return expr.srid
+ try:
+ return expr.field.srid
+ except (AttributeError, FieldError):
+ return None
+
+ def as_sql(self, compiler, connection):
+ if self.function is None:
+ self.function = connection.ops.spatial_function_name(self.name)
+ return super(GeoFunc, self).as_sql(compiler, connection)
+
+ def resolve_expression(self, *args, **kwargs):
+ res = super(GeoFunc, self).resolve_expression(*args, **kwargs)
+ base_srid = res.srid
+ if not base_srid:
+ raise TypeError("Geometry functions can only operate on geometric content.")
+
+ for pos, expr in enumerate(res.source_expressions[1:], start=1):
+ if isinstance(expr, GeomValue) and expr.srid != base_srid:
+ # Automatic SRID conversion so objects are comparable
+ res.source_expressions[pos] = Transform(expr, base_srid).resolve_expression(*args, **kwargs)
+ return res
+
+ def _handle_param(self, value, param_name='', check_types=None):
+ if not hasattr(value, 'resolve_expression'):
+ if check_types and not isinstance(value, check_types):
+ raise TypeError(
+ "The %s parameter has the wrong type: should be %s." % (
+ param_name, str(check_types))
+ )
+ return value
+
+
+class GeomValue(Value):
+ geography = False
+
+ @property
+ def srid(self):
+ return self.value.srid
+
+ def as_sql(self, compiler, connection):
+ if self.geography:
+ self.value = connection.ops.Adapter(self.value, geography=self.geography)
+ else:
+ self.value = connection.ops.Adapter(self.value)
+ return super(GeomValue, self).as_sql(compiler, connection)
+
+
+class GeoFuncWithGeoParam(GeoFunc):
+ def __init__(self, expression, geom, *expressions, **extra):
+ if not hasattr(geom, 'srid'):
+ # Try to interpret it as a geometry input
+ try:
+ geom = GEOSGeometry(geom)
+ except Exception:
+ raise ValueError("This function requires a geometric parameter.")
+ if not geom.srid:
+ raise ValueError("Please provide a geometry attribute with a defined SRID.")
+ geom = GeomValue(geom)
+ super(GeoFuncWithGeoParam, self).__init__(expression, geom, *expressions, **extra)
+
+
+class Area(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.
+ 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.')
+ return super(Area, self).as_sql(compiler, connection)
+
+
+class AsGeoJSON(GeoFunc):
+ output_field_class = TextField
+
+ def __init__(self, expression, bbox=False, crs=False, precision=8, **extra):
+ expressions = [expression]
+ if precision is not None:
+ expressions.append(self._handle_param(precision, 'precision', six.integer_types))
+ options = 0
+ if crs and bbox:
+ options = 3
+ elif bbox:
+ options = 1
+ elif crs:
+ options = 2
+ if options:
+ expressions.append(options)
+ super(AsGeoJSON, self).__init__(*expressions, **extra)
+
+
+class AsGML(GeoFunc):
+ geom_param_pos = 1
+ output_field_class = TextField
+
+ def __init__(self, expression, version=2, precision=8, **extra):
+ expressions = [version, expression]
+ if precision is not None:
+ expressions.append(self._handle_param(precision, 'precision', six.integer_types))
+ super(AsGML, self).__init__(*expressions, **extra)
+
+
+class AsKML(AsGML):
+ pass
+
+
+class AsSVG(GeoFunc):
+ output_field_class = TextField
+
+ def __init__(self, expression, relative=False, precision=8, **extra):
+ relative = relative if hasattr(relative, 'resolve_expression') else int(relative)
+ expressions = [
+ expression,
+ relative,
+ self._handle_param(precision, 'precision', six.integer_types),
+ ]
+ super(AsSVG, self).__init__(*expressions, **extra)
+
+
+class BoundingCircle(GeoFunc):
+ def __init__(self, expression, num_seg=48, **extra):
+ super(BoundingCircle, self).__init__(*[expression, num_seg], **extra)
+
+
+class Centroid(GeoFunc):
+ pass
+
+
+class Difference(GeoFuncWithGeoParam):
+ pass
+
+
+class DistanceResultMixin(object):
+ def convert_value(self, value, expression, connection, context):
+ if value is None:
+ return None
+ geo_field = GeometryField(srid=self.srid) # Fake field to get SRID info
+ if geo_field.geodetic(connection):
+ dist_att = 'm'
+ else:
+ dist_att = DistanceMeasure.unit_attname(geo_field.units_name(connection))
+ return DistanceMeasure(**{dist_att: value})
+
+
+class Distance(DistanceResultMixin, GeoFuncWithGeoParam):
+ output_field_class = FloatField
+ spheroid = None
+
+ def __init__(self, expr1, expr2, spheroid=None, **extra):
+ expressions = [expr1, expr2]
+ if spheroid is not None:
+ self.spheroid = spheroid
+ expressions += (self._handle_param(spheroid, 'spheroid', bool),)
+ super(Distance, self).__init__(*expressions, **extra)
+
+ def as_postgresql(self, compiler, connection):
+ geo_field = GeometryField(srid=self.srid) # Fake field to get SRID info
+ src_field = self.get_source_fields()[0]
+ geography = src_field.geography and self.srid == 4326
+ if geography:
+ # Set parameters as geography if base field is geography
+ for pos, expr in enumerate(
+ self.source_expressions[self.geom_param_pos + 1:], start=self.geom_param_pos + 1):
+ if isinstance(expr, GeomValue):
+ expr.geography = True
+ elif geo_field.geodetic(connection):
+ # Geometry fields with geodetic (lon/lat) coordinates need special distance functions
+ if self.spheroid:
+ self.function = 'ST_Distance_Spheroid' # More accurate, resource intensive
+ # Replace boolean param by the real spheroid of the base field
+ self.source_expressions[2] = Value(geo_field._spheroid)
+ else:
+ self.function = 'ST_Distance_Sphere'
+ return super(Distance, self).as_sql(compiler, connection)
+
+
+class Envelope(GeoFunc):
+ pass
+
+
+class ForceRHR(GeoFunc):
+ pass
+
+
+class GeoHash(GeoFunc):
+ output_field_class = TextField
+
+ def __init__(self, expression, precision=None, **extra):
+ expressions = [expression]
+ if precision is not None:
+ expressions.append(self._handle_param(precision, 'precision', six.integer_types))
+ super(GeoHash, self).__init__(*expressions, **extra)
+
+
+class Intersection(GeoFuncWithGeoParam):
+ pass
+
+
+class Length(DistanceResultMixin, GeoFunc):
+ output_field_class = FloatField
+
+ def __init__(self, expr1, spheroid=True, **extra):
+ self.spheroid = spheroid
+ super(Length, self).__init__(expr1, **extra)
+
+ def as_postgresql(self, compiler, connection):
+ geo_field = GeometryField(srid=self.srid) # Fake field to get SRID info
+ src_field = self.get_source_fields()[0]
+ geography = src_field.geography and self.srid == 4326
+ if geography:
+ self.source_expressions.append(Value(self.spheroid))
+ elif geo_field.geodetic(connection):
+ # Geometry fields with geodetic (lon/lat) coordinates need length_spheroid
+ self.function = 'ST_Length_Spheroid'
+ self.source_expressions.append(Value(geo_field._spheroid))
+ else:
+ dim = min(f.dim for f in self.get_source_fields() if f)
+ if dim > 2:
+ self.function = connection.ops.length3d
+ return super(Length, self).as_sql(compiler, connection)
+
+
+class MemSize(GeoFunc):
+ output_field_class = IntegerField
+
+
+class NumGeometries(GeoFunc):
+ output_field_class = IntegerField
+
+
+class NumPoints(GeoFunc):
+ output_field_class = IntegerField
+
+
+class Perimeter(DistanceResultMixin, GeoFunc):
+ output_field_class = FloatField
+
+ def as_postgresql(self, compiler, connection):
+ dim = min(f.dim for f in self.get_source_fields())
+ if dim > 2:
+ self.function = connection.ops.perimeter3d
+ return super(Perimeter, self).as_sql(compiler, connection)
+
+
+class PointOnSurface(GeoFunc):
+ pass
+
+
+class Reverse(GeoFunc):
+ pass
+
+
+class Scale(GeoFunc):
+ def __init__(self, expression, x, y, z=0.0, **extra):
+ expressions = [
+ expression,
+ self._handle_param(x, 'x', NUMERIC_TYPES),
+ self._handle_param(y, 'y', NUMERIC_TYPES),
+ ]
+ if z != 0.0:
+ expressions.append(self._handle_param(z, 'z', NUMERIC_TYPES))
+ super(Scale, self).__init__(*expressions, **extra)
+
+
+class SnapToGrid(GeoFunc):
+ def __init__(self, expression, *args, **extra):
+ nargs = len(args)
+ expressions = [expression]
+ if nargs in (1, 2):
+ expressions.extend(
+ [self._handle_param(arg, '', NUMERIC_TYPES) for arg in args]
+ )
+ elif nargs == 4:
+ # Reverse origin and size param ordering
+ expressions.extend(
+ [self._handle_param(arg, '', NUMERIC_TYPES) for arg in args[2:]]
+ )
+ expressions.extend(
+ [self._handle_param(arg, '', NUMERIC_TYPES) for arg in args[0:2]]
+ )
+ else:
+ raise ValueError('Must provide 1, 2, or 4 arguments to `SnapToGrid`.')
+ super(SnapToGrid, self).__init__(*expressions, **extra)
+
+
+class SymDifference(GeoFuncWithGeoParam):
+ pass
+
+
+class Transform(GeoFunc):
+ def __init__(self, expression, srid, **extra):
+ expressions = [
+ expression,
+ self._handle_param(srid, 'srid', six.integer_types),
+ ]
+ super(Transform, self).__init__(*expressions, **extra)
+
+ @property
+ def srid(self):
+ # Make srid the resulting srid of the transformation
+ return self.source_expressions[self.geom_param_pos + 1].value
+
+
+class Translate(Scale):
+ pass
+
+
+class Union(GeoFuncWithGeoParam):
+ pass