diff --git a/django/contrib/gis/db/backend/__init__.py b/django/contrib/gis/db/backend/__init__.py index cb027b9eec..172c1268a7 100644 --- a/django/contrib/gis/db/backend/__init__.py +++ b/django/contrib/gis/db/backend/__init__.py @@ -3,97 +3,16 @@ Specifically, this module will import the correct routines and modules needed for GeoDjango to interface with the spatial database. - - Some of the more important classes and routines from the spatial backend - include: - - (1) `GeoBackEndField`, a base class needed for GeometryField. - (2) `get_geo_where_clause`, a routine used by `GeoWhereNode`. - (3) `GIS_TERMS`, a listing of all valid GeoDjango lookup types. - (4) `SpatialBackend`, a container object for information specific to the - spatial backend. """ from django.conf import settings -from django.db.models.sql.query import QUERY_TERMS from django.contrib.gis.db.backend.util import gqn -# These routines (needed by GeoManager), default to False. -ASGML, ASKML, DISTANCE, DISTANCE_SPHEROID, EXTENT, TRANSFORM, UNION, VERSION = tuple(False for i in range(8)) - -# Lookup types in which the rest of the parameters are not -# needed to be substitute in the WHERE SQL (e.g., the 'relate' -# operation on Oracle does not need the mask substituted back -# into the query SQL.). -LIMITED_WHERE = [] - # Retrieving the necessary settings from the backend. if settings.DATABASE_ENGINE == 'postgresql_psycopg2': - from django.contrib.gis.db.backend.postgis.adaptor import \ - PostGISAdaptor as GeoAdaptor - from django.contrib.gis.db.backend.postgis.field import \ - PostGISField as GeoBackendField - from django.contrib.gis.db.backend.postgis.creation import create_spatial_db - from django.contrib.gis.db.backend.postgis.query import \ - get_geo_where_clause, POSTGIS_TERMS as GIS_TERMS, \ - ASGML, ASKML, DISTANCE, DISTANCE_SPHEROID, DISTANCE_FUNCTIONS, \ - EXTENT, GEOM_SELECT, TRANSFORM, UNION, \ - MAJOR_VERSION, MINOR_VERSION1, MINOR_VERSION2 - # PostGIS version info is needed to determine calling order of some - # stored procedures (e.g., AsGML()). - VERSION = (MAJOR_VERSION, MINOR_VERSION1, MINOR_VERSION2) - SPATIAL_BACKEND = 'postgis' + from django.contrib.gis.db.backend.postgis import create_spatial_db, get_geo_where_clause, SpatialBackend elif settings.DATABASE_ENGINE == 'oracle': - from django.contrib.gis.db.backend.adaptor import WKTAdaptor as GeoAdaptor - from django.contrib.gis.db.backend.oracle.field import \ - OracleSpatialField as GeoBackendField - from django.contrib.gis.db.backend.oracle.creation import create_spatial_db - from django.contrib.gis.db.backend.oracle.query import \ - get_geo_where_clause, ORACLE_SPATIAL_TERMS as GIS_TERMS, \ - ASGML, DISTANCE, DISTANCE_FUNCTIONS, GEOM_SELECT, TRANSFORM, UNION - SPATIAL_BACKEND = 'oracle' - LIMITED_WHERE = ['relate'] + from django.contrib.gis.db.backend.oracle import create_spatial_db, get_geo_where_clause, SpatialBackend elif settings.DATABASE_ENGINE == 'mysql': - from django.contrib.gis.db.backend.adaptor import WKTAdaptor as GeoAdaptor - from django.contrib.gis.db.backend.mysql.field import \ - MySQLGeoField as GeoBackendField - from django.contrib.gis.db.backend.mysql.creation import create_spatial_db - from django.contrib.gis.db.backend.mysql.query import \ - get_geo_where_clause, MYSQL_GIS_TERMS as GIS_TERMS, GEOM_SELECT - DISTANCE_FUNCTIONS = {} - SPATIAL_BACKEND = 'mysql' + from django.contrib.gis.db.backend.mysql import create_spatial_db, get_geo_where_clause, SpatialBackend else: raise NotImplementedError('No Geographic Backend exists for %s' % settings.DATABASE_ENGINE) - -class SpatialBackend(object): - "A container for properties of the SpatialBackend." - # Stored procedure names used by the `GeoManager`. - as_kml = ASKML - as_gml = ASGML - distance = DISTANCE - distance_spheroid = DISTANCE_SPHEROID - extent = EXTENT - name = SPATIAL_BACKEND - select = GEOM_SELECT - transform = TRANSFORM - union = UNION - - # Version information, if defined. - version = VERSION - - # All valid GIS lookup terms, and distance functions. - gis_terms = GIS_TERMS - distance_functions = DISTANCE_FUNCTIONS - - # Lookup types where additional WHERE parameters are excluded. - limited_where = LIMITED_WHERE - - # Shortcut booleans. - mysql = SPATIAL_BACKEND == 'mysql' - oracle = SPATIAL_BACKEND == 'oracle' - postgis = SPATIAL_BACKEND == 'postgis' - - # Class for the backend field. - Field = GeoBackendField - - # Adaptor class used for quoting GEOS geometries in the database. - Adaptor = GeoAdaptor diff --git a/django/contrib/gis/db/backend/base.py b/django/contrib/gis/db/backend/base.py new file mode 100644 index 0000000000..d45ac7b6f1 --- /dev/null +++ b/django/contrib/gis/db/backend/base.py @@ -0,0 +1,29 @@ +""" + This module holds the base `SpatialBackend` object, which is + instantiated by each spatial backend with the features it has. +""" +# TODO: Create a `Geometry` protocol and allow user to use +# different Geometry objects -- for now we just use GEOSGeometry. +from django.contrib.gis.geos import GEOSGeometry, GEOSException + +class BaseSpatialBackend(object): + Geometry = GEOSGeometry + GeometryException = GEOSException + + def __init__(self, **kwargs): + kwargs.setdefault('distance_functions', {}) + kwargs.setdefault('limited_where', {}) + for k, v in kwargs.iteritems(): setattr(self, k, v) + + def __getattr__(self, name): + """ + All attributes of the spatial backend return False by default. + """ + try: + return self.__dict__[name] + except KeyError: + return False + + + + diff --git a/django/contrib/gis/db/backend/mysql/__init__.py b/django/contrib/gis/db/backend/mysql/__init__.py index 8b13789179..0484e5f9b2 100644 --- a/django/contrib/gis/db/backend/mysql/__init__.py +++ b/django/contrib/gis/db/backend/mysql/__init__.py @@ -1 +1,13 @@ +__all__ = ['create_spatial_db', 'get_geo_where_clause', 'SpatialBackend'] +from django.contrib.gis.db.backend.base import BaseSpatialBackend +from django.contrib.gis.db.backend.adaptor import WKTAdaptor +from django.contrib.gis.db.backend.mysql.creation import create_spatial_db +from django.contrib.gis.db.backend.mysql.field import MySQLGeoField +from django.contrib.gis.db.backend.mysql.query import * + +SpatialBackend = BaseSpatialBackend(name='mysql', mysql=True, + gis_terms=MYSQL_GIS_TERMS, + select=GEOM_SELECT, + Adaptor=WKTAdaptor, + Field=MySQLGeoField) diff --git a/django/contrib/gis/db/backend/oracle/__init__.py b/django/contrib/gis/db/backend/oracle/__init__.py index e69de29bb2..3eee56ea23 100644 --- a/django/contrib/gis/db/backend/oracle/__init__.py +++ b/django/contrib/gis/db/backend/oracle/__init__.py @@ -0,0 +1,31 @@ +__all__ = ['create_spatial_db', 'get_geo_where_clause', 'SpatialBackend'] + +from django.contrib.gis.db.backend.base import BaseSpatialBackend +from django.contrib.gis.db.backend.oracle.adaptor import OracleSpatialAdaptor +from django.contrib.gis.db.backend.oracle.creation import create_spatial_db +from django.contrib.gis.db.backend.oracle.field import OracleSpatialField +from django.contrib.gis.db.backend.oracle.query import * + +SpatialBackend = BaseSpatialBackend(name='oracle', oracle=True, + area=AREA, + centroid=CENTROID, + difference=DIFFERENCE, + distance=DISTANCE, + distance_functions=DISTANCE_FUNCTIONS, + gis_terms=ORACLE_SPATIAL_TERMS, + gml=ASGML, + intersection=INTERSECTION, + length=LENGTH, + limited_where = {'relate' : None}, + num_geom=NUM_GEOM, + num_points=NUM_POINTS, + perimeter=LENGTH, + point_on_surface=POINT_ON_SURFACE, + select=GEOM_SELECT, + sym_difference=SYM_DIFFERENCE, + transform=TRANSFORM, + unionagg=UNIONAGG, + union=UNION, + Adaptor=OracleSpatialAdaptor, + Field=OracleSpatialField, + ) diff --git a/django/contrib/gis/db/backend/oracle/adaptor.py b/django/contrib/gis/db/backend/oracle/adaptor.py new file mode 100644 index 0000000000..95dc265795 --- /dev/null +++ b/django/contrib/gis/db/backend/oracle/adaptor.py @@ -0,0 +1,5 @@ +from cx_Oracle import CLOB +from django.contrib.gis.db.backend.adaptor import WKTAdaptor + +class OracleSpatialAdaptor(WKTAdaptor): + input_size = CLOB diff --git a/django/contrib/gis/db/backend/oracle/query.py b/django/contrib/gis/db/backend/oracle/query.py index d4cd3669e2..6f5c0dfe94 100644 --- a/django/contrib/gis/db/backend/oracle/query.py +++ b/django/contrib/gis/db/backend/oracle/query.py @@ -15,10 +15,21 @@ from django.contrib.gis.measure import Distance qn = connection.ops.quote_name # The GML, distance, transform, and union procedures. +AREA = 'SDO_GEOM.SDO_AREA' ASGML = 'SDO_UTIL.TO_GMLGEOMETRY' +CENTROID = 'SDO_GEOM.SDO_CENTROID' +DIFFERENCE = 'SDO_GEOM.SDO_DIFFERENCE' DISTANCE = 'SDO_GEOM.SDO_DISTANCE' +EXTENT = 'SDO_AGGR_MBR' +INTERSECTION = 'SDO_GEOM.SDO_INTERSECTION' +LENGTH = 'SDO_GEOM.SDO_LENGTH' +NUM_GEOM = 'SDO_UTIL.GETNUMELEM' +NUM_POINTS = 'SDO_UTIL.GETNUMVERTICES' +POINT_ON_SURFACE = 'SDO_GEOM.SDO_POINTONSURFACE' +SYM_DIFFERENCE = 'SDO_GEOM.SDO_XOR' TRANSFORM = 'SDO_CS.TRANSFORM' -UNION = 'SDO_AGGR_UNION' +UNION = 'SDO_GEOM.SDO_UNION' +UNIONAGG = 'SDO_AGGR_UNION' # We want to get SDO Geometries as WKT because it is much easier to # instantiate GEOS proxies from WKT than SDO_GEOMETRY(...) strings. diff --git a/django/contrib/gis/db/backend/postgis/__init__.py b/django/contrib/gis/db/backend/postgis/__init__.py index e69de29bb2..8a4d09e0d5 100644 --- a/django/contrib/gis/db/backend/postgis/__init__.py +++ b/django/contrib/gis/db/backend/postgis/__init__.py @@ -0,0 +1,42 @@ +__all__ = ['create_spatial_db', 'get_geo_where_clause', 'SpatialBackend'] + +from django.contrib.gis.db.backend.base import BaseSpatialBackend +from django.contrib.gis.db.backend.postgis.adaptor import PostGISAdaptor +from django.contrib.gis.db.backend.postgis.creation import create_spatial_db +from django.contrib.gis.db.backend.postgis.field import PostGISField +from django.contrib.gis.db.backend.postgis.query import * + +SpatialBackend = BaseSpatialBackend(name='postgis', postgis=True, + area=AREA, + centroid=CENTROID, + difference=DIFFERENCE, + distance=DISTANCE, + distance_functions=DISTANCE_FUNCTIONS, + distance_sphere=DISTANCE_SPHERE, + distance_spheroid=DISTANCE_SPHEROID, + envelope=ENVELOPE, + extent=EXTENT, + gis_terms=POSTGIS_TERMS, + gml=ASGML, + intersection=INTERSECTION, + kml=ASKML, + length=LENGTH, + length_spheroid=LENGTH_SPHEROID, + make_line=MAKE_LINE, + mem_size=MEM_SIZE, + num_geom=NUM_GEOM, + num_points=NUM_POINTS, + perimeter=PERIMETER, + point_on_surface=POINT_ON_SURFACE, + scale=SCALE, + select=GEOM_SELECT, + svg=ASSVG, + sym_difference=SYM_DIFFERENCE, + transform=TRANSFORM, + translate=TRANSLATE, + union=UNION, + unionagg=UNIONAGG, + version=(MAJOR_VERSION, MINOR_VERSION1, MINOR_VERSION2), + Adaptor=PostGISAdaptor, + Field=PostGISField, + ) diff --git a/django/contrib/gis/db/backend/postgis/field.py b/django/contrib/gis/db/backend/postgis/field.py index 9e20a72eee..1e055ff60b 100644 --- a/django/contrib/gis/db/backend/postgis/field.py +++ b/django/contrib/gis/db/backend/postgis/field.py @@ -1,6 +1,5 @@ from django.db import connection from django.db.models.fields import Field # Django base Field class -from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.db.backend.util import gqn from django.contrib.gis.db.backend.postgis.query import TRANSFORM diff --git a/django/contrib/gis/db/backend/postgis/query.py b/django/contrib/gis/db/backend/postgis/query.py index 2610173673..90063e1887 100644 --- a/django/contrib/gis/db/backend/postgis/query.py +++ b/django/contrib/gis/db/backend/postgis/query.py @@ -21,7 +21,7 @@ if MAJOR_VERSION != 1 or (MAJOR_VERSION == 1 and MINOR_VERSION1 < 1): # Versions of PostGIS >= 1.2.2 changed their naming convention to be # 'SQL-MM-centric' to conform with the ISO standard. Practically, this -# means that 'ST_' is prefixes geometry function names. +# means that 'ST_' prefixes geometry function names. GEOM_FUNC_PREFIX = '' if MAJOR_VERSION >= 1: if (MINOR_VERSION1 > 2 or @@ -30,26 +30,46 @@ if MAJOR_VERSION >= 1: def get_func(func): return '%s%s' % (GEOM_FUNC_PREFIX, func) - # Custom selection not needed for PostGIS since GEOS geometries may be + # Custom selection not needed for PostGIS because GEOS geometries are # instantiated directly from the HEXEWKB returned by default. If # WKT is needed for some reason in the future, this value may be changed, - # 'AsText(%s)' + # e.g,, 'AsText(%s)'. GEOM_SELECT = None # Functions used by the GeoManager & GeoQuerySet + AREA = get_func('Area') ASKML = get_func('AsKML') ASGML = get_func('AsGML') + ASSVG = get_func('AsSVG') + CENTROID = get_func('Centroid') + DIFFERENCE = get_func('Difference') DISTANCE = get_func('Distance') + DISTANCE_SPHERE = get_func('distance_sphere') DISTANCE_SPHEROID = get_func('distance_spheroid') + ENVELOPE = get_func('Envelope') EXTENT = get_func('extent') GEOM_FROM_TEXT = get_func('GeomFromText') GEOM_FROM_WKB = get_func('GeomFromWKB') + INTERSECTION = get_func('Intersection') + LENGTH = get_func('Length') + LENGTH_SPHEROID = get_func('length_spheroid') + MAKE_LINE = get_func('MakeLine') + MEM_SIZE = get_func('mem_size') + NUM_GEOM = get_func('NumGeometries') + NUM_POINTS = get_func('npoints') + PERIMETER = get_func('Perimeter') + POINT_ON_SURFACE = get_func('PointOnSurface') + SCALE = get_func('Scale') + SYM_DIFFERENCE = get_func('SymDifference') TRANSFORM = get_func('Transform') + TRANSLATE = get_func('Translate') # Special cases for union and KML methods. if MINOR_VERSION1 < 3: - UNION = 'GeomUnion' + UNIONAGG = 'GeomUnion' + UNION = 'Union' else: + UNIONAGG = 'ST_Union' UNION = 'ST_Union' if MINOR_VERSION1 == 1: @@ -80,16 +100,23 @@ class PostGISDistance(PostGISFunction): super(PostGISDistance, self).__init__(self.dist_func, end_subst=') %s %s', operator=operator, result='%%s') -class PostGISSphereDistance(PostGISFunction): - "For PostGIS spherical distance operations." +class PostGISSpheroidDistance(PostGISFunction): + "For PostGIS spherical distance operations (using the spheroid)." dist_func = 'distance_spheroid' def __init__(self, operator): # An extra parameter in `end_subst` is needed for the spheroid string. - super(PostGISSphereDistance, self).__init__(self.dist_func, - beg_subst='%s(%s, %%s, %%s', - end_subst=') %s %s', - operator=operator, result='%%s') + super(PostGISSpheroidDistance, self).__init__(self.dist_func, + beg_subst='%s(%s, %%s, %%s', + end_subst=') %s %s', + operator=operator, result='%%s') +class PostGISSphereDistance(PostGISFunction): + "For PostGIS spherical distance operations." + dist_func = 'distance_sphere' + def __init__(self, operator): + super(PostGISSphereDistance, self).__init__(self.dist_func, end_subst=') %s %s', + operator=operator, result='%%s') + class PostGISRelate(PostGISFunctionParam): "For PostGIS Relate(, ) calls." pattern_regex = re.compile(r'^[012TF\*]{9}$') @@ -164,7 +191,7 @@ POSTGIS_GEOMETRY_FUNCTIONS = { dtypes = (Decimal, Distance, float, int, long) def get_dist_ops(operator): "Returns operations for both regular and spherical distances." - return (PostGISDistance(operator), PostGISSphereDistance(operator)) + return (PostGISDistance(operator), PostGISSphereDistance(operator), PostGISSpheroidDistance(operator)) DISTANCE_FUNCTIONS = { 'distance_gt' : (get_dist_ops('>'), dtypes), 'distance_gte' : (get_dist_ops('>='), dtypes), @@ -193,6 +220,13 @@ POSTGIS_TERMS += POSTGIS_GEOMETRY_FUNCTIONS.keys() # Adding on the Geometry Func POSTGIS_TERMS += MISC_TERMS # Adding any other miscellaneous terms (e.g., 'isnull') POSTGIS_TERMS = tuple(POSTGIS_TERMS) # Making immutable +# For checking tuple parameters -- not very pretty but gets job done. +def exactly_two(val): return val == 2 +def two_to_three(val): return val >= 2 and val <=3 +def num_params(lookup_type, val): + if lookup_type in DISTANCE_FUNCTIONS and lookup_type != 'dwithin': return two_to_three(val) + else: return exactly_two(val) + #### The `get_geo_where_clause` function for PostGIS. #### def get_geo_where_clause(lookup_type, table_prefix, field, value): "Returns the SQL WHERE clause for use in PostGIS SQL construction." @@ -216,8 +250,10 @@ def get_geo_where_clause(lookup_type, table_prefix, field, value): # Ensuring that a tuple _value_ was passed in from the user if not isinstance(value, (tuple, list)): raise TypeError('Tuple required for `%s` lookup type.' % lookup_type) - if len(value) != 2: - raise ValueError('2-element tuple required or `%s` lookup type.' % lookup_type) + # Number of valid tuple parameters depends on the lookup type. + nparams = len(value) + if not num_params(lookup_type, nparams): + raise ValueError('Incorrect number of parameters given for `%s` lookup type.' % lookup_type) # Ensuring the argument type matches what we expect. if not isinstance(value[1], arg_type): @@ -234,7 +270,9 @@ def get_geo_where_clause(lookup_type, table_prefix, field, value): raise TypeError('PostGIS spherical operations are only valid on PointFields.') if value[0].geom_typeid != 0: raise TypeError('PostGIS geometry distance parameter is required to be of type Point.') - op = op[1] + # Setting up the geodetic operation appropriately. + if nparams == 3 and value[2] == 'spheroid': op = op[2] + else: op = op[1] else: op = op[0] else: diff --git a/django/contrib/gis/db/models/fields/__init__.py b/django/contrib/gis/db/models/fields/__init__.py index f36045cf02..0a0d144dcc 100644 --- a/django/contrib/gis/db/models/fields/__init__.py +++ b/django/contrib/gis/db/models/fields/__init__.py @@ -3,15 +3,12 @@ from django.db import connection from django.contrib.gis.db.backend import SpatialBackend, gqn # GeometryProxy, GEOS, Distance, and oldforms imports. from django.contrib.gis.db.models.proxy import GeometryProxy -from django.contrib.gis.geos import GEOSException, GEOSGeometry from django.contrib.gis.measure import Distance from django.contrib.gis.oldforms import WKTField -# Attempting to get the spatial reference system. -try: - from django.contrib.gis.models import SpatialRefSys -except ImportError: - SpatialRefSys = None +# The `get_srid_info` function gets SRID information from the spatial +# reference system table w/o using the ORM. +from django.contrib.gis.models import get_srid_info #TODO: Flesh out widgets; consider adding support for OGR Geometry proxies. class GeometryField(SpatialBackend.Field): @@ -47,29 +44,7 @@ class GeometryField(SpatialBackend.Field): # Setting the SRID and getting the units. Unit information must be # easily available in the field instance for distance queries. self._srid = srid - if SpatialRefSys: - # Getting the spatial reference WKT associated with the SRID from the - # `spatial_ref_sys` (or equivalent) spatial database table. - # - # The following doesn't work: SpatialRefSys.objects.get(srid=srid) - # Why? `syncdb` fails to recognize installed geographic models when there's - # an ORM query instantiated within a model field. - cur = connection.cursor() - qn = connection.ops.quote_name - stmt = 'SELECT %(table)s.%(wkt_col)s FROM %(table)s WHERE (%(table)s.%(srid_col)s = %(srid)s)' - stmt = stmt % {'table' : qn(SpatialRefSys._meta.db_table), - 'wkt_col' : qn(SpatialRefSys.wkt_col()), - 'srid_col' : qn('srid'), - 'srid' : srid, - } - cur.execute(stmt) - srs_wkt = cur.fetchone()[0] - - # Getting metadata associated with the spatial reference system identifier. - # Specifically, getting the unit information and spheroid information - # (both required for distance queries). - self._unit, self._unit_name = SpatialRefSys.get_units(srs_wkt) - self._spheroid = SpatialRefSys.get_spheroid(srs_wkt) + self._unit, self._unit_name, self._spheroid = get_srid_info(srid) # Setting the dimension of the geometry field. self._dim = dim @@ -79,19 +54,26 @@ class GeometryField(SpatialBackend.Field): ### Routines specific to GeometryField ### @property def geodetic(self): + """ + Returns true if this field's SRID corresponds with a coordinate + system that uses non-projected units (e.g., latitude/longitude). + """ return self._unit_name in self.geodetic_units - def get_distance(self, dist, lookup_type): + def get_distance(self, dist_val, lookup_type): """ Returns a distance number in units of the field. For example, if `D(km=1)` was passed in and the units of the field were in meters, then 1000 would be returned. """ - postgis = SpatialBackend.name == 'postgis' + # Getting the distance parameter and any options. + if len(dist_val) == 1: dist, option = dist_val[0], None + else: dist, option = dist_val + if isinstance(dist, Distance): if self.geodetic: # Won't allow Distance objects w/DWithin lookups on PostGIS. - if postgis and lookup_type == 'dwithin': + if SpatialBackend.postgis and lookup_type == 'dwithin': raise TypeError('Only numeric values of degree units are allowed on geographic DWithin queries.') # Spherical distance calculation parameter should be in meters. dist_param = dist.m @@ -100,9 +82,11 @@ class GeometryField(SpatialBackend.Field): else: # Assuming the distance is in the units of the field. dist_param = dist - - # Sphereical distance query; returning meters. - if postgis and self.geodetic and lookup_type != 'dwithin': + + if SpatialBackend.postgis and self.geodetic and lookup_type != 'dwithin' and option == 'spheroid': + # On PostGIS, by default `ST_distance_sphere` is used; but if the + # accuracy of `ST_distance_spheroid` is needed than the spheroid + # needs to be passed to the SQL stored procedure. return [gqn(self._spheroid), dist_param] else: return [dist_param] @@ -119,12 +103,12 @@ class GeometryField(SpatialBackend.Field): # When the input is not a GEOS geometry, attempt to construct one # from the given string input. - if isinstance(geom, GEOSGeometry): + if isinstance(geom, SpatialBackend.Geometry): pass elif isinstance(geom, basestring): try: - geom = GEOSGeometry(geom) - except GEOSException: + geom = SpatialBackend.Geometry(geom) + except SpatialBackend.GeometryException: raise ValueError('Could not create geometry from lookup value: %s' % str(value)) else: raise TypeError('Cannot use parameter of `%s` type as lookup parameter.' % type(value)) @@ -148,8 +132,8 @@ class GeometryField(SpatialBackend.Field): def contribute_to_class(self, cls, name): super(GeometryField, self).contribute_to_class(cls, name) - # Setup for lazy-instantiated GEOSGeometry object. - setattr(cls, self.attname, GeometryProxy(GEOSGeometry, self)) + # Setup for lazy-instantiated Geometry object. + setattr(cls, self.attname, GeometryProxy(SpatialBackend.Geometry, self)) def get_db_prep_lookup(self, lookup_type, value): """ @@ -166,7 +150,7 @@ class GeometryField(SpatialBackend.Field): geom = self.get_geometry(value) # Getting the WHERE clause list and the associated params list. The params - # list is populated with the Adaptor wrapping the GEOSGeometry for the + # list is populated with the Adaptor wrapping the Geometry for the # backend. The WHERE clause list contains the placeholder for the adaptor # (e.g. any transformation SQL). where = [self.get_placeholder(geom)] @@ -175,7 +159,7 @@ class GeometryField(SpatialBackend.Field): if isinstance(value, (tuple, list)): if lookup_type in SpatialBackend.distance_functions: # Getting the distance parameter in the units of the field. - where += self.get_distance(value[1], lookup_type) + where += self.get_distance(value[1:], lookup_type) elif lookup_type in SpatialBackend.limited_where: pass else: @@ -187,15 +171,15 @@ class GeometryField(SpatialBackend.Field): def get_db_prep_save(self, value): "Prepares the value for saving in the database." - if isinstance(value, GEOSGeometry): + if isinstance(value, SpatialBackend.Geometry): return SpatialBackend.Adaptor(value) elif value is None: return None else: - raise TypeError('Geometry Proxy should only return GEOSGeometry objects or None.') + raise TypeError('Geometry Proxy should only return Geometry objects or None.') def get_manipulator_field_objs(self): - "Using the WKTField (defined above) to be our manipulator." + "Using the WKTField (oldforms) to be our manipulator." return [WKTField] # The OpenGIS Geometry Type Fields diff --git a/django/contrib/gis/db/models/manager.py b/django/contrib/gis/db/models/manager.py index 8634dd9cee..4b010ac20c 100644 --- a/django/contrib/gis/db/models/manager.py +++ b/django/contrib/gis/db/models/manager.py @@ -7,20 +7,71 @@ class GeoManager(Manager): def get_query_set(self): return GeoQuerySet(model=self.model) + def area(self, *args, **kwargs): + return self.get_query_set().area(*args, **kwargs) + + def centroid(self, *args, **kwargs): + return self.get_query_set().centroid(*args, **kwargs) + + def difference(self, *args, **kwargs): + return self.get_query_set().difference(*args, **kwargs) + def distance(self, *args, **kwargs): return self.get_query_set().distance(*args, **kwargs) + def envelope(self, *args, **kwargs): + return self.get_query_set().envelope(*args, **kwargs) + def extent(self, *args, **kwargs): return self.get_query_set().extent(*args, **kwargs) def gml(self, *args, **kwargs): return self.get_query_set().gml(*args, **kwargs) + def intersection(self, *args, **kwargs): + return self.get_query_set().intersection(*args, **kwargs) + def kml(self, *args, **kwargs): return self.get_query_set().kml(*args, **kwargs) + def length(self, *args, **kwargs): + return self.get_query_set().length(*args, **kwargs) + + def make_line(self, *args, **kwargs): + return self.get_query_set().make_line(*args, **kwargs) + + def mem_size(self, *args, **kwargs): + return self.get_query_set().mem_size(*args, **kwargs) + + def num_geom(self, *args, **kwargs): + return self.get_query_set().num_geom(*args, **kwargs) + + def num_points(self, *args, **kwargs): + return self.get_query_set().num_points(*args, **kwargs) + + def perimeter(self, *args, **kwargs): + return self.get_query_set().perimeter(*args, **kwargs) + + def point_on_surface(self, *args, **kwargs): + return self.get_query_set().point_on_surface(*args, **kwargs) + + def scale(self, *args, **kwargs): + return self.get_query_set().scale(*args, **kwargs) + + def svg(self, *args, **kwargs): + return self.get_query_set().svg(*args, **kwargs) + + def sym_difference(self, *args, **kwargs): + return self.get_query_set().sym_difference(*args, **kwargs) + def transform(self, *args, **kwargs): return self.get_query_set().transform(*args, **kwargs) + def translate(self, *args, **kwargs): + return self.get_query_set().translate(*args, **kwargs) + def union(self, *args, **kwargs): return self.get_query_set().union(*args, **kwargs) + + def unionagg(self, *args, **kwargs): + return self.get_query_set().unionagg(*args, **kwargs) diff --git a/django/contrib/gis/db/models/proxy.py b/django/contrib/gis/db/models/proxy.py index 43e7dbe8ef..34276a6d63 100644 --- a/django/contrib/gis/db/models/proxy.py +++ b/django/contrib/gis/db/models/proxy.py @@ -1,7 +1,7 @@ """ The GeometryProxy object, allows for lazy-geometries. The proxy uses - Python descriptors for instantiating and setting GEOS Geometry objects - corresponding to geographic model fields. + Python descriptors for instantiating and setting Geometry objects + corresponding to geographic model fields. Thanks to Robert Coup for providing this functionality (see #4322). """ @@ -31,8 +31,8 @@ class GeometryProxy(object): elif (geom_value is None) or (geom_value==''): geom = None else: - # Otherwise, a GEOSGeometry object is built using the field's contents, - # and the model's corresponding attribute is set. + # Otherwise, a Geometry object is built using the field's contents, + # and the model's corresponding attribute is set. geom = self._klass(geom_value) setattr(obj, self._field.attname, geom) return geom diff --git a/django/contrib/gis/db/models/query.py b/django/contrib/gis/db/models/query.py index 65b011c7a8..e779886c92 100644 --- a/django/contrib/gis/db/models/query.py +++ b/django/contrib/gis/db/models/query.py @@ -1,12 +1,12 @@ -from itertools import izip from django.core.exceptions import ImproperlyConfigured from django.db import connection from django.db.models.query import sql, QuerySet, Q from django.contrib.gis.db.backend import SpatialBackend from django.contrib.gis.db.models.fields import GeometryField, PointField -from django.contrib.gis.db.models.sql import GeoQuery, GeoWhereNode -from django.contrib.gis.geos import GEOSGeometry, Point +from django.contrib.gis.db.models.sql import AreaField, DistanceField, GeomField, GeoQuery, GeoWhereNode +from django.contrib.gis.measure import Area, Distance +from django.contrib.gis.models import get_srid_info qn = connection.ops.quote_name # For backwards-compatibility; Q object should work just fine @@ -28,174 +28,236 @@ class GeoQuerySet(QuerySet): super(GeoQuerySet, self).__init__(model=model, query=query) self.query = query or GeoQuery(self.model, connection) - def distance(self, *args, **kwargs): + def area(self, tolerance=0.05, **kwargs): + """ + Returns the area of the geographic field in an `area` attribute on + each element of this GeoQuerySet. + """ + # Peforming setup here rather than in `_spatial_attribute` so that + # we can get the units for `AreaField`. + procedure_args, geo_field = self._spatial_setup('area', field_name=kwargs.get('field_name', None)) + s = {'procedure_args' : procedure_args, + 'geo_field' : geo_field, + 'setup' : False, + } + if SpatialBackend.oracle: + s['procedure_fmt'] = '%(geo_col)s,%(tolerance)s' + s['procedure_args']['tolerance'] = tolerance + s['select_field'] = AreaField('sq_m') # Oracle returns area in units of meters. + elif SpatialBackend.postgis: + if not geo_field.geodetic: + # Getting the area units of the geographic field. + s['select_field'] = AreaField(Area.unit_attname(geo_field._unit_name)) + else: + # TODO: Do we want to support raw number areas for geodetic fields? + raise Exception('Area on geodetic coordinate systems not supported.') + return self._spatial_attribute('area', s, **kwargs) + + def centroid(self, **kwargs): + """ + Returns the centroid of the geographic field in a `centroid` + attribute on each element of this GeoQuerySet. + """ + return self._geom_attribute('centroid', **kwargs) + + def difference(self, geom, **kwargs): + """ + Returns the spatial difference of the geographic field in a `difference` + attribute on each element of this GeoQuerySet. + """ + return self._geomset_attribute('difference', geom, **kwargs) + + def distance(self, geom, **kwargs): """ Returns the distance from the given geographic field name to the given geometry in a `distance` attribute on each element of the GeoQuerySet. + + Keyword Arguments: + `spheroid` => If the geometry field is geodetic and PostGIS is + the spatial database, then the more accurate + spheroid calculation will be used instead of the + quicker sphere calculation. + + `tolerance` => Used only for Oracle. The tolerance is + in meters -- a default of 5 centimeters (0.05) + is used. """ - DISTANCE = SpatialBackend.distance - if not DISTANCE: - raise ImproperlyConfigured('Distance() stored proecedure not available.') + return self._distance_attribute('distance', geom, **kwargs) - # Getting the geometry field and GEOSGeometry object to base distance - # calculations from. - nargs = len(args) - if nargs == 1: - field_name = None - geom = args[0] - elif nargs == 2: - field_name, geom = args - else: - raise ValueError('Maximum two arguments allowed for `distance` aggregate.') + def envelope(self, **kwargs): + """ + Returns a Geometry representing the bounding box of the + Geometry field in an `envelope` attribute on each element of + the GeoQuerySet. + """ + return self._geom_attribute('envelope', **kwargs) - # Getting the GeometryField and quoted column. - geo_field = self.query._geo_field(field_name) - if not geo_field: - raise TypeError('Distance output only available on GeometryFields.') - geo_col = self.query._field_column(geo_field) - - # Using the field's get_db_prep_lookup() to get any needed - # transformation SQL -- we pass in a 'dummy' `contains` - # `distance_lte` lookup type. - where, params = geo_field.get_db_prep_lookup('distance_lte', (geom, 0)) - if SpatialBackend.oracle: - # The `tolerance` keyword may be used for Oracle; the tolerance is - # in meters -- a default of 5 centimeters is used. - tolerance = kwargs.get('tolerance', 0.05) - dist_select = {'distance' : '%s(%s, %s, %s)' % (DISTANCE, geo_col, where[0], tolerance)} - else: - if len(where) == 3: - # Spherical distance calculation was requested (b/c spheroid - # parameter was attached) However, the PostGIS ST_distance_spheroid() - # procedure may only do queries from point columns to point geometries - # some error checking is required. - if not isinstance(geo_field, PointField): - raise TypeError('Spherical distance calculation only supported on PointFields.') - if not isinstance(GEOSGeometry(buffer(params[0].wkb)), Point): - raise TypeError('Spherical distance calculation only supported with Point Geometry parameters') - - # Call to distance_spheroid() requires the spheroid as well. - dist_sql = '%s(%s, %s, %s)' % (SpatialBackend.distance_spheroid, geo_col, where[0], where[1]) - else: - dist_sql = '%s(%s, %s)' % (DISTANCE, geo_col, where[0]) - dist_select = {'distance' : dist_sql} - return self.extra(select=dist_select, select_params=params) - - def extent(self, field_name=None): + def extent(self, **kwargs): """ Returns the extent (aggregate) of the features in the GeoQuerySet. The extent will be returned as a 4-tuple, consisting of (xmin, ymin, xmax, ymax). """ - EXTENT = SpatialBackend.extent - if not EXTENT: - raise ImproperlyConfigured('Extent stored procedure not available.') + convert_extent = None + if SpatialBackend.postgis: + def convert_extent(box, geo_field): + # TODO: Parsing of BOX3D, Oracle support (patches welcome!) + # Box text will be something like "BOX(-90.0 30.0, -85.0 40.0)"; + # parsing out and returning as a 4-tuple. + ll, ur = box[4:-1].split(',') + xmin, ymin = map(float, ll.split()) + xmax, ymax = map(float, ur.split()) + return (xmin, ymin, xmax, ymax) + elif SpatialBackend.oracle: + def convert_extent(wkt, geo_field): + raise NotImplementedError + return self._spatial_aggregate('extent', convert_func=convert_extent, **kwargs) - # Getting the GeometryField and quoted column. - geo_field = self.query._geo_field(field_name) - if not geo_field: - raise TypeError('Extent information only available on GeometryFields.') - geo_col = self.query._field_column(geo_field) - - # Constructing the query that will select the extent. - extent_sql = '%s(%s)' % (EXTENT, geo_col) - - self.query.select = [GeomSQL(extent_sql)] - self.query.select_fields = [None] - try: - esql, params = self.query.as_sql() - except sql.datastructures.EmptyResultSet: - return None - - # Getting a cursor, executing the query, and extracting the returned - # value from the extent function. - cursor = connection.cursor() - cursor.execute(esql, params) - box = cursor.fetchone()[0] - - if box: - # TODO: Parsing of BOX3D, Oracle support (patches welcome!) - # Box text will be something like "BOX(-90.0 30.0, -85.0 40.0)"; - # parsing out and returning as a 4-tuple. - ll, ur = box[4:-1].split(',') - xmin, ymin = map(float, ll.split()) - xmax, ymax = map(float, ur.split()) - return (xmin, ymin, xmax, ymax) - else: - return None - - def gml(self, field_name=None, precision=8, version=2): + def gml(self, precision=8, version=2, **kwargs): """ Returns GML representation of the given field in a `gml` attribute on each element of the GeoQuerySet. """ - # Is GML output supported? - ASGML = SpatialBackend.as_gml - if not ASGML: - raise ImproperlyConfigured('AsGML() stored procedure not available.') - - # Getting the GeometryField and quoted column. - geo_field = self.query._geo_field(field_name) - if not geo_field: - raise TypeError('GML output only available on GeometryFields.') - geo_col = self.query._field_column(geo_field) - - if SpatialBackend.oracle: - gml_select = {'gml':'%s(%s)' % (ASGML, geo_col)} - elif SpatialBackend.postgis: + s = {'desc' : 'GML', 'procedure_args' : {'precision' : precision}} + if SpatialBackend.postgis: # PostGIS AsGML() aggregate function parameter order depends on the # version -- uggh. major, minor1, minor2 = SpatialBackend.version if major >= 1 and (minor1 > 3 or (minor1 == 3 and minor2 > 1)): - gml_select = {'gml':'%s(%s,%s,%s)' % (ASGML, version, geo_col, precision)} + procedure_fmt = '%(version)s,%(geo_col)s,%(precision)s' else: - gml_select = {'gml':'%s(%s,%s,%s)' % (ASGML, geo_col, precision, version)} + procedure_fmt = '%(geo_col)s,%(precision)s,%(version)s' + s['procedure_args'] = {'precision' : precision, 'version' : version} - # Adding GML function call to SELECT part of the SQL. - return self.extra(select=gml_select) + return self._spatial_attribute('gml', s, **kwargs) - def kml(self, field_name=None, precision=8): + def intersection(self, geom, **kwargs): """ - Returns KML representation of the given field name in a `kml` - attribute on each element of the GeoQuerySet. + Returns the spatial intersection of the Geometry field in + an `intersection` attribute on each element of this + GeoQuerySet. """ - # Is KML output supported? - ASKML = SpatialBackend.as_kml - if not ASKML: - raise ImproperlyConfigured('AsKML() stored procedure not available.') + return self._geomset_attribute('intersection', geom, **kwargs) - # Getting the GeometryField and quoted column. - geo_field = self.query._geo_field(field_name) - if not geo_field: - raise TypeError('KML output only available on GeometryFields.') + def kml(self, **kwargs): + """ + Returns KML representation of the geometry field in a `kml` + attribute on each element of this GeoQuerySet. + """ + s = {'desc' : 'KML', + 'procedure_fmt' : '%(geo_col)s,%(precision)s', + 'procedure_args' : {'precision' : kwargs.pop('precision', 8)}, + } + return self._spatial_attribute('kml', s, **kwargs) - geo_col = self.query._field_column(geo_field) + def length(self, **kwargs): + """ + Returns the length of the geometry field as a `Distance` object + stored in a `length` attribute on each element of this GeoQuerySet. + """ + return self._distance_attribute('length', None, **kwargs) - # Adding the AsKML function call to SELECT part of the SQL. - return self.extra(select={'kml':'%s(%s,%s)' % (ASKML, geo_col, precision)}) + def make_line(self, **kwargs): + """ + Creates a linestring from all of the PointField geometries in the + this GeoQuerySet and returns it. This is a spatial aggregate + method, and thus returns a geometry rather than a GeoQuerySet. + """ + kwargs['geo_field_type'] = PointField + kwargs['agg_field'] = GeometryField + return self._spatial_aggregate('make_line', **kwargs) - def transform(self, field_name=None, srid=4326): + def mem_size(self, **kwargs): + """ + Returns the memory size (number of bytes) that the geometry field takes + in a `mem_size` attribute on each element of this GeoQuerySet. + """ + return self._spatial_attribute('mem_size', {}, **kwargs) + + def num_geom(self, **kwargs): + """ + Returns the number of geometries if the field is a + GeometryCollection or Multi* Field in a `num_geom` + attribute on each element of this GeoQuerySet; otherwise + the sets with None. + """ + return self._spatial_attribute('num_geom', {}, **kwargs) + + def num_points(self, **kwargs): + """ + Returns the number of points in the first linestring in the + Geometry field in a `num_points` attribute on each element of + this GeoQuerySet; otherwise sets with None. + """ + return self._spatial_attribute('num_points', {}, **kwargs) + + def perimeter(self, **kwargs): + """ + Returns the perimeter of the geometry field as a `Distance` object + stored in a `perimeter` attribute on each element of this GeoQuerySet. + """ + return self._distance_attribute('perimeter', None, **kwargs) + + def point_on_surface(self, **kwargs): + """ + Returns a Point geometry guaranteed to lie on the surface of the + Geometry field in a `point_on_surface` attribute on each element + of this GeoQuerySet; otherwise sets with None. + """ + return self._geom_attribute('point_on_surface', **kwargs) + + def scale(self, x, y, z=0.0, **kwargs): + """ + Scales the geometry to a new size by multiplying the ordinates + with the given x,y,z scale factors. + """ + s = {'procedure_fmt' : '%(geo_col)s,%(x)s,%(y)s,%(z)s', + 'procedure_args' : {'x' : x, 'y' : y, 'z' : z}, + 'select_field' : GeomField(), + } + return self._spatial_attribute('scale', s, **kwargs) + + def svg(self, **kwargs): + """ + Returns SVG representation of the geographic field in a `svg` + attribute on each element of this GeoQuerySet. + """ + s = {'desc' : 'SVG', + 'procedure_fmt' : '%(geo_col)s,%(rel)s,%(precision)s', + 'procedure_args' : {'rel' : int(kwargs.pop('relative', 0)), + 'precision' : kwargs.pop('precision', 8)}, + } + return self._spatial_attribute('svg', s, **kwargs) + + def sym_difference(self, geom, **kwargs): + """ + Returns the symmetric difference of the geographic field in a + `sym_difference` attribute on each element of this GeoQuerySet. + """ + return self._geomset_attribute('sym_difference', geom, **kwargs) + + def translate(self, x, y, z=0.0, **kwargs): + """ + Translates the geometry to a new location using the given numeric + parameters as offsets. + """ + s = {'procedure_fmt' : '%(geo_col)s,%(x)s,%(y)s,%(z)s', + 'procedure_args' : {'x' : x, 'y' : y, 'z' : z}, + 'select_field' : GeomField(), + } + return self._spatial_attribute('translate', s, **kwargs) + + def transform(self, srid=4326, **kwargs): """ Transforms the given geometry field to the given SRID. If no SRID is provided, the transformation will default to using 4326 (WGS84). """ - # Getting the geographic field. - TRANSFORM = SpatialBackend.transform - if not TRANSFORM: - raise ImproperlyConfigured('Transform stored procedure not available.') + if not isinstance(srid, (int, long)): + raise TypeError('An integer SRID must be provided.') + field_name = kwargs.get('field_name', None) + tmp, geo_field = self._spatial_setup('transform', field_name=field_name) - # `field_name` is first for backwards compatibility; but we want to - # be able to take integer srid as first parameter. - if isinstance(field_name, (int, long)): - srid = field_name - field_name = None - - # Getting the GeometryField and quoted column. - geo_field = self.query._geo_field(field_name) - if not geo_field: - raise TypeError('%s() only available for GeometryFields' % TRANSFORM) - - # Getting the selection SQL for the given geograph + # Getting the selection SQL for the given geographic field. field_col = self._geocol_select(geo_field, field_name) # Why cascading substitutions? Because spatial backends like @@ -206,63 +268,334 @@ class GeoQuerySet(QuerySet): # Setting the key for the field's column with the custom SELECT SQL to # override the geometry column returned from the database. - if SpatialBackend.oracle: - custom_sel = '%s(%s, %s)' % (TRANSFORM, geo_col, srid) - self.query.ewkt = srid - else: - custom_sel = '%s(%s, %s)' % (TRANSFORM, geo_col, srid) + custom_sel = '%s(%s, %s)' % (SpatialBackend.transform, geo_col, srid) + # TODO: Should we have this as an alias? + # custom_sel = '(%s(%s, %s)) AS %s' % (SpatialBackend.transform, geo_col, srid, qn(geo_field.name)) + self.query.transformed_srid = srid # So other GeoQuerySet methods self.query.custom_select[geo_field] = custom_sel return self._clone() - def union(self, field_name=None, tolerance=0.0005): + def union(self, geom, **kwargs): + """ + Returns the union of the geographic field with the given + Geometry in a `union` attribute on each element of this GeoQuerySet. + """ + return self._geomset_attribute('union', geom, **kwargs) + + def unionagg(self, **kwargs): """ Performs an aggregate union on the given geometry field. Returns None if the GeoQuerySet is empty. The `tolerance` keyword is for Oracle backends only. """ - # Making sure backend supports the Union stored procedure - UNION = SpatialBackend.union - if not UNION: - raise ImproperlyConfigured('Union stored procedure not available.') + kwargs['agg_field'] = GeometryField + return self._spatial_aggregate('unionagg', **kwargs) - # Getting the GeometryField and quoted column. + ### Private API -- Abstracted DRY routines. ### + def _spatial_setup(self, att, aggregate=False, desc=None, field_name=None, geo_field_type=None): + """ + Performs set up for executing the spatial function. + """ + # Does the spatial backend support this? + func = getattr(SpatialBackend, att, False) + if desc is None: desc = att + if not func: raise ImproperlyConfigured('%s stored procedure not available.' % desc) + + # Initializing the procedure arguments. + procedure_args = {'function' : func} + + # Is there a geographic field in the model to perform this + # operation on? geo_field = self.query._geo_field(field_name) if not geo_field: - raise TypeError('Aggregate Union only available on GeometryFields.') - geo_col = self.query._field_column(geo_field) + raise TypeError('%s output only available on GeometryFields.' % func) - # Replacing the select with a call to the ST_Union stored procedure - # on the geographic field column. - if SpatialBackend.oracle: - union_sql = '%s' % SpatialBackend.select - union_sql = union_sql % ('%s(SDOAGGRTYPE(%s,%s))' % (UNION, geo_col, tolerance)) - else: - union_sql = '%s(%s)' % (UNION, geo_col) + # If the `geo_field_type` keyword was used, then enforce that + # type limitation. + if not geo_field_type is None and not isinstance(geo_field, geo_field_type): + raise TypeError('"%s" stored procedures may only be called on %ss.' % (func, geo_field_type.__name__)) - # Only want the union SQL to be selected. - self.query.select = [GeomSQL(union_sql)] - self.query.select_fields = [GeometryField] - try: - usql, params = self.query.as_sql() - except sql.datastructures.EmptyResultSet: - return None + # Setting the procedure args. + procedure_args['geo_col'] = self._geocol_select(geo_field, field_name, aggregate) - # Getting a cursor, executing the query. - cursor = connection.cursor() - cursor.execute(usql, params) - if SpatialBackend.oracle: - # On Oracle have to read out WKT from CLOB first. - clob = cursor.fetchone()[0] - if clob: u = clob.read() - else: u = None - else: - u = cursor.fetchone()[0] + return procedure_args, geo_field + + def _spatial_aggregate(self, att, field_name=None, + agg_field=None, convert_func=None, + geo_field_type=None, tolerance=0.0005): + """ + DRY routine for calling aggregate spatial stored procedures and + returning their result to the caller of the function. + """ + # Constructing the setup keyword arguments. + setup_kwargs = {'aggregate' : True, + 'field_name' : field_name, + 'geo_field_type' : geo_field_type, + } + procedure_args, geo_field = self._spatial_setup(att, **setup_kwargs) - if u: return GEOSGeometry(u) - else: return None + if SpatialBackend.oracle: + procedure_args['tolerance'] = tolerance + # Adding in selection SQL for Oracle geometry columns. + if agg_field is GeometryField: + agg_sql = '%s' % SpatialBackend.select + else: + agg_sql = '%s' + agg_sql = agg_sql % ('%(function)s(SDOAGGRTYPE(%(geo_col)s,%(tolerance)s))' % procedure_args) + else: + agg_sql = '%(function)s(%(geo_col)s)' % procedure_args - # Private API utilities, subject to change. - def _geocol_select(self, geo_field, field_name): + # Wrapping our selection SQL in `GeomSQL` to bypass quoting, and + # specifying the type of the aggregate field. + self.query.select = [GeomSQL(agg_sql)] + self.query.select_fields = [agg_field] + + try: + # `asql` => not overriding `sql` module. + asql, params = self.query.as_sql() + except sql.datastructures.EmptyResultSet: + return None + + # Getting a cursor, executing the query, and extracting the returned + # value from the aggregate function. + cursor = connection.cursor() + cursor.execute(asql, params) + result = cursor.fetchone()[0] + + # If the `agg_field` is specified as a GeometryField, then autmatically + # set up the conversion function. + if agg_field is GeometryField and not callable(convert_func): + if SpatialBackend.postgis: + def convert_geom(hex, geo_field): + if hex: return SpatialBackend.Geometry(hex) + else: return None + elif SpatialBackend.oracle: + def convert_geom(clob, geo_field): + if clob: return SpatialBackend.Geometry(clob.read(), geo_field._srid) + else: return None + convert_func = convert_geom + + # Returning the callback function evaluated on the result culled + # from the executed cursor. + if callable(convert_func): + return convert_func(result, geo_field) + else: + return result + + def _spatial_attribute(self, att, settings, field_name=None, model_att=None): + """ + DRY routine for calling a spatial stored procedure on a geometry column + and attaching its output as an attribute of the model. + + Arguments: + att: + The name of the spatial attribute that holds the spatial + SQL function to call. + + settings: + Dictonary of internal settings to customize for the spatial procedure. + + Public Keyword Arguments: + + field_name: + The name of the geographic field to call the spatial + function on. May also be a lookup to a geometry field + as part of a foreign key relation. + + model_att: + The name of the model attribute to attach the output of + the spatial function to. + """ + # Default settings. + settings.setdefault('desc', None) + settings.setdefault('geom_args', ()) + settings.setdefault('geom_field', None) + settings.setdefault('procedure_args', {}) + settings.setdefault('procedure_fmt', '%(geo_col)s') + settings.setdefault('select_params', []) + + # Performing setup for the spatial column, unless told not to. + if settings.get('setup', True): + default_args, geo_field = self._spatial_setup(att, desc=settings['desc'], field_name=field_name) + for k, v in default_args.iteritems(): settings['procedure_args'].setdefault(k, v) + else: + geo_field = settings['geo_field'] + + # The attribute to attach to the model. + if not isinstance(model_att, basestring): model_att = att + + # Special handling for any argument that is a geometry. + for name in settings['geom_args']: + # Using the field's get_db_prep_lookup() to get any needed + # transformation SQL -- we pass in a 'dummy' `contains` lookup. + where, params = geo_field.get_db_prep_lookup('contains', settings['procedure_args'][name]) + # Replacing the procedure format with that of any needed + # transformation SQL. + old_fmt = '%%(%s)s' % name + new_fmt = where[0] % '%%s' + settings['procedure_fmt'] = settings['procedure_fmt'].replace(old_fmt, new_fmt) + settings['select_params'].extend(params) + + # Getting the format for the stored procedure. + fmt = '%%(function)s(%s)' % settings['procedure_fmt'] + + # If the result of this function needs to be converted. + if settings.get('select_field', False): + sel_fld = settings['select_field'] + if isinstance(sel_fld, GeomField) and SpatialBackend.select: + self.query.custom_select[model_att] = SpatialBackend.select + self.query.extra_select_fields[model_att] = sel_fld + + # Finally, setting the extra selection attribute with + # the format string expanded with the stored procedure + # arguments. + return self.extra(select={model_att : fmt % settings['procedure_args']}, + select_params=settings['select_params']) + + def _distance_attribute(self, func, geom=None, tolerance=0.05, spheroid=False, **kwargs): + """ + DRY routine for GeoQuerySet distance attribute routines. + """ + # Setting up the distance procedure arguments. + procedure_args, geo_field = self._spatial_setup(func, field_name=kwargs.get('field_name', None)) + + # If geodetic defaulting distance attribute to meters (Oracle and + # PostGIS spherical distances return meters). Otherwise, use the + # units of the geometry field. + if geo_field.geodetic: + dist_att = 'm' + else: + dist_att = Distance.unit_attname(geo_field._unit_name) + + # Shortcut booleans for what distance function we're using. + distance = func == 'distance' + length = func == 'length' + perimeter = func == 'perimeter' + if not (distance or length or perimeter): + raise ValueError('Unknown distance function: %s' % func) + + # The field's get_db_prep_lookup() is used to get any + # extra distance parameters. Here we set up the + # parameters that will be passed in to field's function. + lookup_params = [geom or 'POINT (0 0)', 0] + + # If the spheroid calculation is desired, either by the `spheroid` + # keyword or wehn calculating the length of geodetic field, make + # sure the 'spheroid' distance setting string is passed in so we + # get the correct spatial stored procedure. + if spheroid or (SpatialBackend.postgis and geo_field.geodetic and length): + lookup_params.append('spheroid') + where, params = geo_field.get_db_prep_lookup('distance_lte', lookup_params) + + # The `geom_args` flag is set to true if a geometry parameter was + # passed in. + geom_args = bool(geom) + + if SpatialBackend.oracle: + if distance: + procedure_fmt = '%(geo_col)s,%(geom)s,%(tolerance)s' + elif length or perimeter: + procedure_fmt = '%(geo_col)s,%(tolerance)s' + procedure_args['tolerance'] = tolerance + else: + # Getting whether this field is in units of degrees since the field may have + # been transformed via the `transform` GeoQuerySet method. + if self.query.transformed_srid: + u, unit_name, s = get_srid_info(self.query.transformed_srid) + geodetic = unit_name in geo_field.geodetic_units + else: + geodetic = geo_field.geodetic + + if distance: + if self.query.transformed_srid: + # Setting the `geom_args` flag to false because we want to handle + # transformation SQL here, rather than the way done by default + # (which will transform to the original SRID of the field rather + # than to what was transformed to). + geom_args = False + procedure_fmt = '%s(%%(geo_col)s, %s)' % (SpatialBackend.transform, self.query.transformed_srid) + if geom.srid is None or geom.srid == self.query.transformed_srid: + # If the geom parameter srid is None, it is assumed the coordinates + # are in the transformed units. A placeholder is used for the + # geometry parameter. + procedure_fmt += ', %%s' + else: + # We need to transform the geom to the srid specified in `transform()`, + # so wrapping the geometry placeholder in transformation SQL. + procedure_fmt += ', %s(%%%%s, %s)' % (SpatialBackend.transform, self.query.transformed_srid) + else: + # `transform()` was not used on this GeoQuerySet. + procedure_fmt = '%(geo_col)s,%(geom)s' + + if geodetic: + # Spherical distance calculation is needed (because the geographic + # field is geodetic). However, the PostGIS ST_distance_sphere/spheroid() + # procedures may only do queries from point columns to point geometries + # some error checking is required. + if not isinstance(geo_field, PointField): + raise TypeError('Spherical distance calculation only supported on PointFields.') + if not str(SpatialBackend.Geometry(buffer(params[0].wkb)).geom_type) == 'Point': + raise TypeError('Spherical distance calculation only supported with Point Geometry parameters') + # The `function` procedure argument needs to be set differently for + # geodetic distance calculations. + if spheroid: + # Call to distance_spheroid() requires spheroid param as well. + procedure_fmt += ',%(spheroid)s' + procedure_args.update({'function' : SpatialBackend.distance_spheroid, 'spheroid' : where[1]}) + else: + procedure_args.update({'function' : SpatialBackend.distance_sphere}) + elif length or perimeter: + procedure_fmt = '%(geo_col)s' + if geodetic and length: + # There's no `length_sphere` + procedure_fmt += ',%(spheroid)s' + procedure_args.update({'function' : SpatialBackend.length_spheroid, 'spheroid' : where[1]}) + + # Setting up the settings for `_spatial_attribute`. + s = {'select_field' : DistanceField(dist_att), + 'setup' : False, + 'geo_field' : geo_field, + 'procedure_args' : procedure_args, + 'procedure_fmt' : procedure_fmt, + } + if geom_args: + s['geom_args'] = ('geom',) + s['procedure_args']['geom'] = geom + elif geom: + # The geometry is passed in as a parameter because we handled + # transformation conditions in this routine. + s['select_params'] = [SpatialBackend.Adaptor(geom)] + return self._spatial_attribute(func, s, **kwargs) + + def _geom_attribute(self, func, tolerance=0.05, **kwargs): + """ + DRY routine for setting up a GeoQuerySet method that attaches a + Geometry attribute (e.g., `centroid`, `point_on_surface`). + """ + s = {'select_field' : GeomField(),} + if SpatialBackend.oracle: + s['procedure_fmt'] = '%(geo_col)s,%(tolerance)s' + s['procedure_args'] = {'tolerance' : tolerance} + return self._spatial_attribute(func, s, **kwargs) + + def _geomset_attribute(self, func, geom, tolerance=0.05, **kwargs): + """ + DRY routine for setting up a GeoQuerySet method that attaches a + Geometry attribute and takes a Geoemtry parameter. This is used + for geometry set-like operations (e.g., intersection, difference, + union, sym_difference). + """ + s = {'geom_args' : ('geom',), + 'select_field' : GeomField(), + 'procedure_fmt' : '%(geo_col)s,%(geom)s', + 'procedure_args' : {'geom' : geom}, + } + if SpatialBackend.oracle: + s['procedure_fmt'] += ',%(tolerance)s' + s['procedure_args']['tolerance'] = tolerance + return self._spatial_attribute(func, s, **kwargs) + + def _geocol_select(self, geo_field, field_name, aggregate=False): """ Helper routine for constructing the SQL to select the geographic column. Takes into account if the geographic field is in a @@ -274,6 +607,8 @@ class GeoQuerySet(QuerySet): # (e.g., if 'location__point' was given as the field name). self.query.add_select_related([field_name]) self.query.pre_sql_setup() + # Can't non-aggregate and aggregate selections together. + if aggregate: self.query.aggregate = True rel_table, rel_col = self.query.related_select_cols[self.query.related_select_fields.index(geo_field)] return self.query._field_column(geo_field, rel_table) else: diff --git a/django/contrib/gis/db/models/sql/__init__.py b/django/contrib/gis/db/models/sql/__init__.py index 8e87f6b2a2..4a66b41664 100644 --- a/django/contrib/gis/db/models/sql/__init__.py +++ b/django/contrib/gis/db/models/sql/__init__.py @@ -1,2 +1,2 @@ -from django.contrib.gis.db.models.sql.query import GeoQuery +from django.contrib.gis.db.models.sql.query import AreaField, DistanceField, GeomField, GeoQuery from django.contrib.gis.db.models.sql.where import GeoWhereNode diff --git a/django/contrib/gis/db/models/sql/query.py b/django/contrib/gis/db/models/sql/query.py index 9cd691bea9..f1b4de546f 100644 --- a/django/contrib/gis/db/models/sql/query.py +++ b/django/contrib/gis/db/models/sql/query.py @@ -6,6 +6,7 @@ from django.db.models.fields.related import ForeignKey from django.contrib.gis.db.backend import SpatialBackend from django.contrib.gis.db.models.fields import GeometryField from django.contrib.gis.db.models.sql.where import GeoWhereNode +from django.contrib.gis.measure import Area, Distance # Valid GIS query types. ALL_TERMS = sql.constants.QUERY_TERMS.copy() @@ -24,14 +25,19 @@ class GeoQuery(sql.Query): # The following attributes are customized for the GeoQuerySet. # The GeoWhereNode and SpatialBackend classes contain backend-specific # routines and functions. + self.aggregate = False self.custom_select = {} - self.ewkt = None + self.transformed_srid = None + self.extra_select_fields = {} def clone(self, *args, **kwargs): obj = super(GeoQuery, self).clone(*args, **kwargs) - # Customized selection dictionary and EWKT flag have to be added to obj. + # Customized selection dictionary and transformed srid flag have + # to also be added to obj. + obj.aggregate = self.aggregate obj.custom_select = self.custom_select.copy() - obj.ewkt = self.ewkt + obj.transformed_srid = self.transformed_srid + obj.extra_select_fields = self.extra_select_fields.copy() return obj def get_columns(self, with_aliases=False): @@ -49,7 +55,8 @@ class GeoQuery(sql.Query): """ qn = self.quote_name_unless_alias qn2 = self.connection.ops.quote_name - result = ['(%s) AS %s' % (col, qn2(alias)) for alias, col in self.extra_select.iteritems()] + result = ['(%s) AS %s' % (self.get_extra_select_format(alias) % col, qn2(alias)) + for alias, col in self.extra_select.iteritems()] aliases = set(self.extra_select.keys()) if with_aliases: col_aliases = aliases.copy() @@ -80,17 +87,18 @@ class GeoQuery(sql.Query): result.extend(cols) aliases.update(new_aliases) # This loop customized for GeoQuery. - for (table, col), field in izip(self.related_select_cols, self.related_select_fields): - r = self.get_field_select(field, table) - if with_aliases and col in col_aliases: - c_alias = 'Col%d' % len(col_aliases) - result.append('%s AS %s' % (r, c_alias)) - aliases.add(c_alias) - col_aliases.add(c_alias) - else: - result.append(r) - aliases.add(r) - col_aliases.add(col) + if not self.aggregate: + for (table, col), field in izip(self.related_select_cols, self.related_select_fields): + r = self.get_field_select(field, table) + if with_aliases and col in col_aliases: + c_alias = 'Col%d' % len(col_aliases) + result.append('%s AS %s' % (r, c_alias)) + aliases.add(c_alias) + col_aliases.add(c_alias) + else: + result.append(r) + aliases.add(r) + col_aliases.add(col) self._select_aliases = aliases return result @@ -139,7 +147,51 @@ class GeoQuery(sql.Query): col_aliases.add(field.column) return result, aliases + def resolve_columns(self, row, fields=()): + """ + This routine is necessary so that distances and geometries returned + from extra selection SQL get resolved appropriately into Python + objects. + """ + values = [] + aliases = self.extra_select.keys() + index_start = len(aliases) + values = [self.convert_values(v, self.extra_select_fields.get(a, None)) + for v, a in izip(row[:index_start], aliases)] + if SpatialBackend.oracle: + # This is what happens normally in Oracle's `resolve_columns`. + for value, field in izip(row[index_start:], fields): + values.append(self.convert_values(value, field)) + else: + values.extend(row[index_start:]) + return values + + def convert_values(self, value, field): + """ + Using the same routines that Oracle does we can convert our + extra selection objects into Geometry and Distance objects. + TODO: Laziness. + """ + if SpatialBackend.oracle: + # Running through Oracle's first. + value = super(GeoQuery, self).convert_values(value, field) + if isinstance(field, DistanceField): + # Using the field's distance attribute, can instantiate + # `Distance` with the right context. + value = Distance(**{field.distance_att : value}) + elif isinstance(field, AreaField): + value = Area(**{field.area_att : value}) + elif isinstance(field, GeomField): + value = SpatialBackend.Geometry(value) + return value + #### Routines unique to GeoQuery #### + def get_extra_select_format(self, alias): + sel_fmt = '%s' + if alias in self.custom_select: + sel_fmt = sel_fmt % self.custom_select[alias] + return sel_fmt + def get_field_select(self, fld, alias=None): """ Returns the SELECT SQL string for the given field. Figures out @@ -173,8 +225,8 @@ class GeoQuery(sql.Query): # the SRID is prefixed to the returned WKT to ensure that the # transformed geometries have an SRID different than that of the # field -- this is only used by `transform` for Oracle backends. - if self.ewkt and SpatialBackend.oracle: - sel_fmt = "'SRID=%d;'||%s" % (self.ewkt, sel_fmt) + if self.transformed_srid and SpatialBackend.oracle: + sel_fmt = "'SRID=%d;'||%s" % (self.transformed_srid, sel_fmt) else: sel_fmt = '%s' return sel_fmt @@ -188,8 +240,8 @@ class GeoQuery(sql.Query): Related model field strings like 'address__point', may also be used. - If a GeometryField exists according to the given name - parameter it will be returned, otherwise returns False. + If a GeometryField exists according to the given name parameter + it will be returned, otherwise returns False. """ if isinstance(name_param, basestring): # This takes into account the situation where the name is a @@ -242,3 +294,17 @@ class GeoQuery(sql.Query): # Otherwise, check by the given field name -- which may be # a lookup to a _related_ geographic field. return self._check_geo_field(self.model, field_name) + +### Field Classes for `convert_values` #### +class AreaField(object): + def __init__(self, area_att): + self.area_att = area_att + +class DistanceField(object): + def __init__(self, distance_att): + self.distance_att = distance_att + +# Rather than use GeometryField (which requires a SQL query +# upon instantiation), use this lighter weight class. +class GeomField(object): + pass diff --git a/django/contrib/gis/db/models/sql/where.py b/django/contrib/gis/db/models/sql/where.py index 3c37232cfd..d9c75eaec3 100644 --- a/django/contrib/gis/db/models/sql/where.py +++ b/django/contrib/gis/db/models/sql/where.py @@ -1,5 +1,5 @@ from django.db.models.sql.where import WhereNode -from django.contrib.gis.db.backend import get_geo_where_clause, GIS_TERMS +from django.contrib.gis.db.backend import get_geo_where_clause, SpatialBackend class GeoWhereNode(WhereNode): """ @@ -9,7 +9,7 @@ class GeoWhereNode(WhereNode): def make_atom(self, child, qn): table_alias, name, field, lookup_type, value = child if hasattr(field, '_geom'): - if lookup_type in GIS_TERMS: + if lookup_type in SpatialBackend.gis_terms: # Getting the geographic where clause; substitution parameters # will be populated in the GeoFieldSQL object returned by the # GeometryField. diff --git a/django/contrib/gis/measure.py b/django/contrib/gis/measure.py index 5af5ab40f6..ea2e57c1ca 100644 --- a/django/contrib/gis/measure.py +++ b/django/contrib/gis/measure.py @@ -30,14 +30,60 @@ Distance and Area objects to allow for sensible and convienient calculation and conversions. -Author: Robert Coup +Author: Robert Coup, Justin Bronn Inspired by GeoPy (http://exogen.case.edu/projects/geopy/) and Geoff Biggs' PhD work on dimensioned units for robotics. """ +__all__ = ['A', 'Area', 'D', 'Distance'] from decimal import Decimal -class Distance(object): +class MeasureBase(object): + def default_units(self, kwargs): + """ + Return the unit value and the the default units specified + from the given keyword arguments dictionary. + """ + val = 0.0 + for unit, value in kwargs.iteritems(): + if unit in self.UNITS: + val += self.UNITS[unit] * value + default_unit = unit + elif unit in self.ALIAS: + u = self.ALIAS[unit] + val += self.UNITS[u] * value + default_unit = u + else: + lower = unit.lower() + if lower in self.UNITS: + val += self.UNITS[lower] * value + default_unit = lower + elif lower in self.LALIAS: + u = self.LALIAS[lower] + val += self.UNITS[u] * value + default_unit = u + else: + raise AttributeError('Unknown unit type: %s' % unit) + return val, default_unit + + @classmethod + def unit_attname(cls, unit_str): + """ + Retrieves the unit attribute name for the given unit string. + For example, if the given unit string is 'metre', 'm' would be returned. + An exception is raised if an attribute cannot be found. + """ + lower = unit_str.lower() + if unit_str in cls.UNITS: + return unit_str + elif lower in cls.UNITS: + return lower + elif lower in cls.LALIAS: + return cls.LALIAS[lower] + else: + raise Exception('Could not find a unit keyword associated with "%s"' % unit_str) + +class Distance(MeasureBase): UNITS = { 'chain' : 20.1168, 'chain_benoit' : 20.116782, @@ -53,7 +99,6 @@ class Distance(object): 'fathom' : 1.8288, 'ft': 0.3048, 'german_m' : 1.0000135965, - 'grad' : 0.0157079632679, 'gold_coast_ft' : 0.304799710181508, 'indian_yd' : 0.914398530744, 'in' : 0.0254, @@ -92,9 +137,10 @@ class Distance(object): 'British chain (Sears 1922)' : 'british_chain_sears', 'British chain (Sears 1922 truncated)' : 'british_chain_sears_truncated', 'British foot (Sears 1922)' : 'british_ft', + 'British foot' : 'british_ft', 'British yard (Sears 1922)' : 'british_yd', + 'British yard' : 'british_yd', "Clarke's Foot" : 'clarke_ft', - "Clarke's foot" : 'clarke_ft', "Clarke's link" : 'clarke_link', 'Chain (Benoit)' : 'chain_benoit', 'Chain (Sears)' : 'chain_sears', @@ -111,33 +157,11 @@ class Distance(object): 'Yard (Indian)' : 'indian_yd', 'Yard (Sears)' : 'sears_yd' } - REV_ALIAS = dict((value, key) for key, value in ALIAS.items()) + LALIAS = dict([(k.lower(), v) for k, v in ALIAS.items()]) def __init__(self, default_unit=None, **kwargs): # The base unit is in meters. - self.m = 0.0 - self._default_unit = 'm' - - for unit,value in kwargs.items(): - if unit in self.UNITS: - self.m += self.UNITS[unit] * value - self._default_unit = unit - elif unit in self.ALIAS: - u = self.ALIAS[unit] - self.m += self.UNITS[u] * value - self._default_unit = u - else: - lower = unit.lower() - if lower in self.UNITS: - self.m += self.UNITS[lower] * value - self._default_unit = lower - elif lower in self.ALIAS: - u = self.ALIAS[lower] - self.m += self.UNITS[u] * value - self._default_unit = u - else: - raise AttributeError('Unknown unit type: %s' % unit) - + self.m, self._default_unit = self.default_units(kwargs) if default_unit and isinstance(default_unit, str): self._default_unit = default_unit @@ -216,49 +240,15 @@ class Distance(object): def __nonzero__(self): return bool(self.m) - @classmethod - def unit_attname(cls, unit_str): - """ - Retrieves the unit attribute name for the given unit string. - For example, if the given unit string is 'metre', 'm' would be returned. - An exception is raised if an attribute cannot be found. - """ - lower = unit_str.lower() - - if unit_str in cls.UNITS: - return unit_str - elif lower in cls.UNITS: - return lower - elif unit_str in cls.ALIAS: - return cls.ALIAS[unit_str] - elif lower in cls.ALIAS: - return cls.ALIAS[lower] - else: - raise Exception('Could not find a unit keyword associated with "%s"' % unit_str) - -class Area(object): - # TODO: Add units from above. - UNITS = { - 'sq_m': 1.0, - 'sq_km': 1000000.0, - 'sq_mi': 2589988.110336, - 'sq_ft': 0.09290304, - 'sq_yd': 0.83612736, - 'sq_nm': 3429904.0, - } +class Area(MeasureBase): + # Getting the square units values and the alias dictionary. + UNITS = dict([('sq_%s' % k, v ** 2) for k, v in Distance.UNITS.items()]) + ALIAS = dict([(k, 'sq_%s' % v) for k, v in Distance.ALIAS.items()]) + LALIAS = dict([(k.lower(), v) for k, v in ALIAS.items()]) def __init__(self, default_unit=None, **kwargs): - self.sq_m = 0.0 - self._default_unit = 'sq_m' - - for unit,value in kwargs.items(): - if unit in self.UNITS: - self.sq_m += self.UNITS[unit] * value - self._default_unit = unit - else: - raise AttributeError('Unknown unit type: ' + unit) - - if default_unit: + self.sq_m, self._default_unit = self.default_units(kwargs) + if default_unit and isinstance(default_unit, str): self._default_unit = default_unit def __getattr__(self, name): @@ -333,7 +323,6 @@ class Area(object): def __nonzero__(self): return bool(self.sq_m) - # Shortcuts D = Distance diff --git a/django/contrib/gis/models.py b/django/contrib/gis/models.py index 9d923760a3..8bf8ef4964 100644 --- a/django/contrib/gis/models.py +++ b/django/contrib/gis/models.py @@ -209,9 +209,54 @@ class SpatialRefSysMixin(object): return unicode(self.wkt) # The SpatialRefSys and GeometryColumns models +_srid_info = True if settings.DATABASE_ENGINE == 'postgresql_psycopg2': from django.contrib.gis.db.backend.postgis.models import GeometryColumns, SpatialRefSys elif settings.DATABASE_ENGINE == 'oracle': from django.contrib.gis.db.backend.oracle.models import GeometryColumns, SpatialRefSys else: - pass + _srid_info = False + +if _srid_info: + def get_srid_info(srid): + """ + Returns the units, unit name, and spheroid WKT associated with the + given SRID from the `spatial_ref_sys` (or equivalent) spatial database + table. We use a database cursor to execute the query because this + function is used when it is not possible to use the ORM (for example, + during field initialization). + """ + from django.db import connection + # Getting the spatial reference WKT associated with the SRID from the + # `spatial_ref_sys` (or equivalent) spatial database table. + # + # The following doesn't work: SpatialRefSys.objects.get(srid=srid) + # Why? `syncdb` fails to recognize installed geographic models when there's + # an ORM query instantiated within a model field. + cur = connection.cursor() + qn = connection.ops.quote_name + stmt = 'SELECT %(table)s.%(wkt_col)s FROM %(table)s WHERE (%(table)s.%(srid_col)s = %(srid)s)' + stmt = stmt % {'table' : qn(SpatialRefSys._meta.db_table), + 'wkt_col' : qn(SpatialRefSys.wkt_col()), + 'srid_col' : qn('srid'), + 'srid' : srid, + } + cur.execute(stmt) + srs_wkt = cur.fetchone()[0] + if srs_wkt is None: + raise ValueError('Failed to find Spatial Reference System entry corresponding to SRID=%s' % srid) + + # Getting metadata associated with the spatial reference system identifier. + # Specifically, getting the unit information and spheroid information + # (both required for distance queries). + unit, unit_name = SpatialRefSys.get_units(srs_wkt) + spheroid = SpatialRefSys.get_spheroid(srs_wkt) + return unit, unit_name, spheroid +else: + def get_srid_info(srid): + """ + Dummy routine for the backends that do not have the OGC required + spatial metadata tables (like MySQL). + """ + return None, None, None + diff --git a/django/contrib/gis/tests/__init__.py b/django/contrib/gis/tests/__init__.py index b3afe9baa7..44465f4f23 100644 --- a/django/contrib/gis/tests/__init__.py +++ b/django/contrib/gis/tests/__init__.py @@ -11,7 +11,7 @@ from django.conf import settings if not settings._target: settings.configure() # Tests that require use of a spatial database (e.g., creation of models) -test_models = ['geoapp', 'relatedapp'] +test_models = ['geoapp',] # Tests that do not require setting up and tearing down a spatial database. test_suite_names = [ @@ -20,11 +20,14 @@ test_suite_names = [ ] if HAS_GDAL: if oracle: - # TODO: There is a problem with the `syncdb` SQL for the LayerMapping - # tests on Oracle. - test_models += ['distapp'] - elif postgis: + # TODO: There's a problem with `select_related` and GeoQuerySet on + # Oracle -- e.g., GeoModel.objects.distance(geom, field_name='fk__point') + # doesn't work so we don't test `relatedapp`. test_models += ['distapp', 'layermap'] + elif postgis: + test_models += ['distapp', 'layermap', 'relatedapp'] + elif mysql: + test_models += ['relatedapp'] test_suite_names += [ 'test_gdal_driver', diff --git a/django/contrib/gis/tests/distapp/data.py b/django/contrib/gis/tests/distapp/data.py index 6ccee717c6..fefe313905 100644 --- a/django/contrib/gis/tests/distapp/data.py +++ b/django/contrib/gis/tests/distapp/data.py @@ -11,16 +11,23 @@ au_cities = (('Wollongong', 150.902, -34.4245), ('Hillsdale', 151.231341, -33.952685), ) -stx_cities = (('Downtown Houston', 951640.547328, 4219369.26172), - ('West University Place', 943547.922328, 4213623.65345), - ('Southside Place', 944704.643307, 4212768.87617), - ('Bellaire', 942595.669129, 4212686.72583), - ('Pearland', 959674.616506, 4197465.6526), - ('Galveston', 1008151.16007, 4170027.47655), - ('Sealy', 874859.286808, 4219186.8641), - ('San Antonio', 649173.910483, 4176413.27786), - ('Round Rock', 726846.03695, 4297160.99715), - ('Saint Hedwig', 677644.649952, 4175467.06744), +stx_cities = (('Downtown Houston', -95.363151, 29.763374), + ('West University Place', -95.448601, 29.713803), + ('Southside Place', -95.436920, 29.705777), + ('Bellaire', -95.458732, 29.705614), + ('Pearland', -95.287303, 29.563568), + ('Galveston', -94.797489, 29.301336), + ('Sealy', -96.156952, 29.780918), + ('San Antonio', -98.493183, 29.424170), + ('Saint Hedwig', -98.199820, 29.414197), ) +# Data from U.S. Census ZCTA cartographic boundary file for Texas (`zt48_d00.shp`). +stx_zips = (('77002', 'POLYGON ((-95.365015 29.772327, -95.362415 29.772327, -95.360915 29.771827, -95.354615 29.771827, -95.351515 29.772527, -95.350915 29.765327, -95.351015 29.762436, -95.350115 29.760328, -95.347515 29.758528, -95.352315 29.753928, -95.356415 29.756328, -95.358215 29.754028, -95.360215 29.756328, -95.363415 29.757128, -95.364014 29.75638, -95.363415 29.753928, -95.360015 29.751828, -95.361815 29.749528, -95.362715 29.750028, -95.367516 29.744128, -95.369316 29.745128, -95.373916 29.744128, -95.380116 29.738028, -95.387916 29.727929, -95.388516 29.729629, -95.387916 29.732129, -95.382916 29.737428, -95.376616 29.742228, -95.372616 29.747228, -95.378601 29.750846, -95.378616 29.752028, -95.378616 29.754428, -95.376016 29.754528, -95.374616 29.759828, -95.373616 29.761128, -95.371916 29.763928, -95.372316 29.768727, -95.365884 29.76791, -95.366015 29.767127, -95.358715 29.765327, -95.358615 29.766327, -95.359115 29.767227, -95.360215 29.767027, -95.362783 29.768267, -95.365315 29.770527, -95.365015 29.772327))'), + ('77005', 'POLYGON ((-95.447918 29.727275, -95.428017 29.728729, -95.421117 29.729029, -95.418617 29.727629, -95.418517 29.726429, -95.402117 29.726629, -95.402117 29.725729, -95.395316 29.725729, -95.391916 29.726229, -95.389716 29.725829, -95.396517 29.715429, -95.397517 29.715929, -95.400917 29.711429, -95.411417 29.715029, -95.418417 29.714729, -95.418317 29.70623, -95.440818 29.70593, -95.445018 29.70683, -95.446618 29.70763, -95.447418 29.71003, -95.447918 29.727275))'), + ('77025', 'POLYGON ((-95.418317 29.70623, -95.414717 29.706129, -95.414617 29.70533, -95.418217 29.70533, -95.419817 29.69533, -95.419484 29.694196, -95.417166 29.690901, -95.414517 29.69433, -95.413317 29.69263, -95.412617 29.68973, -95.412817 29.68753, -95.414087 29.685055, -95.419165 29.685428, -95.421617 29.68513, -95.425717 29.67983, -95.425017 29.67923, -95.424517 29.67763, -95.427418 29.67763, -95.438018 29.664631, -95.436713 29.664411, -95.440118 29.662231, -95.439218 29.661031, -95.437718 29.660131, -95.435718 29.659731, -95.431818 29.660331, -95.441418 29.656631, -95.441318 29.656331, -95.441818 29.656131, -95.441718 29.659031, -95.441118 29.661031, -95.446718 29.656431, -95.446518 29.673431, -95.446918 29.69013, -95.447418 29.71003, -95.446618 29.70763, -95.445018 29.70683, -95.440818 29.70593, -95.418317 29.70623))'), + ('77401', 'POLYGON ((-95.447918 29.727275, -95.447418 29.71003, -95.446918 29.69013, -95.454318 29.68893, -95.475819 29.68903, -95.475819 29.69113, -95.484419 29.69103, -95.484519 29.69903, -95.480419 29.70133, -95.480419 29.69833, -95.474119 29.69833, -95.474119 29.70453, -95.472719 29.71283, -95.468019 29.71293, -95.468219 29.720229, -95.464018 29.720229, -95.464118 29.724529, -95.463018 29.725929, -95.459818 29.726129, -95.459918 29.720329, -95.451418 29.720429, -95.451775 29.726303, -95.451318 29.727029, -95.447918 29.727275))'), + ) +interstates = (('I-25', 'LINESTRING(-104.4780170766108 36.66698791870694, -104.4468522338495 36.79925409393386, -104.46212692626 36.9372149776075, -104.5126119783768 37.08163268820887, -104.5247764602161 37.29300499892048, -104.7084397427668 37.49150259925398, -104.8126599016282 37.69514285621863, -104.8452887035466 37.87613395659479, -104.7160169341003 38.05951763337799, -104.6165437927668 38.30432045855106, -104.6437227858174 38.53979986564737, -104.7596170387259 38.7322907594295, -104.8380078676822 38.89998460604341, -104.8501253693506 39.09980189213358, -104.8791648316464 39.24368776457503, -104.8635041274215 39.3785278162751, -104.8894471170052 39.5929228239605, -104.9721242843344 39.69528482419685, -105.0112104500356 39.7273080432394, -105.0010368577104 39.76677607811571, -104.981835619 39.81466504121967, -104.9858891550477 39.88806911250832, -104.9873548059578 39.98117234571016, -104.9766220487419 40.09796423450692, -104.9818565932953 40.36056530662884, -104.9912746373997 40.74904484447656)'), + ) diff --git a/django/contrib/gis/tests/distapp/models.py b/django/contrib/gis/tests/distapp/models.py index 836b769e61..f23b277fea 100644 --- a/django/contrib/gis/tests/distapp/models.py +++ b/django/contrib/gis/tests/distapp/models.py @@ -7,6 +7,13 @@ class SouthTexasCity(models.Model): objects = models.GeoManager() def __unicode__(self): return self.name +class SouthTexasCityFt(models.Model): + "Same City model as above, but U.S. survey feet are the units." + name = models.CharField(max_length=30) + point = models.PointField(srid=2278) + objects = models.GeoManager() + def __unicode__(self): return self.name + class AustraliaCity(models.Model): "City model for Australia, using WGS84." name = models.CharField(max_length=30) @@ -14,7 +21,22 @@ class AustraliaCity(models.Model): objects = models.GeoManager() def __unicode__(self): return self.name -#class County(models.Model): -# name = models.CharField(max_length=30) -# mpoly = models.MultiPolygonField(srid=32140) -# objects = models.GeoManager() +class CensusZipcode(models.Model): + "Model for a few South Texas ZIP codes (in original Census NAD83)." + name = models.CharField(max_length=5) + poly = models.PolygonField(srid=4269) + objects = models.GeoManager() + +class SouthTexasZipcode(models.Model): + "Model for a few South Texas ZIP codes." + name = models.CharField(max_length=5) + poly = models.PolygonField(srid=32140) + objects = models.GeoManager() + def __unicode__(self): return self.name + +class Interstate(models.Model): + "Geodetic model for U.S. Interstates." + name = models.CharField(max_length=10) + line = models.LineStringField() + objects = models.GeoManager() + def __unicode__(self): return self.name diff --git a/django/contrib/gis/tests/distapp/tests.py b/django/contrib/gis/tests/distapp/tests.py index d1fe83ebc5..53bcf9d42d 100644 --- a/django/contrib/gis/tests/distapp/tests.py +++ b/django/contrib/gis/tests/distapp/tests.py @@ -1,14 +1,15 @@ import os, unittest from decimal import Decimal +from django.db.models import Q from django.contrib.gis.gdal import DataSource from django.contrib.gis.geos import GEOSGeometry, Point, LineString from django.contrib.gis.measure import D # alias for Distance from django.contrib.gis.db.models import GeoQ -from django.contrib.gis.tests.utils import oracle +from django.contrib.gis.tests.utils import oracle, postgis, no_oracle -from models import SouthTexasCity, AustraliaCity -from data import au_cities, stx_cities +from models import AustraliaCity, Interstate, SouthTexasCity, SouthTexasCityFt, CensusZipcode, SouthTexasZipcode +from data import au_cities, interstates, stx_cities, stx_zips class DistanceTest(unittest.TestCase): @@ -20,41 +21,64 @@ class DistanceTest(unittest.TestCase): # Another one for Australia au_pnt = GEOSGeometry('POINT (150.791 -34.4919)', 4326) - def get_cities(self, qs): + def get_names(self, qs): cities = [c.name for c in qs] cities.sort() return cities def test01_init(self): "Initialization of distance models." - - def load_cities(city_model, srid, data_tup): + + # Loading up the cities. + def load_cities(city_model, data_tup): for name, x, y in data_tup: - c = city_model(name=name, point=Point(x, y, srid=srid)) + c = city_model(name=name, point=Point(x, y, srid=4326)) c.save() - load_cities(SouthTexasCity, 32140, stx_cities) - load_cities(AustraliaCity, 4326, au_cities) + load_cities(SouthTexasCity, stx_cities) + load_cities(SouthTexasCityFt, stx_cities) + load_cities(AustraliaCity, au_cities) - self.assertEqual(10, SouthTexasCity.objects.count()) + self.assertEqual(9, SouthTexasCity.objects.count()) + self.assertEqual(9, SouthTexasCityFt.objects.count()) self.assertEqual(11, AustraliaCity.objects.count()) + + # Loading up the South Texas Zip Codes. + for name, wkt in stx_zips: + poly = GEOSGeometry(wkt, srid=4269) + SouthTexasZipcode(name=name, poly=poly).save() + CensusZipcode(name=name, poly=poly).save() + self.assertEqual(4, SouthTexasZipcode.objects.count()) + self.assertEqual(4, CensusZipcode.objects.count()) + + # Loading up the Interstates. + for name, wkt in interstates: + Interstate(name=name, line=GEOSGeometry(wkt, srid=4326)).save() + self.assertEqual(1, Interstate.objects.count()) def test02_dwithin(self): "Testing the `dwithin` lookup type." # Distances -- all should be equal (except for the # degree/meter pair in au_cities, that's somewhat # approximate). - tx_dists = [7000, D(km=7), D(mi=4.349)] + tx_dists = [(7000, 22965.83), D(km=7), D(mi=4.349)] au_dists = [(0.5, 32000), D(km=32), D(mi=19.884)] # Expected cities for Australia and Texas. tx_cities = ['Downtown Houston', 'Southside Place'] au_cities = ['Mittagong', 'Shellharbour', 'Thirroul', 'Wollongong'] + # Performing distance queries on two projected coordinate systems one + # with units in meters and the other in units of U.S. survey feet. for dist in tx_dists: - qs = SouthTexasCity.objects.filter(point__dwithin=(self.stx_pnt, dist)) - self.assertEqual(tx_cities, self.get_cities(qs)) + if isinstance(dist, tuple): dist1, dist2 = dist + else: dist1 = dist2 = dist + qs1 = SouthTexasCity.objects.filter(point__dwithin=(self.stx_pnt, dist1)) + qs2 = SouthTexasCityFt.objects.filter(point__dwithin=(self.stx_pnt, dist2)) + for qs in qs1, qs2: + self.assertEqual(tx_cities, self.get_names(qs)) + # Now performing the `dwithin` queries on a geodetic coordinate system. for dist in au_dists: if isinstance(dist, D) and not oracle: type_error = True else: type_error = False @@ -70,23 +94,29 @@ class DistanceTest(unittest.TestCase): # Distance objects into a DWithin query using a geodetic field. self.assertRaises(TypeError, qs.count) else: - self.assertEqual(au_cities, self.get_cities(qs)) + self.assertEqual(au_cities, self.get_names(qs)) - def test03_distance_aggregate(self): - "Testing the `distance` GeoQuerySet method." + def test03a_distance_method(self): + "Testing the `distance` GeoQuerySet method on projected coordinate systems." # The point for La Grange, TX lagrange = GEOSGeometry('POINT(-96.876369 29.905320)', 4326) - # Got these from using the raw SQL statement: - # SELECT ST_Distance(point, ST_Transform(ST_GeomFromText('POINT(-96.876369 29.905320)', 4326),32140)) FROM distapp_southtexascity; - distances = [147075.069813, 139630.198056, 140888.552826, - 138809.684197, 158309.246259, 212183.594374, - 70870.188967, 165337.758878, 102128.654360, - 139196.085105] + # Reference distances in feet and in meters. Got these values from + # using the provided raw SQL statements. + # SELECT ST_Distance(point, ST_Transform(ST_GeomFromText('POINT(-96.876369 29.905320)', 4326), 32140)) FROM distapp_southtexascity; + m_distances = [147075.069813, 139630.198056, 140888.552826, + 138809.684197, 158309.246259, 212183.594374, + 70870.188967, 165337.758878, 139196.085105] + # SELECT ST_Distance(point, ST_Transform(ST_GeomFromText('POINT(-96.876369 29.905320)', 4326), 2278)) FROM distapp_southtexascityft; + ft_distances = [482528.79154625, 458103.408123001, 462231.860397575, + 455411.438904354, 519386.252102563, 696139.009211594, + 232513.278304279, 542445.630586414, 456679.155883207] - # Testing when the field name is explicitly set. - dist1 = SouthTexasCity.objects.distance('point', lagrange) + # Testing using different variations of parameters and using models + # with different projected coordinate systems. + dist1 = SouthTexasCity.objects.distance(lagrange, field_name='point') dist2 = SouthTexasCity.objects.distance(lagrange) # Using GEOSGeometry parameter - dist3 = SouthTexasCity.objects.distance(lagrange.ewkt) # Using EWKT string parameter. + dist3 = SouthTexasCityFt.objects.distance(lagrange.ewkt) # Using EWKT string parameter. + dist4 = SouthTexasCityFt.objects.distance(lagrange) # Original query done on PostGIS, have to adjust AlmostEqual tolerance # for Oracle. @@ -94,9 +124,15 @@ class DistanceTest(unittest.TestCase): else: tol = 5 # Ensuring expected distances are returned for each distance queryset. - for qs in [dist1, dist2, dist3]: + for qs in [dist1, dist2, dist3, dist4]: for i, c in enumerate(qs): - self.assertAlmostEqual(distances[i], c.distance, tol) + self.assertAlmostEqual(m_distances[i], c.distance.m, tol) + self.assertAlmostEqual(ft_distances[i], c.distance.survey_ft, tol) + + def test03b_distance_method(self): + "Testing the `distance` GeoQuerySet method on geodetic coordnate systems." + if oracle: tol = 2 + else: tol = 5 # Now testing geodetic distance aggregation. hillsdale = AustraliaCity.objects.get(name='Hillsdale') @@ -106,61 +142,153 @@ class DistanceTest(unittest.TestCase): self.assertRaises(TypeError, AustraliaCity.objects.distance, 'LINESTRING(0 0, 1 1)') self.assertRaises(TypeError, AustraliaCity.objects.distance, LineString((0, 0), (1, 1))) - # Got these distances using the raw SQL statement: + # Got the reference distances using the raw SQL statements: # SELECT ST_distance_spheroid(point, ST_GeomFromText('POINT(151.231341 -33.952685)', 4326), 'SPHEROID["WGS 84",6378137.0,298.257223563]') FROM distapp_australiacity WHERE (NOT (id = 11)); - geodetic_distances = [60504.0628825298, 77023.948962654, 49154.8867507115, 90847.435881812, 217402.811862568, 709599.234619957, 640011.483583758, 7772.00667666425, 1047861.7859506, 1165126.55237647] + spheroid_distances = [60504.0628825298, 77023.948962654, 49154.8867507115, 90847.435881812, 217402.811862568, 709599.234619957, 640011.483583758, 7772.00667666425, 1047861.7859506, 1165126.55237647] + # SELECT ST_distance_sphere(point, ST_GeomFromText('POINT(151.231341 -33.952685)', 4326)) FROM distapp_australiacity WHERE (NOT (id = 11)); st_distance_sphere + sphere_distances = [60580.7612632291, 77143.7785056615, 49199.2725132184, 90804.4414289463, 217712.63666124, 709131.691061906, 639825.959074112, 7786.80274606706, 1049200.46122281, 1162619.7297006] - # Ensuring the expected distances are returned. - qs = AustraliaCity.objects.exclude(id=hillsdale.id).distance(hillsdale.point) + # Testing with spheroid distances first. + qs = AustraliaCity.objects.exclude(id=hillsdale.id).distance(hillsdale.point, spheroid=True) for i, c in enumerate(qs): - self.assertAlmostEqual(geodetic_distances[i], c.distance, tol) + self.assertAlmostEqual(spheroid_distances[i], c.distance.m, tol) + if postgis: + # PostGIS uses sphere-only distances by default, testing these as well. + qs = AustraliaCity.objects.exclude(id=hillsdale.id).distance(hillsdale.point) + for i, c in enumerate(qs): + self.assertAlmostEqual(sphere_distances[i], c.distance.m, tol) + + @no_oracle # Oracle already handles geographic distance calculation. + def test03c_distance_method(self): + "Testing the `distance` GeoQuerySet method used with `transform` on a geographic field." + # Normally you can't compute distances from a geometry field + # that is not a PointField (on PostGIS). + self.assertRaises(TypeError, CensusZipcode.objects.distance, self.stx_pnt) + + # We'll be using a Polygon (created by buffering the centroid + # of 77005 to 100m) -- which aren't allowed in geographic distance + # queries normally, however our field has been transformed to + # a non-geographic system. + z = SouthTexasZipcode.objects.get(name='77005') + + # Reference query: + # SELECT ST_Distance(ST_Transform("distapp_censuszipcode"."poly", 32140), ST_GeomFromText('', 32140)) FROM "distapp_censuszipcode"; + dists_m = [3553.30384972258, 1243.18391525602, 2186.15439472242] + + # Having our buffer in the SRID of the transformation and of the field + # -- should get the same results. The first buffer has no need for + # transformation SQL because it is the same SRID as what was given + # to `transform()`. The second buffer will need to be transformed, + # however. + buf1 = z.poly.centroid.buffer(100) + buf2 = buf1.transform(4269, clone=True) + for buf in [buf1, buf2]: + qs = CensusZipcode.objects.exclude(name='77005').transform(32140).distance(buf) + self.assertEqual(['77002', '77025', '77401'], self.get_names(qs)) + for i, z in enumerate(qs): + self.assertAlmostEqual(z.distance.m, dists_m[i], 5) def test04_distance_lookups(self): "Testing the `distance_lt`, `distance_gt`, `distance_lte`, and `distance_gte` lookup types." - # Only two cities (Houston and Southside Place) should be - # within 7km of the given point. - dists = [D(km=7), D(mi=4.349), # Distance instances in different units. - 7000, 7000.0, Decimal(7000), # int, float, Decimal parameters. - ] + # Retrieving the cities within a 20km 'donut' w/a 7km radius 'hole' + # (thus, Houston and Southside place will be excluded as tested in + # the `test02_dwithin` above). + qs1 = SouthTexasCity.objects.filter(point__distance_gte=(self.stx_pnt, D(km=7))).filter(point__distance_lte=(self.stx_pnt, D(km=20))) + qs2 = SouthTexasCityFt.objects.filter(point__distance_gte=(self.stx_pnt, D(km=7))).filter(point__distance_lte=(self.stx_pnt, D(km=20))) + for qs in qs1, qs2: + cities = self.get_names(qs) + self.assertEqual(cities, ['Bellaire', 'Pearland', 'West University Place']) - for dist in dists: - qs = SouthTexasCity.objects.filter(point__dwithin=(self.stx_pnt, dist)) - for c in qs: - cities = self.get_cities(qs) - self.assertEqual(cities, ['Downtown Houston', 'Southside Place']) - - # Now only retrieving the cities within a 20km 'donut' w/a 7km radius 'hole' - # (thus, Houston and Southside place will be excluded) - qs = SouthTexasCity.objects.filter(point__distance_gte=(self.stx_pnt, D(km=7))).filter(point__distance_lte=(self.stx_pnt, D(km=20))) - cities = self.get_cities(qs) - self.assertEqual(cities, ['Bellaire', 'Pearland', 'West University Place']) - - def test05_geodetic_distance(self): - "Testing distance lookups on geodetic coordinate systems." + # Doing a distance query using Polygons instead of a Point. + z = SouthTexasZipcode.objects.get(name='77005') + qs = SouthTexasZipcode.objects.exclude(name='77005').filter(poly__distance_lte=(z.poly, D(m=275))) + self.assertEqual(['77025', '77401'], self.get_names(qs)) + # If we add a little more distance 77002 should be included. + qs = SouthTexasZipcode.objects.exclude(name='77005').filter(poly__distance_lte=(z.poly, D(m=300))) + self.assertEqual(['77002', '77025', '77401'], self.get_names(qs)) + def test05_geodetic_distance_lookups(self): + "Testing distance lookups on geodetic coordinate systems." if not oracle: # Oracle doesn't have this limitation -- PostGIS only allows geodetic # distance queries from Points to PointFields. mp = GEOSGeometry('MULTIPOINT(0 0, 5 23)') self.assertRaises(TypeError, AustraliaCity.objects.filter(point__distance_lte=(mp, D(km=100)))) - - hobart = AustraliaCity.objects.get(name='Hobart') - + # Too many params (4 in this case) should raise a ValueError. + self.assertRaises(ValueError, + AustraliaCity.objects.filter(point__distance_lte=('POINT(5 23)', D(km=100), 'spheroid', '4')).count) + + # Not enough params should raise a ValueError. + self.assertRaises(ValueError, + AustraliaCity.objects.filter(point__distance_lte=('POINT(5 23)',)).count) + # Getting all cities w/in 550 miles of Hobart. + hobart = AustraliaCity.objects.get(name='Hobart') qs = AustraliaCity.objects.exclude(name='Hobart').filter(point__distance_lte=(hobart.point, D(mi=550))) - cities = self.get_cities(qs) + cities = self.get_names(qs) self.assertEqual(cities, ['Batemans Bay', 'Canberra', 'Melbourne']) # Cities that are either really close or really far from Wollongong -- # and using different units of distance. wollongong = AustraliaCity.objects.get(name='Wollongong') - gq1 = GeoQ(point__distance_lte=(wollongong.point, D(yd=19500))) # Yards (~17km) - gq2 = GeoQ(point__distance_gte=(wollongong.point, D(nm=400))) # Nautical Miles - qs = AustraliaCity.objects.exclude(name='Wollongong').filter(gq1 | gq2) - cities = self.get_cities(qs) - self.assertEqual(cities, ['Adelaide', 'Hobart', 'Shellharbour', 'Thirroul']) + d1, d2 = D(yd=19500), D(nm=400) # Yards (~17km) & Nautical miles. + # Normal geodetic distance lookup (uses `distance_sphere` on PostGIS. + gq1 = GeoQ(point__distance_lte=(wollongong.point, d1)) + gq2 = GeoQ(point__distance_gte=(wollongong.point, d2)) + qs1 = AustraliaCity.objects.exclude(name='Wollongong').filter(gq1 | gq2) + + # Geodetic distance lookup but telling GeoDjango to use `distance_spheroid` + # instead (we should get the same results b/c accuracy variance won't matter + # in this test case). Using `Q` instead of `GeoQ` to be different (post-qsrf + # it doesn't matter). + if postgis: + gq3 = Q(point__distance_lte=(wollongong.point, d1, 'spheroid')) + gq4 = Q(point__distance_gte=(wollongong.point, d2, 'spheroid')) + qs2 = AustraliaCity.objects.exclude(name='Wollongong').filter(gq3 | gq4) + querysets = [qs1, qs2] + else: + querysets = [qs1] + + for qs in querysets: + cities = self.get_names(qs) + self.assertEqual(cities, ['Adelaide', 'Hobart', 'Shellharbour', 'Thirroul']) + + def test06_area(self): + "Testing the `area` GeoQuerySet method." + # Reference queries: + # SELECT ST_Area(poly) FROM distapp_southtexaszipcode; + area_sq_m = [5437908.90234375, 10183031.4389648, 11254471.0073242, 9881708.91772461] + # Tolerance has to be lower for Oracle and differences + # with GEOS 3.0.0RC4 + tol = 2 + for i, z in enumerate(SouthTexasZipcode.objects.area()): + self.assertAlmostEqual(area_sq_m[i], z.area.sq_m, tol) + + def test07_length(self): + "Testing the `length` GeoQuerySet method." + # Reference query (should use `length_spheroid`). + # SELECT ST_length_spheroid(ST_GeomFromText('', 4326) 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]'); + len_m = 473504.769553813 + qs = Interstate.objects.length() + if oracle: tol = 2 + else: tol = 7 + self.assertAlmostEqual(len_m, qs[0].length.m, tol) + + def test08_perimeter(self): + "Testing the `perimeter` GeoQuerySet method." + # Reference query: + # SELECT ST_Perimeter(distapp_southtexaszipcode.poly) FROM distapp_southtexaszipcode; + perim_m = [18404.3550889361, 15627.2108551001, 20632.5588368978, 17094.5996143697] + if oracle: tol = 2 + else: tol = 7 + for i, z in enumerate(SouthTexasZipcode.objects.perimeter()): + self.assertAlmostEqual(perim_m[i], z.perimeter.m, tol) + + # Running on points; should return 0. + for i, c in enumerate(SouthTexasCity.objects.perimeter(model_att='perim')): + self.assertEqual(0, c.perim.m) def suite(): s = unittest.TestSuite() diff --git a/django/contrib/gis/tests/geoapp/tests.py b/django/contrib/gis/tests/geoapp/tests.py index b06ec2130b..e2fd392f3a 100644 --- a/django/contrib/gis/tests/geoapp/tests.py +++ b/django/contrib/gis/tests/geoapp/tests.py @@ -28,14 +28,10 @@ class GeoModelTest(unittest.TestCase): def get_file(wkt_file): return os.path.join(data_dir, wkt_file) - co = State(name='Colorado', poly=fromfile(get_file('co.wkt'))) - co.save() - ks = State(name='Kansas', poly=fromfile(get_file('ks.wkt'))) - ks.save() - tx = Country(name='Texas', mpoly=fromfile(get_file('tx.wkt'))) - tx.save() - nz = Country(name='New Zealand', mpoly=fromfile(get_file('nz.wkt'))) - nz.save() + State(name='Colorado', poly=fromfile(get_file('co.wkt'))).save() + State(name='Kansas', poly=fromfile(get_file('ks.wkt'))).save() + Country(name='Texas', mpoly=fromfile(get_file('tx.wkt'))).save() + Country(name='New Zealand', mpoly=fromfile(get_file('nz.wkt'))).save() # Ensuring that data was loaded from initial SQL. self.assertEqual(2, Country.objects.count()) @@ -49,7 +45,7 @@ class GeoModelTest(unittest.TestCase): def test02_proxy(self): "Testing Lazy-Geometry support (using the GeometryProxy)." if DISABLE: return - #### Testing on a Point + ## Testing on a Point pnt = Point(0, 0) nullcity = City(name='NullCity', point=pnt) nullcity.save() @@ -86,7 +82,7 @@ class GeoModelTest(unittest.TestCase): self.assertEqual(Point(23, 5), City.objects.get(name='NullCity').point) nullcity.delete() - #### Testing on a Polygon + ## Testing on a Polygon shell = LinearRing((0, 0), (0, 100), (100, 100), (100, 0), (0, 0)) inner = LinearRing((40, 40), (40, 60), (60, 60), (60, 40), (40, 40)) @@ -136,7 +132,7 @@ class GeoModelTest(unittest.TestCase): ref_kml = ref_kml2 # Ensuring the KML is as expected. - ptown1 = City.objects.kml('point', precision=9).get(name='Pueblo') + ptown1 = City.objects.kml(field_name='point', precision=9).get(name='Pueblo') ptown2 = City.objects.kml(precision=9).get(name='Pueblo') for ptown in [ptown1, ptown2]: self.assertEqual(ref_kml, ptown.kml) @@ -147,8 +143,8 @@ class GeoModelTest(unittest.TestCase): # Should throw a TypeError when tyring to obtain GML from a # non-geometry field. qs = City.objects.all() - self.assertRaises(TypeError, qs.gml, 'name') - ptown1 = City.objects.gml('point', precision=9).get(name='Pueblo') + self.assertRaises(TypeError, qs.gml, field_name='name') + ptown1 = City.objects.gml(field_name='point', precision=9).get(name='Pueblo') ptown2 = City.objects.gml(precision=9).get(name='Pueblo') if oracle: @@ -172,12 +168,12 @@ class GeoModelTest(unittest.TestCase): # Asserting the result of the transform operation with the values in # the pre-transformed points. Oracle does not have the 3084 SRID. if not oracle: - h = City.objects.transform('point', srid=htown.srid).get(name='Houston') + h = City.objects.transform(htown.srid).get(name='Houston') self.assertEqual(3084, h.point.srid) self.assertAlmostEqual(htown.x, h.point.x, prec) self.assertAlmostEqual(htown.y, h.point.y, prec) - p1 = City.objects.transform('point', srid=ptown.srid).get(name='Pueblo') + p1 = City.objects.transform(ptown.srid, field_name='point').get(name='Pueblo') p2 = City.objects.transform(srid=ptown.srid).get(name='Pueblo') for p in [p1, p2]: self.assertEqual(2774, p.point.srid) @@ -186,7 +182,7 @@ class GeoModelTest(unittest.TestCase): @no_oracle # Most likely can do this in Oracle, however, it is not yet implemented (patches welcome!) def test05_extent(self): - "Testing the extent() GeoManager method." + "Testing the `extent` GeoQuerySet method." if DISABLE: return # Reference query: # `SELECT ST_extent(point) FROM geoapp_city WHERE (name='Houston' or name='Dallas');` @@ -199,6 +195,17 @@ class GeoModelTest(unittest.TestCase): for val, exp in zip(extent, expected): self.assertAlmostEqual(exp, val, 8) + @no_oracle + def test06_make_line(self): + "Testing the `make_line` GeoQuerySet method." + # Ensuring that a `TypeError` is raised on models without PointFields. + self.assertRaises(TypeError, State.objects.make_line) + self.assertRaises(TypeError, Country.objects.make_line) + # Reference query: + # SELECT AsText(ST_MakeLine(geoapp_city.point)) FROM geoapp_city; + self.assertEqual(GEOSGeometry('LINESTRING(-95.363151 29.763374,-96.801611 32.782057,-97.521157 34.464642,174.783117 -41.315268,-104.609252 38.255001,-95.23506 38.971823,-87.650175 41.850385,-123.305196 48.462611)', srid=4326), + City.objects.make_line()) + def test09_disjoint(self): "Testing the `disjoint` lookup type." if DISABLE: return @@ -317,8 +324,7 @@ class GeoModelTest(unittest.TestCase): # Saving another commonwealth w/a NULL geometry. if not oracle: # TODO: Fix saving w/NULL geometry on Oracle. - nmi = State(name='Northern Mariana Islands', poly=None) - nmi.save() + State(name='Northern Mariana Islands', poly=None).save() @no_oracle # No specific `left` or `right` operators in Oracle. def test13_left_right(self): @@ -416,34 +422,36 @@ class GeoModelTest(unittest.TestCase): c = City() self.assertEqual(c.point, None) - def test17_union(self): - "Testing the union() GeoManager method." + def test17_unionagg(self): + "Testing the `unionagg` (aggregate union) GeoManager method." if DISABLE: return tx = Country.objects.get(name='Texas').mpoly - # Houston, Dallas, San Antonio - union = fromstr('MULTIPOINT(-98.493183 29.424170,-96.801611 32.782057,-95.363151 29.763374)') + # Houston, Dallas, San Antonio -- Oracle has different order. + union1 = fromstr('MULTIPOINT(-98.493183 29.424170,-96.801611 32.782057,-95.363151 29.763374)') + union2 = fromstr('MULTIPOINT(-96.801611 32.782057,-95.363151 29.763374,-98.493183 29.424170)') qs = City.objects.filter(point__within=tx) - self.assertRaises(TypeError, qs.union, 'name') - u1 = qs.union('point') - u2 = qs.union() - self.assertEqual(True, union.equals_exact(u1, 10)) # Going up to 10 digits of precision. - self.assertEqual(True, union.equals_exact(u2, 10)) + self.assertRaises(TypeError, qs.unionagg, 'name') + u1 = qs.unionagg(field_name='point') + u2 = qs.unionagg() + tol = 0.00001 + if SpatialBackend.oracle: + union = union2 + else: + union = union1 + self.assertEqual(True, union.equals_exact(u1, tol)) + self.assertEqual(True, union.equals_exact(u2, tol)) qs = City.objects.filter(name='NotACity') - self.assertEqual(None, qs.union('point')) + self.assertEqual(None, qs.unionagg(field_name='point')) def test18_geometryfield(self): "Testing GeometryField." if DISABLE: return - f1 = Feature(name='Point', geom=Point(1, 1)) - f2 = Feature(name='LineString', geom=LineString((0, 0), (1, 1), (5, 5))) - f3 = Feature(name='Polygon', geom=Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)))) - f4 = Feature(name='GeometryCollection', - geom=GeometryCollection(Point(2, 2), LineString((0, 0), (2, 2)), - Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0))))) - f1.save() - f2.save() - f3.save() - f4.save() + Feature(name='Point', geom=Point(1, 1)).save() + Feature(name='LineString', geom=LineString((0, 0), (1, 1), (5, 5))).save() + Feature(name='Polygon', geom=Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)))).save() + Feature(name='GeometryCollection', + geom=GeometryCollection(Point(2, 2), LineString((0, 0), (2, 2)), + Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0))))).save() f_1 = Feature.objects.get(name='Point') self.assertEqual(True, isinstance(f_1.geom, Point)) @@ -458,6 +466,82 @@ class GeoModelTest(unittest.TestCase): self.assertEqual(True, isinstance(f_4.geom, GeometryCollection)) self.assertEqual(f_3.geom, f_4.geom[2]) + def test19_centroid(self): + "Testing the `centroid` GeoQuerySet method." + qs = State.objects.exclude(poly__isnull=True).centroid() + if oracle: tol = 0.1 + else: tol = 0.000000001 + for s in qs: + self.assertEqual(True, s.poly.centroid.equals_exact(s.centroid, tol)) + + def test20_pointonsurface(self): + "Testing the `point_on_surface` GeoQuerySet method." + # Reference values. + if SpatialBackend.oracle: + # SELECT SDO_UTIL.TO_WKTGEOMETRY(SDO_GEOM.SDO_POINTONSURFACE(GEOAPP_COUNTRY.MPOLY, 0.05)) FROM GEOAPP_COUNTRY; + ref = {'New Zealand' : fromstr('POINT (174.616364 -36.100861)', srid=4326), + 'Texas' : fromstr('POINT (-103.002434 36.500397)', srid=4326), + } + elif SpatialBackend.postgis: + # Using GEOSGeometry to compute the reference point on surface values + # -- since PostGIS also uses GEOS these should be the same. + ref = {'New Zealand' : Country.objects.get(name='New Zealand').mpoly.point_on_surface, + 'Texas' : Country.objects.get(name='Texas').mpoly.point_on_surface + } + for cntry in Country.objects.point_on_surface(): + self.assertEqual(ref[cntry.name], cntry.point_on_surface) + + @no_oracle + def test21_scale(self): + "Testing the `scale` GeoQuerySet method." + xfac, yfac = 2, 3 + qs = Country.objects.scale(xfac, yfac, model_att='scaled') + for c in qs: + for p1, p2 in zip(c.mpoly, c.scaled): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + self.assertEqual(c1[0] * xfac, c2[0]) + self.assertEqual(c1[1] * yfac, c2[1]) + + @no_oracle + def test22_translate(self): + "Testing the `translate` GeoQuerySet method." + xfac, yfac = 5, -23 + qs = Country.objects.translate(xfac, yfac, model_att='translated') + for c in qs: + for p1, p2 in zip(c.mpoly, c.translated): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + self.assertEqual(c1[0] + xfac, c2[0]) + self.assertEqual(c1[1] + yfac, c2[1]) + + def test23_numgeom(self): + "Testing the `num_geom` GeoQuerySet method." + # Both 'countries' only have two geometries. + for c in Country.objects.num_geom(): self.assertEqual(2, c.num_geom) + for c in City.objects.filter(point__isnull=False).num_geom(): + # Oracle will return 1 for the number of geometries on non-collections, + # whereas PostGIS will return None. + if postgis: self.assertEqual(None, c.num_geom) + else: self.assertEqual(1, c.num_geom) + + def test24_numpoints(self): + "Testing the `num_points` GeoQuerySet method." + for c in Country.objects.num_points(): self.assertEqual(c.mpoly.num_points, c.num_points) + if postgis: + # Oracle cannot count vertices in Point geometries. + for c in City.objects.num_points(): self.assertEqual(1, c.num_points) + + @no_oracle + def test25_geoset(self): + "Testing the `difference`, `intersection`, `sym_difference`, and `union` GeoQuerySet methods." + geom = Point(5, 23) + for c in Country.objects.all().intersection(geom).difference(geom).sym_difference(geom).union(geom): + self.assertEqual(c.mpoly.difference(geom), c.difference) + self.assertEqual(c.mpoly.intersection(geom), c.intersection) + self.assertEqual(c.mpoly.sym_difference(geom), c.sym_difference) + self.assertEqual(c.mpoly.union(geom), c.union) + def suite(): s = unittest.TestSuite() s.addTest(unittest.makeSuite(GeoModelTest)) diff --git a/django/contrib/gis/tests/geoapp/tests_mysql.py b/django/contrib/gis/tests/geoapp/tests_mysql.py index 89496e81b9..4e7c411f52 100644 --- a/django/contrib/gis/tests/geoapp/tests_mysql.py +++ b/django/contrib/gis/tests/geoapp/tests_mysql.py @@ -169,9 +169,9 @@ class GeoModelTest(unittest.TestCase): def test07_mysql_limitations(self): "Testing that union(), kml(), gml() raise exceptions." - self.assertRaises(ImproperlyConfigured, City.objects.union, 'point') - self.assertRaises(ImproperlyConfigured, State.objects.all().kml, 'poly') - self.assertRaises(ImproperlyConfigured, Country.objects.all().gml, 'mpoly') + self.assertRaises(ImproperlyConfigured, City.objects.union, Point(5, 23), field_name='point') + self.assertRaises(ImproperlyConfigured, State.objects.all().kml, field_name='poly') + self.assertRaises(ImproperlyConfigured, Country.objects.all().gml, field_name='mpoly') def suite(): s = unittest.TestSuite() diff --git a/django/contrib/gis/tests/layermap/models.py b/django/contrib/gis/tests/layermap/models.py index fe7604f980..5dbd528030 100644 --- a/django/contrib/gis/tests/layermap/models.py +++ b/django/contrib/gis/tests/layermap/models.py @@ -19,7 +19,7 @@ class City(models.Model): name = models.CharField(max_length=25) population = models.IntegerField() density = models.DecimalField(max_digits=7, decimal_places=1) - date = models.DateField() + dt = models.DateField() point = models.PointField() objects = models.GeoManager() @@ -42,7 +42,7 @@ cofeat_mapping = {'name' : 'Name', city_mapping = {'name' : 'Name', 'population' : 'Population', 'density' : 'Density', - 'date' : 'Created', + 'dt' : 'Created', 'point' : 'POINT', } diff --git a/django/contrib/gis/tests/layermap/tests.py b/django/contrib/gis/tests/layermap/tests.py index 278c1a2998..740ab8647e 100644 --- a/django/contrib/gis/tests/layermap/tests.py +++ b/django/contrib/gis/tests/layermap/tests.py @@ -69,7 +69,7 @@ class LayerMapTest(unittest.TestCase): city = City.objects.get(name=feat['Name'].value) self.assertEqual(feat['Population'].value, city.population) self.assertEqual(Decimal(str(feat['Density'])), city.density) - self.assertEqual(feat['Created'].value, city.date) + self.assertEqual(feat['Created'].value, city.dt) # Comparing the geometries. pnt1, pnt2 = feat.geom, city.point diff --git a/django/contrib/gis/tests/relatedapp/tests.py b/django/contrib/gis/tests/relatedapp/tests.py index a49b66c0dd..5fe63cbc8e 100644 --- a/django/contrib/gis/tests/relatedapp/tests.py +++ b/django/contrib/gis/tests/relatedapp/tests.py @@ -34,7 +34,7 @@ class RelatedGeoModelTest(unittest.TestCase): @no_mysql def test03_transform_related(self): - "Testing the `transform` GeoManager method on related geographic models." + "Testing the `transform` GeoQuerySet method on related geographic models." # All the transformations are to state plane coordinate systems using # US Survey Feet (thus a tolerance of 0 implies error w/in 1 survey foot). if postgis: @@ -47,6 +47,7 @@ class RelatedGeoModelTest(unittest.TestCase): def check_pnt(ref, pnt): self.assertAlmostEqual(ref.x, pnt.x, tol) self.assertAlmostEqual(ref.y, pnt.y, tol) + self.assertEqual(ref.srid, pnt.srid) # Turning on debug so we can manually verify the number of SQL queries issued. # DISABLED: the number of queries count testing mechanism is way too brittle. @@ -62,13 +63,35 @@ class RelatedGeoModelTest(unittest.TestCase): for name, srid, wkt in transformed: # Doing this implicitly sets `select_related` select the location. - qs = list(City.objects.filter(name=name).transform('location__point', srid)) - check_pnt(GEOSGeometry(wkt), qs[0].location.point) + qs = list(City.objects.filter(name=name).transform(srid, field_name='location__point')) + check_pnt(GEOSGeometry(wkt, srid), qs[0].location.point) #settings.DEBUG= dbg # Verifying the number of issued SQL queries. #self.assertEqual(nqueries, len(connection.queries)) + @no_mysql + def test04_related_aggregate(self): + "Testing the `extent` and `unionagg` GeoQuerySet aggregates on related geographic models." + if postgis: + # One for all locations, one that excludes Roswell. + all_extent = (-104.528060913086, 33.0583305358887,-79.4607315063477, 40.1847610473633) + txpa_extent = (-97.51611328125, 33.0583305358887,-79.4607315063477, 40.1847610473633) + e1 = City.objects.extent(field_name='location__point') + e2 = City.objects.exclude(name='Roswell').extent(field_name='location__point') + for ref, e in [(all_extent, e1), (txpa_extent, e2)]: + for ref_val, e_val in zip(ref, e): self.assertAlmostEqual(ref_val, e_val) + + # The second union is for a query that has something in the WHERE clause. + ref_u1 = GEOSGeometry('MULTIPOINT(-104.528056 33.387222,-97.516111 33.058333,-79.460734 40.18476)', 4326) + ref_u2 = GEOSGeometry('MULTIPOINT(-97.516111 33.058333,-79.460734 40.18476)', 4326) + u1 = City.objects.unionagg(field_name='location__point') + u2 = City.objects.exclude(name='Roswell').unionagg(field_name='location__point') + self.assertEqual(ref_u1, u1) + self.assertEqual(ref_u2, u2) + + # TODO: Related tests for KML, GML, and distance lookups. + def suite(): s = unittest.TestSuite() s.addTest(unittest.makeSuite(RelatedGeoModelTest))