From fae19f8cedf14c946febd10513bfbaf9377eaf64 Mon Sep 17 00:00:00 2001 From: Justin Bronn Date: Tue, 4 Dec 2007 17:44:37 +0000 Subject: [PATCH] gis: Added distance querying capabilites via the `distance` manager method and the `distance_[gt|gte|lt|lte]` lookup types (works for both PostGIS and Oracle); improved Oracle query construction and fixed `transform` issues. git-svn-id: http://code.djangoproject.com/svn/django/branches/gis@6886 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/gis/db/backend/__init__.py | 47 ++---- .../contrib/gis/db/backend/oracle/__init__.py | 2 +- django/contrib/gis/db/backend/oracle/field.py | 46 +++--- .../contrib/gis/db/backend/oracle/models.py | 6 +- django/contrib/gis/db/backend/oracle/query.py | 133 ++++++++++++---- .../gis/db/backend/postgis/__init__.py | 2 +- .../contrib/gis/db/backend/postgis/adaptor.py | 4 +- .../contrib/gis/db/backend/postgis/field.py | 68 ++++---- .../contrib/gis/db/backend/postgis/models.py | 10 +- .../contrib/gis/db/backend/postgis/query.py | 105 ++++++------ django/contrib/gis/db/backend/util.py | 3 + .../contrib/gis/db/models/fields/__init__.py | 110 +++++++++++-- django/contrib/gis/db/models/manager.py | 3 + django/contrib/gis/db/models/query.py | 150 +++++++++++++++--- django/contrib/gis/models.py | 103 +++++++++--- django/contrib/gis/tests/__init__.py | 13 +- django/contrib/gis/tests/distapp/__init__.py | 0 .../gis/tests/distapp/cities/cities.dbf | Bin 0 -> 1037 bytes .../gis/tests/distapp/cities/cities.prj | 1 + .../gis/tests/distapp/cities/cities.shp | Bin 0 -> 436 bytes .../gis/tests/distapp/cities/cities.shx | Bin 0 -> 196 bytes django/contrib/gis/tests/distapp/models.py | 20 +++ django/contrib/gis/tests/distapp/tests.py | 86 ++++++++++ django/contrib/gis/tests/geoapp/models.py | 4 + .../gis/tests/geoapp/sql/city.oracle.sql | 8 + django/contrib/gis/tests/geoapp/tests.py | 131 +++++++++++---- 26 files changed, 786 insertions(+), 269 deletions(-) create mode 100644 django/contrib/gis/tests/distapp/__init__.py create mode 100644 django/contrib/gis/tests/distapp/cities/cities.dbf create mode 100644 django/contrib/gis/tests/distapp/cities/cities.prj create mode 100644 django/contrib/gis/tests/distapp/cities/cities.shp create mode 100644 django/contrib/gis/tests/distapp/cities/cities.shx create mode 100644 django/contrib/gis/tests/distapp/models.py create mode 100644 django/contrib/gis/tests/distapp/tests.py create mode 100644 django/contrib/gis/tests/geoapp/sql/city.oracle.sql diff --git a/django/contrib/gis/db/backend/__init__.py b/django/contrib/gis/db/backend/__init__.py index 1744d76664..1c015804ff 100644 --- a/django/contrib/gis/db/backend/__init__.py +++ b/django/contrib/gis/db/backend/__init__.py @@ -26,41 +26,33 @@ from django.utils.datastructures import SortedDict from django.contrib.gis.geos import GEOSGeometry # These routines (needed by GeoManager), default to False. -ASGML, ASKML, TRANSFORM, UNION= (False, False, False, False) +ASGML, ASKML, DISTANCE, TRANSFORM, UNION= (False, False, False, False, False) if settings.DATABASE_ENGINE == 'postgresql_psycopg2': # PostGIS is the spatial database, getting the rquired modules, # renaming as necessary. from django.contrib.gis.db.backend.postgis import \ PostGISField as GeoBackendField, POSTGIS_TERMS as GIS_TERMS, \ - create_spatial_db, get_geo_where_clause, gqn, \ - ASGML, ASKML, GEOM_SELECT, TRANSFORM, UNION + create_spatial_db, get_geo_where_clause, \ + ASGML, ASKML, DISTANCE, GEOM_SELECT, TRANSFORM, UNION SPATIAL_BACKEND = 'postgis' elif settings.DATABASE_ENGINE == 'oracle': from django.contrib.gis.db.backend.oracle import \ OracleSpatialField as GeoBackendField, \ ORACLE_SPATIAL_TERMS as GIS_TERMS, \ - create_spatial_db, get_geo_where_clause, gqn, \ - ASGML, GEOM_SELECT, TRANSFORM, UNION + create_spatial_db, get_geo_where_clause, \ + ASGML, DISTANCE, GEOM_SELECT, TRANSFORM, UNION SPATIAL_BACKEND = 'oracle' elif settings.DATABASE_ENGINE == 'mysql': from django.contrib.gis.db.backend.mysql import \ MySQLGeoField as GeoBackendField, \ MYSQL_GIS_TERMS as GIS_TERMS, \ - create_spatial_db, get_geo_where_clause, gqn, \ + create_spatial_db, get_geo_where_clause, \ GEOM_SELECT SPATIAL_BACKEND = 'mysql' else: raise NotImplementedError('No Geographic Backend exists for %s' % settings.DATABASE_ENGINE) -def geo_quotename(value): - """ - Returns the quotation used on a given Geometry value using the geometry - quoting from the backend (the `gqn` function). - """ - if isinstance(value, (StringType, UnicodeType)): return gqn(value) - else: return str(value) - #### query.py overloaded functions #### # parse_lookup() and lookup_inner() are modified from their django/db/models/query.py # counterparts to support constructing SQL for geographic queries. @@ -117,7 +109,7 @@ def parse_lookup(kwarg_items, opts): raise ValueError, "Cannot use None as a query value" elif callable(value): value = value() - + joins2, where2, params2 = lookup_inner(path, lookup_type, value, opts, opts.db_table, None) joins.update(joins2) where.extend(where2) @@ -287,28 +279,15 @@ def lookup_inner(path, lookup_type, value, opts, table, column): # If the field is a geometry field, then the WHERE clause will need to be obtained # with the get_geo_where_clause() if hasattr(field, '_geom'): - # Do we have multiple arguments, e.g., `relate`, `dwithin` lookup types - # need more than argument. - multiple_args = isinstance(value, tuple) - # Getting the preparation SQL object from the field. - if multiple_args: - geo_prep = field.get_db_prep_lookup(lookup_type, value[0]) - else: - geo_prep = field.get_db_prep_lookup(lookup_type, value) - + geo_prep = field.get_db_prep_lookup(lookup_type, value) + # Getting the adapted geometry from the field. gwc = get_geo_where_clause(lookup_type, current_table + '.', column, value) - - # A GeoFieldSQL object is returned by `get_db_prep_lookup` -- - # getting the substitution list and the geographic parameters. - subst_list = geo_prep.where - if multiple_args: subst_list += map(geo_quotename, value[1:]) - gwc = gwc % tuple(subst_list) - - # Finally, appending onto the WHERE clause, and extending with - # the additional parameters. - where.append(gwc) + + # Substituting in the the where parameters into the geographic where + # clause, and extending the parameters. + where.append(gwc % tuple(geo_prep.where)) params.extend(geo_prep.params) else: where.append(get_where_clause(lookup_type, current_table + '.', column, value, db_type)) diff --git a/django/contrib/gis/db/backend/oracle/__init__.py b/django/contrib/gis/db/backend/oracle/__init__.py index 1353600cfb..7384b56027 100644 --- a/django/contrib/gis/db/backend/oracle/__init__.py +++ b/django/contrib/gis/db/backend/oracle/__init__.py @@ -10,5 +10,5 @@ from django.contrib.gis.db.backend.oracle.creation import create_spatial_db from django.contrib.gis.db.backend.oracle.field import OracleSpatialField, gqn from django.contrib.gis.db.backend.oracle.query import \ get_geo_where_clause, ORACLE_SPATIAL_TERMS, \ - ASGML, GEOM_SELECT, TRANSFORM, UNION + ASGML, DISTANCE, GEOM_SELECT, TRANSFORM, UNION diff --git a/django/contrib/gis/db/backend/oracle/field.py b/django/contrib/gis/db/backend/oracle/field.py index 695b33369a..3389c6dfb8 100644 --- a/django/contrib/gis/db/backend/oracle/field.py +++ b/django/contrib/gis/db/backend/oracle/field.py @@ -6,7 +6,7 @@ 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 get_srid, GeoFieldSQL from django.contrib.gis.db.backend.oracle.adaptor import OracleSpatialAdaptor -from django.contrib.gis.db.backend.oracle.query import ORACLE_SPATIAL_TERMS, TRANSFORM +from django.contrib.gis.db.backend.oracle.query import ORACLE_SPATIAL_TERMS, DISTANCE_FUNCTIONS, TRANSFORM # Quotename & geographic quotename, respectively. qn = connection.ops.quote_name @@ -21,12 +21,12 @@ class OracleSpatialField(Field): empty_strings_allowed = False - def __init__(self, extent=(-180.0, -90.0, 180.0, 90.0), tolerance=0.00005, **kwargs): + def __init__(self, extent=(-180.0, -90.0, 180.0, 90.0), tolerance=0.05, **kwargs): """ Oracle Spatial backend needs to have the extent -- for projected coordinate systems _you must define the extent manually_, since the coordinates are for geodetic systems. The `tolerance` keyword specifies the tolerance - for error (in meters). + for error (in meters), and defaults to 0.05 (5 centimeters). """ # Oracle Spatial specific keyword arguments. self._extent = extent @@ -104,32 +104,32 @@ class OracleSpatialField(Field): # special case for isnull lookup if lookup_type == 'isnull': return GeoFieldSQL([], []) - # When the input is not a GEOS geometry, attempt to construct one - # from the given string input. - if isinstance(value, GEOSGeometry): - pass - elif isinstance(value, (StringType, UnicodeType)): - try: - value = GEOSGeometry(value) - except GEOSException: - raise TypeError("Could not create geometry from lookup value: %s" % str(value)) - else: - raise TypeError('Cannot use parameter of %s type as lookup parameter.' % type(value)) - - # Getting the SRID of the geometry, or defaulting to that of the field if - # it is None. - srid = get_srid(self, value) + # Get the geometry with SRID; defaults SRID to that + # of the field if it is None + geom = self.get_geometry(value) # The adaptor will be used by psycopg2 for quoting the WKT. - adapt = OracleSpatialAdaptor(value) - if srid != self._srid: + adapt = OracleSpatialAdaptor(geom) + + if geom.srid != self._srid: # Adding the necessary string substitutions and parameters # to perform a geometry transformation. - return GeoFieldSQL(['%s(SDO_GEOMETRY(%%s, %s), %%s)' % (TRANSFORM, srid)], - [adapt, self._srid]) + where = ['%s(SDO_GEOMETRY(%%s, %s), %%s)' % (TRANSFORM, geom.srid)] + params = [adapt, self._srid] else: - return GeoFieldSQL(['SDO_GEOMETRY(%%s, %s)' % srid], [adapt]) + where = ['SDO_GEOMETRY(%%s, %s)' % geom.srid] + params = [adapt] + if isinstance(value, tuple): + if lookup_type in DISTANCE_FUNCTIONS or lookup_type == 'dwithin': + # Getting the distance parameter in the units of the field + where += [self.get_distance(value[1])] + elif lookup_type == 'relate': + # No extra where parameters for SDO_RELATE queries. + pass + else: + where += map(gqn, value[1:]) + return GeoFieldSQL(where, params) else: raise TypeError("Field has invalid lookup: %s" % lookup_type) diff --git a/django/contrib/gis/db/backend/oracle/models.py b/django/contrib/gis/db/backend/oracle/models.py index ba1f37dd79..6cda7ab0bf 100644 --- a/django/contrib/gis/db/backend/oracle/models.py +++ b/django/contrib/gis/db/backend/oracle/models.py @@ -20,7 +20,7 @@ class GeometryColumns(models.Model): db_table = 'USER_SDO_GEOM_METADATA' @classmethod - def table_name_col(self): + def table_name_col(cls): return 'table_name' def __unicode__(self): @@ -43,3 +43,7 @@ class SpatialRefSys(models.Model, SpatialRefSysMixin): @property def wkt(self): return self.wktext + + @classmethod + def wkt_col(cls): + return 'wktext' diff --git a/django/contrib/gis/db/backend/oracle/query.py b/django/contrib/gis/db/backend/oracle/query.py index f74eeb1467..b2982183ac 100644 --- a/django/contrib/gis/db/backend/oracle/query.py +++ b/django/contrib/gis/db/backend/oracle/query.py @@ -2,25 +2,100 @@ This module contains the spatial lookup types, and the get_geo_where_clause() routine for Oracle Spatial. """ +import re +from decimal import Decimal from django.db import connection +from django.contrib.gis.measure import Distance qn = connection.ops.quote_name -ORACLE_GEOMETRY_FUNCTIONS = { - 'contains' : 'SDO_CONTAINS', - 'coveredby' : 'SDO_COVEREDBY', - 'covers' : 'SDO_COVERS', - 'disjoint' : 'SDO_DISJOINT', - 'dwithin' : ('SDO_WITHIN_DISTANCE', float), - 'intersects' : 'SDO_OVERLAPBDYINTERSECT', # TODO: Is this really the same as ST_Intersects()? - 'equals' : 'SDO_EQUAL', - 'exact' : 'SDO_EQUAL', - 'overlaps' : 'SDO_OVERLAPS', - 'same_as' : 'SDO_EQUAL', - #'relate' : ('SDO_RELATE', str), # Oracle uses a different syntax, e.g., 'mask=inside+touch' - 'touches' : 'SDO_TOUCH', - 'within' : 'SDO_INSIDE', +# The GML, distance, transform, and union procedures. +ASGML = 'SDO_UTIL.TO_GMLGEOMETRY' +DISTANCE = 'SDO_GEOM.SDO_DISTANCE' +TRANSFORM = 'SDO_CS.TRANSFORM' +UNION = 'SDO_AGGR_UNION' + +class SDOOperation(object): + "Base class for SDO* Oracle operations." + + def __init__(self, lookup, subst='', operator='=', result="'TRUE'", + beg_subst='%s(%s%s, %%s'): + self.lookup = lookup + self.subst = subst + self.operator = operator + self.result = result + self.beg_subst = beg_subst + self.end_subst = ') %s %s' % (self.operator, self.result) + + @property + def sql_subst(self): + return ''.join([self.beg_subst, self.subst, self.end_subst]) + + def as_sql(self, table, field): + return self.sql_subst % self.params(table, field) + + def params(self, table, field): + return (self.lookup, table, field) + +class SDODistance(SDOOperation): + "Class for Distance queries." + def __init__(self, op, tolerance=0.05): + super(SDODistance, self).__init__(DISTANCE, subst=", %s", operator=op, result='%%s') + self.tolerance = tolerance + + def params(self, table, field): + return (self.lookup, table, field, self.tolerance) + +class SDOGeomRelate(SDOOperation): + "Class for using SDO_GEOM.RELATE." + def __init__(self, mask, tolerance=0.05): + super(SDOGeomRelate, self).__init__('SDO_GEOM.RELATE', beg_subst="%s(%s%s, '%s'", + subst=", %%s, %s", result="'%s'" % mask) + self.mask = mask + self.tolerance = tolerance + + def params(self, table, field): + return (self.lookup, table, field, self.mask, self.tolerance) + +class SDORelate(SDOOperation): + "Class for using SDO_RELATE." + masks = 'TOUCH|OVERLAPBDYDISJOINT|OVERLAPBDYINTERSECT|EQUAL|INSIDE|COVEREDBY|CONTAINS|COVERS|ANYINTERACT|ON' + mask_regex = re.compile(r'^(%s)(\+(%s))*$' % (masks, masks), re.I) + + def __init__(self, mask, **kwargs): + super(SDORelate, self).__init__('SDO_RELATE', subst=", 'mask=%s'", **kwargs) + if not self.mask_regex.match(mask): + raise ValueError('Invalid %s mask: "%s"' % (self.lookup, mask)) + self.mask = mask + + def params(self, table, field): + return (self.lookup, table, field, self.mask) + +# Valid distance types and substitutions +dtypes = (Decimal, Distance, float, int) +DISTANCE_FUNCTIONS = { + 'distance_gt' : (SDODistance('>'), dtypes), + 'distance_gte' : (SDODistance('>='), dtypes), + 'distance_lt' : (SDODistance('<'), dtypes), + 'distance_lte' : (SDODistance('<='), dtypes), } +ORACLE_GEOMETRY_FUNCTIONS = { + 'contains' : SDOOperation('SDO_CONTAINS'), + 'coveredby' : SDOOperation('SDO_COVEREDBY'), + 'covers' : SDOOperation('SDO_COVERS'), + 'disjoint' : SDOGeomRelate('DISJOINT'), + 'dwithin' : (SDOOperation('SDO_WITHIN_DISTANCE', "%%s, 'distance=%%s'"), dtypes), + 'intersects' : SDOOperation('SDO_OVERLAPBDYINTERSECT'), # TODO: Is this really the same as ST_Intersects()? + 'equals' : SDOOperation('SDO_EQUAL'), + 'exact' : SDOOperation('SDO_EQUAL'), + 'overlaps' : SDOOperation('SDO_OVERLAPS'), + 'same_as' : SDOOperation('SDO_EQUAL'), + 'relate' : (SDORelate, basestring), # Oracle uses a different syntax, e.g., 'mask=inside+touch' + 'touches' : SDOOperation('SDO_TOUCH'), + 'within' : SDOOperation('SDO_INSIDE'), + } +ORACLE_GEOMETRY_FUNCTIONS.update(DISTANCE_FUNCTIONS) + # This lookup type does not require a mapping. MISC_TERMS = ['isnull'] @@ -43,25 +118,33 @@ def get_geo_where_clause(lookup_type, table_prefix, field_name, value): if isinstance(lookup_info, tuple): # First element of tuple is lookup type, second element is the type # of the expected argument (e.g., str, float) - func, arg_type = lookup_info + sdo_op, arg_type = lookup_info # Ensuring that a tuple _value_ was passed in from the user - if not isinstance(value, tuple) or len(value) != 2: - raise TypeError('2-element tuple required for %s lookup type.' % lookup_type) + if not isinstance(value, tuple): + raise TypeError('Tuple required for `%s` lookup type.' % lookup_type) + if len(value) != 2: + raise ValueError('2-element tuple required for %s lookup type.' % lookup_type) # Ensuring the argument type matches what we expect. if not isinstance(value[1], arg_type): raise TypeError('Argument type should be %s, got %s instead.' % (arg_type, type(value[1]))) - if func == 'dwithin': - # TODO: test and consider adding different distance options. - return "%s(%s, %%s, 'distance=%s')" % (func, table_prefix + field_name, value[1]) + if lookup_type == 'relate': + # The SDORelate class handles construction for these queries, and verifies + # the mask argument. + return sdo_op(value[1]).as_sql(table_prefix, field_name) + elif lookup_type in DISTANCE_FUNCTIONS: + op = DISTANCE_FUNCTIONS[lookup_type][0] + return op.as_sql(table_prefix, field_name) + # return '%s(%s%s, %%s) %s %%s' % (DISTANCE, table_prefix, field_name, op) else: - return "%s(%s, %%s, %%s) = 'TRUE'" % (func, table_prefix + field_name) + return sdo_op.as_sql(table_prefix, field_name) else: - # Returning the SQL necessary for the geometry function call. For example: + # Lookup info is a SDOOperation instance, whos `as_sql` method returns + # the SQL necessary for the geometry function call. For example: # SDO_CONTAINS("geoapp_country"."poly", SDO_GEOMTRY('POINT(5 23)', 4326)) = 'TRUE' - return "%s(%s, %%s) = 'TRUE'" % (lookup_info, table_prefix + field_name) + return lookup_info.as_sql(table_prefix, field_name) # Handling 'isnull' lookup type if lookup_type == 'isnull': @@ -69,10 +152,6 @@ def get_geo_where_clause(lookup_type, table_prefix, field_name, value): raise TypeError("Got invalid lookup_type: %s" % repr(lookup_type)) -ASGML = 'SDO_UTIL.TO_GMLGEOMETRY' -UNION = 'SDO_AGGR_UNION' -TRANSFORM = 'SDO_CS.TRANSFORM' - # Want to get SDO Geometries as WKT (much easier to instantiate GEOS proxies # from WKT than SDO_GEOMETRY(...) strings ;) GEOM_SELECT = 'SDO_UTIL.TO_WKTGEOMETRY(%s)' diff --git a/django/contrib/gis/db/backend/postgis/__init__.py b/django/contrib/gis/db/backend/postgis/__init__.py index f84748efcb..34a9b93768 100644 --- a/django/contrib/gis/db/backend/postgis/__init__.py +++ b/django/contrib/gis/db/backend/postgis/__init__.py @@ -6,4 +6,4 @@ from django.contrib.gis.db.backend.postgis.field import PostGISField, gqn from django.contrib.gis.db.backend.postgis.query import \ get_geo_where_clause, GEOM_FUNC_PREFIX, POSTGIS_TERMS, \ MAJOR_VERSION, MINOR_VERSION1, MINOR_VERSION2, \ - ASKML, ASGML, GEOM_FROM_TEXT, UNION, TRANSFORM, GEOM_SELECT + ASKML, ASGML, DISTANCE, GEOM_FROM_TEXT, UNION, TRANSFORM, GEOM_SELECT diff --git a/django/contrib/gis/db/backend/postgis/adaptor.py b/django/contrib/gis/db/backend/postgis/adaptor.py index 37ef6ffac0..e91834722b 100644 --- a/django/contrib/gis/db/backend/postgis/adaptor.py +++ b/django/contrib/gis/db/backend/postgis/adaptor.py @@ -7,11 +7,11 @@ from psycopg2 import Binary from psycopg2.extensions import ISQLQuote class PostGISAdaptor(object): - def __init__(self, geom, srid): + def __init__(self, geom): "Initializes on the geometry and the SRID." # Getting the WKB and the SRID self.wkb = geom.wkb - self.srid = srid + self.srid = geom.srid def __conform__(self, proto): # Does the given protocol conform to what Psycopg2 expects? diff --git a/django/contrib/gis/db/backend/postgis/field.py b/django/contrib/gis/db/backend/postgis/field.py index 442795efd4..77712e7005 100644 --- a/django/contrib/gis/db/backend/postgis/field.py +++ b/django/contrib/gis/db/backend/postgis/field.py @@ -1,19 +1,26 @@ -from types import StringType, UnicodeType +from types import UnicodeType from django.db import connection from django.db.models.fields import Field # Django base Field class from django.contrib.gis.geos import GEOSGeometry, GEOSException from django.contrib.gis.db.backend.util import get_srid, GeoFieldSQL from django.contrib.gis.db.backend.postgis.adaptor import PostGISAdaptor -from django.contrib.gis.db.backend.postgis.query import POSTGIS_TERMS, TRANSFORM -from psycopg2 import Binary +from django.contrib.gis.db.backend.postgis.query import \ + DISTANCE, DISTANCE_FUNCTIONS, POSTGIS_TERMS, TRANSFORM # Quotename & geographic quotename, respectively qn = connection.ops.quote_name def gqn(value): - if isinstance(value, UnicodeType): value = value.encode('ascii') - return "'%s'" % value + if isinstance(value, basestring): + if isinstance(value, UnicodeType): value = value.encode('ascii') + return "'%s'" % value + else: + return str(value) class PostGISField(Field): + """ + The backend-specific geographic field for PostGIS. + """ + def _add_geom(self, style, db_table): """ Constructs the addition of the geometry to the table using the @@ -92,53 +99,44 @@ class PostGISField(Field): """ if lookup_type in POSTGIS_TERMS: # special case for isnull lookup - if lookup_type == 'isnull': - return GeoFieldSQL([], [value]) + if lookup_type == 'isnull': return GeoFieldSQL([], []) - # When the input is not a GEOS geometry, attempt to construct one - # from the given string input. - if isinstance(value, GEOSGeometry): - pass - elif isinstance(value, (StringType, UnicodeType)): - try: - value = GEOSGeometry(value) - except GEOSException: - raise TypeError("Could not create geometry from lookup value: %s" % str(value)) - else: - raise TypeError('Cannot use parameter of %s type as lookup parameter.' % type(value)) - - # Getting the SRID of the geometry, or defaulting to that of the field if - # it is None. - srid = get_srid(self, value) + # Get the geometry with SRID; defaults SRID to + # that of the field if it is None. + geom = self.get_geometry(value) # The adaptor will be used by psycopg2 for quoting the WKB. - adapt = PostGISAdaptor(value, srid) + adapt = PostGISAdaptor(geom) - if srid != self._srid: + if geom.srid != self._srid: # Adding the necessary string substitutions and parameters # to perform a geometry transformation. - return GeoFieldSQL(['%s(%%s,%%s)' % TRANSFORM], - [adapt, self._srid]) + where = ['%s(%%s,%%s)' % TRANSFORM] + params = [adapt, self._srid] else: - return GeoFieldSQL(['%s'], [adapt]) + # Otherwise, the adaptor will take care of everything. + where = ['%s'] + params = [adapt] + + if isinstance(value, tuple): + if lookup_type in DISTANCE_FUNCTIONS or lookup_type == 'dwithin': + # Getting the distance parameter in the units of the field. + where += [self.get_distance(value[1])] + else: + where += map(gqn, value[1:]) + return GeoFieldSQL(where, params) else: raise TypeError("Field has invalid lookup: %s" % lookup_type) + def get_db_prep_save(self, value): "Prepares the value for saving in the database." if not bool(value): return None if isinstance(value, GEOSGeometry): - return PostGISAdaptor(value, value.srid) + return PostGISAdaptor(value) else: raise TypeError('Geometry Proxy should only return GEOSGeometry objects.') - def get_internal_type(self): - """ - Returns NoField because a stored procedure is used by PostGIS to create - the Geometry Fields. - """ - return 'NoField' - def get_placeholder(self, value): """ Provides a proper substitution value for Geometries that are not in the diff --git a/django/contrib/gis/db/backend/postgis/models.py b/django/contrib/gis/db/backend/postgis/models.py index 89c67a4f4e..5e45bd7ac4 100644 --- a/django/contrib/gis/db/backend/postgis/models.py +++ b/django/contrib/gis/db/backend/postgis/models.py @@ -16,17 +16,17 @@ class GeometryColumns(models.Model): """ f_table_catalog = models.CharField(maxlength=256) f_table_schema = models.CharField(maxlength=256) - f_table_name = models.CharField(maxlength=256, primary_key=True) + f_table_name = models.CharField(maxlength=256) f_geometry_column = models.CharField(maxlength=256) coord_dimension = models.IntegerField() - srid = models.IntegerField() + srid = models.IntegerField(primary_key=True) type = models.CharField(maxlength=30) class Meta: db_table = 'geometry_columns' @classmethod - def table_name_col(self): + def table_name_col(cls): "Class method for returning the table name column for this model." return 'f_table_name' @@ -52,3 +52,7 @@ class SpatialRefSys(models.Model, SpatialRefSysMixin): @property def wkt(self): return self.srtext + + @classmethod + def wkt_col(cls): + return 'srtext' diff --git a/django/contrib/gis/db/backend/postgis/query.py b/django/contrib/gis/db/backend/postgis/query.py index ec08353e45..76d75b3536 100644 --- a/django/contrib/gis/db/backend/postgis/query.py +++ b/django/contrib/gis/db/backend/postgis/query.py @@ -2,9 +2,10 @@ This module contains the spatial lookup types, and the get_geo_where_clause() routine for PostGIS. """ +from decimal import Decimal from django.db import connection +from django.contrib.gis.measure import Distance from django.contrib.gis.db.backend.postgis.management import postgis_version_tuple -from types import StringType, UnicodeType qn = connection.ops.quote_name # Getting the PostGIS version information @@ -62,16 +63,38 @@ POSTGIS_OPERATORS = { # 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. -if MAJOR_VERSION > 1 or (MAJOR_VERSION == 1 and (MINOR_VERSION1 > 2 or (MINOR_VERSION1 == 2 and MINOR_VERSION2 >= 2))): - GEOM_FUNC_PREFIX = 'ST_' +GEOM_FUNC_PREFIX = '' +if MAJOR_VERSION >= 1: + if (MINOR_VERSION1 > 2 or + (MINOR_VERSION1 == 2 and MINOR_VERSION2 >= 2)): + GEOM_FUNC_PREFIX = 'ST_' + + def get_func(func): return '%s%s' % (GEOM_FUNC_PREFIX, func) + + # Functions used by the GeoManager & GeoQuerySet + ASKML = get_func('AsKML') + ASGML = get_func('AsGML') + DISTANCE = get_func('Distance') + GEOM_FROM_TEXT = get_func('GeomFromText') + GEOM_FROM_WKB = get_func('GeomFromWKB') + TRANSFORM = get_func('Transform') + + # Special cases for union and KML methods. + if MINOR_VERSION1 < 3: + UNION = 'GeomUnion' + else: + UNION = 'ST_Union' + + if MINOR_VERSION1 == 1: + ASKML = False else: - GEOM_FUNC_PREFIX = '' + raise NotImplementedError('PostGIS versions < 1.0 are not supported.') # For PostGIS >= 1.2.2 the following lookup types will do a bounding box query -# first before calling the more computationally expensive GEOS routines (called -# "inline index magic"): -# 'touches', 'crosses', 'contains', 'intersects', 'within', 'overlaps', and -# 'covers'. +# first before calling the more computationally expensive GEOS routines (called +# "inline index magic"): +# 'touches', 'crosses', 'contains', 'intersects', 'within', 'overlaps', and +# 'covers'. POSTGIS_GEOMETRY_FUNCTIONS = { 'equals' : 'Equals', 'disjoint' : 'Disjoint', @@ -81,25 +104,36 @@ POSTGIS_GEOMETRY_FUNCTIONS = { 'overlaps' : 'Overlaps', 'contains' : 'Contains', 'intersects' : 'Intersects', - 'relate' : ('Relate', str), + 'relate' : ('Relate', basestring), + } + +# Valid distance types and substitutions +dtypes = (Decimal, Distance, float, int) +DISTANCE_FUNCTIONS = { + 'distance_gt' : ('>', dtypes), + 'distance_gte' : ('>=', dtypes), + 'distance_lt' : ('<', dtypes), + 'distance_lte' : ('<=', dtypes), } if GEOM_FUNC_PREFIX == 'ST_': # Adding the GEOM_FUNC_PREFIX to the lookup functions. - for lookup, func in POSTGIS_GEOMETRY_FUNCTIONS.items(): - if isinstance(func, tuple): - POSTGIS_GEOMETRY_FUNCTIONS[lookup] = (GEOM_FUNC_PREFIX + func[0], func[1]) + for lookup, f in POSTGIS_GEOMETRY_FUNCTIONS.items(): + if isinstance(f, tuple): + POSTGIS_GEOMETRY_FUNCTIONS[lookup] = (get_func(f[0]), f[1]) else: - POSTGIS_GEOMETRY_FUNCTIONS[lookup] = GEOM_FUNC_PREFIX + func + POSTGIS_GEOMETRY_FUNCTIONS[lookup] = get_func(f) # The ST_DWithin, ST_CoveredBy, and ST_Covers routines become available in 1.2.2+ POSTGIS_GEOMETRY_FUNCTIONS.update( - {'dwithin' : ('ST_DWithin', float), + {'dwithin' : ('ST_DWithin', dtypes), 'coveredby' : 'ST_CoveredBy', 'covers' : 'ST_Covers', } ) +POSTGIS_GEOMETRY_FUNCTIONS.update(DISTANCE_FUNCTIONS) + # Any other lookup types that do not require a mapping. MISC_TERMS = ['isnull'] @@ -139,52 +173,31 @@ def get_geo_where_clause(lookup_type, table_prefix, field_name, value): func, arg_type = lookup_info # Ensuring that a tuple _value_ was passed in from the user - if not isinstance(value, tuple) or len(value) != 2: - raise TypeError('2-element tuple required for `%s` lookup type.' % lookup_type) + if not isinstance(value, tuple): + 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) # Ensuring the argument type matches what we expect. if not isinstance(value[1], arg_type): raise TypeError('Argument type should be %s, got %s instead.' % (arg_type, type(value[1]))) - - return "%s(%s%s, %%s, %%s)" % (func, table_prefix, field_name) + + if lookup_type in DISTANCE_FUNCTIONS: + op = DISTANCE_FUNCTIONS[lookup_type][0] + return '%s(%s%s, %%s) %s %%s' % (DISTANCE, table_prefix, field_name, op) + else: + return "%s(%s%s, %%s, %%s)" % (func, table_prefix, field_name) else: # Returning the SQL necessary for the geometry function call. For example: # ST_Contains("geoapp_country"."poly", ST_GeomFromWKB(..)) return '%s(%s%s, %%s)' % (lookup_info, table_prefix, field_name) - + # Handling 'isnull' lookup type if lookup_type == 'isnull': return "%s%s IS %sNULL" % (table_prefix, field_name, (not value and 'NOT ' or '')) raise TypeError("Got invalid lookup_type: %s" % repr(lookup_type)) -# Functions that we define manually. -if MAJOR_VERSION == 1: - if MINOR_VERSION1 == 3: - # PostGIS versions 1.3.x - ASKML = 'ST_AsKML' - ASGML = 'ST_AsGML' - GEOM_FROM_TEXT = 'ST_GeomFromText' - GEOM_FROM_WKB = 'ST_GeomFromWKB' - UNION = 'ST_Union' - TRANSFORM = 'ST_Transform' - elif MINOR_VERSION1 == 2 and MINOR_VERSION2 >= 1: - # PostGIS versions 1.2.x - ASKML = 'AsKML' - ASGML = 'AsGML' - GEOM_FROM_TEXT = 'GeomFromText' - GEOM_FROM_WKB = 'GeomFromWKB' - UNION = 'GeomUnion' - TRANSFORM = 'Transform' - elif MINOR_VERSION1 == 1 and MINOR_VERSION2 >= 0: - # PostGIS versions 1.1.x - ASKML = False - ASGML = 'AsGML' - GEOM_FROM_TEXT = 'GeomFromText' - GEOM_FROM_WKB = 'GeomFromWKB' - TRANSFORM = 'Transform' - UNION = 'GeomUnion' - # Custom selection not needed for PostGIS since GEOS geometries may be # instantiated directly from the HEXEWKB returned by default. If # WKT is needed for some reason in the future, this value may be changed, diff --git a/django/contrib/gis/db/backend/util.py b/django/contrib/gis/db/backend/util.py index 34c5c9a280..b744d00304 100644 --- a/django/contrib/gis/db/backend/util.py +++ b/django/contrib/gis/db/backend/util.py @@ -7,6 +7,9 @@ class GeoFieldSQL(object): self.where = where self.params = params + def __str__(self): + return self.where[0] % tuple(self.params) + def get_srid(field, geom): """ Gets the SRID depending on the value of the SRID setting of the field diff --git a/django/contrib/gis/db/models/fields/__init__.py b/django/contrib/gis/db/models/fields/__init__.py index dc23051cf1..a4eeb4f39a 100644 --- a/django/contrib/gis/db/models/fields/__init__.py +++ b/django/contrib/gis/db/models/fields/__init__.py @@ -1,8 +1,17 @@ +from decimal import Decimal from django.conf import settings +from django.db import connection from django.contrib.gis.db.backend import GeoBackendField # these depend on the spatial database backend. 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 -from django.contrib.gis.geos import GEOSGeometry + +# Attempting to get the spatial reference system. +try: + from django.contrib.gis.models import SpatialRefSys +except NotImplementedError: + SpatialRefSys = None #TODO: Flesh out widgets; consider adding support for OGR Geometry proxies. class GeometryField(GeoBackendField): @@ -11,30 +20,107 @@ class GeometryField(GeoBackendField): # The OpenGIS Geometry name. _geom = 'GEOMETRY' - def __init__(self, srid=4326, index=True, dim=2, **kwargs): - """The initialization function for geometry fields. Takes the following + def __init__(self, srid=4326, spatial_index=True, dim=2, **kwargs): + """ + The initialization function for geometry fields. Takes the following as keyword arguments: - srid - The spatial reference system identifier. An OGC standard. - Defaults to 4326 (WGS84) + srid: + The spatial reference system identifier, an OGC standard. + Defaults to 4326 (WGS84). - index - Indicates whether to create a GiST index. Defaults to True. - Set this instead of 'db_index' for geographic fields since index - creation is different for geometry columns. + spatial_index: + Indicates whether to create a spatial index. Defaults to True. + Set this instead of 'db_index' for geographic fields since index + creation is different for geometry columns. - dim - The number of dimensions for this geometry. Defaults to 2. + dim: + The number of dimensions for this geometry. Defaults to 2. """ - self._index = index + + # Backward-compatibility notice, this will disappear in future revisions. + if 'index' in kwargs: + from warnings import warn + warn('The `index` keyword has been deprecated, please use the `spatial_index` keyword instead.') + self._index = kwargs['index'] + else: + self._index = spatial_index + + # Setting the SRID and getting the units. self._srid = srid + if SpatialRefSys: + # This doesn't work when we actually use: SpatialRefSys.objects.get(srid=srid) + # Why? `syncdb` fails to recognize installed geographic models when there's + # an ORM query instantiated within a model field. No matter, this works fine + # too. + 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) + row = cur.fetchone() + self._unit, self._unit_name = SpatialRefSys.get_units(row[0]) + + # Setting the dimension of the geometry field. self._dim = dim super(GeometryField, self).__init__(**kwargs) # Calling the parent initializtion function + ### Routines specific to GeometryField ### + def get_distance(self, dist): + if isinstance(dist, Distance): + return getattr(dist, Distance.unit_attname(self._unit_name)) + elif isinstance(dist, (int, float, Decimal)): + # Assuming the distance is in the units of the field. + return dist + + def get_geometry(self, value): + """ + Retrieves the geometry, setting the default SRID from the given + lookup parameters. + """ + if isinstance(value, tuple): + geom = value[0] + else: + geom = value + + # When the input is not a GEOS geometry, attempt to construct one + # from the given string input. + if isinstance(geom, GEOSGeometry): + pass + elif isinstance(geom, basestring): + try: + geom = GEOSGeometry(geom) + except GEOSException: + 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)) + + # Assigning the SRID value. + geom.srid = self.get_srid(geom) + + return geom + + def get_srid(self, geom): + """ + Has logic for retrieving the default SRID taking into account + the SRID of the field. + """ + if geom.srid is None or (geom.srid == -1 and self._srid != -1): + return self._srid + else: + return geom.srid + + ### Routines overloaded from 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)) - + def get_manipulator_field_objs(self): "Using the WKTField (defined above) to be our manipulator." return [WKTField] diff --git a/django/contrib/gis/db/models/manager.py b/django/contrib/gis/db/models/manager.py index 2ac7595a30..399988d1d6 100644 --- a/django/contrib/gis/db/models/manager.py +++ b/django/contrib/gis/db/models/manager.py @@ -7,6 +7,9 @@ class GeoManager(Manager): def get_query_set(self): return GeoQuerySet(model=self.model) + def distance(self, *args, **kwargs): + return self.get_query_set().distance(*args, **kwargs) + def gml(self, *args, **kwargs): return self.get_query_set().gml(*args, **kwargs) diff --git a/django/contrib/gis/db/models/query.py b/django/contrib/gis/db/models/query.py index 8114c64c22..61893fba67 100644 --- a/django/contrib/gis/db/models/query.py +++ b/django/contrib/gis/db/models/query.py @@ -6,9 +6,13 @@ from django.db.models.fields import FieldDoesNotExist from django.utils.datastructures import SortedDict from django.contrib.gis.db.models.fields import GeometryField # parse_lookup depends on the spatial database backend. -from django.contrib.gis.db.backend import parse_lookup, ASGML, ASKML, GEOM_SELECT, SPATIAL_BACKEND, TRANSFORM, UNION +from django.contrib.gis.db.backend import parse_lookup, \ + ASGML, ASKML, DISTANCE, GEOM_SELECT, SPATIAL_BACKEND, TRANSFORM, UNION from django.contrib.gis.geos import GEOSGeometry +# Flag indicating whether the backend is Oracle. +oracle = SPATIAL_BACKEND == 'oracle' + class GeoQ(Q): "Geographical query encapsulation object." @@ -28,11 +32,23 @@ class GeoQuerySet(QuerySet): # For replacement fields in the SELECT. self._custom_select = {} + self._ewkt = None # If GEOM_SELECT is defined in the backend, then it will be used # for the selection format of the geometry column. - if GEOM_SELECT: self._geo_fmt = GEOM_SELECT - else: self._geo_fmt = '%s' + if GEOM_SELECT: + #if oracle and hasattr(self, '_ewkt'): + # Transformed geometries in Oracle use EWKT so that the SRID + # on the transformed lazy geometries is set correctly). + #print '-=' * 20 + #print self._ewkt, GEOM_SELECT + #self._geo_fmt = "'SRID=%d;'||%s" % (self._ewkt, GEOM_SELECT) + #self._geo_fmt = GEOM_SELECT + #else: + #print '-=' * 20 + self._geo_fmt = GEOM_SELECT + else: + self._geo_fmt = '%s' def _filter_or_exclude(self, mapper, *args, **kwargs): # mapper is a callable used to transform Q objects, @@ -59,15 +75,23 @@ class GeoQuerySet(QuerySet): select = [] # This is the only component of this routine that is customized for the - # GeoQuerySet. Specifically, this allows operations to be done on fields - # in the SELECT, overriding their values -- this is different from using - # QuerySet.extra(select=foo) because extra() adds an an _additional_ - # field to be selected. Used in returning transformed geometries, and - # handling the selection of native database geometry formats. + # GeoQuerySet. Specifically, this allows operations to be done on fields + # in the SELECT, overriding their values -- this is different from using + # QuerySet.extra(select=foo) because extra() adds an an _additional_ + # field to be selected. Used in returning transformed geometries, and + # handling the selection of native database geometry formats. for f in opts.fields: # Getting the selection format string. - if hasattr(f, '_geom'): sel_fmt = self._geo_fmt - else: sel_fmt = '%s' + if hasattr(f, '_geom'): + sel_fmt = self._geo_fmt + + # If an SRID needs to specified other than what is in the field + # (like when `transform` is called), make sure to explicitly set + # the SRID by returning EWKT. + if self._ewkt and oracle: + sel_fmt = "'SRID=%d;'||%s" % (self._ewkt, sel_fmt) + else: + sel_fmt = '%s' # Getting the field selection substitution string if f.column in self._custom_select: @@ -147,7 +171,7 @@ class GeoQuerySet(QuerySet): sql.append("ORDER BY " + ", ".join(order_by)) # LIMIT and OFFSET clauses - if SPATIAL_BACKEND != 'oracle': + if not oracle: if self._limit is not None: sql.append("%s " % connection.ops.limit_offset_sql(self._limit, self._offset)) else: @@ -206,6 +230,7 @@ class GeoQuerySet(QuerySet): def _clone(self, klass=None, **kwargs): c = super(GeoQuerySet, self)._clone(klass, **kwargs) c._custom_select = self._custom_select + c._ewkt = self._ewkt return c #### Methods specific to the GeoQuerySet #### @@ -227,7 +252,60 @@ class GeoQuerySet(QuerySet): else: return False - def gml(self, field_name, precision=8, version=2): + def _get_geofield(self): + "Returns the name of the first Geometry field encountered." + for field in self.model._meta.fields: + if isinstance(field, GeometryField): + return field.name + raise Exception('No GeometryFields in the model.') + + def distance(self, *args, **kwargs): + """ + Returns the distance from the given geographic field name to the + given geometry in a `distance` attribute on each element of the + GeoQuerySet. + """ + if not DISTANCE: + raise ImproperlyConfigured('Distance() stored proecedure not available.') + + # Getting the geometry field and GEOSGeometry object to base distance + # calculations from. + nargs = len(args) + if nargs == 1: + field_name = self._get_geofield() + geom = args[0] + elif nargs == 2: + field_name, geom = args + else: + raise ValueError('Maximum two arguments allowed for `distance` aggregate.') + + # Getting the quoted column. + field_col = self._geo_column(field_name) + if not field_col: + raise TypeError('Distance output only available on GeometryFields.') + + # Getting the geographic field instance. + geo_field = self.model._meta.get_field(field_name) + + # Using the field's get_db_prep_lookup() to get any needed + # transformation SQL -- we pass in a 'dummy' `contains` lookup + # type. + geom_sql = geo_field.get_db_prep_lookup('contains', geom) + if oracle: + # The `tolerance` keyword may be used for Oracle. + tolerance = kwargs.get('tolerance', 0.05) + + # More legwork here because the OracleSpatialAdaptor doesn't do + # quoting of the WKT. + params = ["'%s'" % geom_sql.params[0]] + params.extend(geom_sql.params[1:]) + gsql = geom_sql.where[0] % tuple(params) + dist_select = {'distance' : '%s(%s, %s, %s)' % (DISTANCE, field_col, gsql, tolerance)} + else: + dist_select = {'distance' : '%s(%s, %s)' % (DISTANCE, field_col, geom_sql)} + return self.extra(select=dist_select) + + def gml(self, field_name=None, precision=8, version=2): """ Returns GML representation of the given field in a `gml` attribute on each element of the GeoQuerySet. @@ -236,12 +314,16 @@ class GeoQuerySet(QuerySet): if not ASGML: raise ImproperlyConfigured('AsGML() stored procedure not available.') - # Is the given field name a geographic field? + # If no field name explicitly given, get the first GeometryField from + # the model. + if not field_name: + field_name = self._get_geofield() + field_col = self._geo_column(field_name) if not field_col: - raise TypeError('GML output only available on GeometryFields') - - if SPATIAL_BACKEND == 'oracle': + raise TypeError('GML output only available on GeometryFields.') + + if oracle: gml_select = {'gml':'%s(%s)' % (ASGML, field_col)} else: gml_select = {'gml':'%s(%s,%s,%s)' % (ASGML, field_col, precision, version)} @@ -249,7 +331,7 @@ class GeoQuerySet(QuerySet): # Adding GML function call to SELECT part of the SQL. return self.extra(select=gml_select) - def kml(self, field_name, precision=8): + def kml(self, field_name=None, precision=8): """ Returns KML representation of the given field name in a `kml` attribute on each element of the GeoQuerySet. @@ -258,7 +340,10 @@ class GeoQuerySet(QuerySet): if not ASKML: raise ImproperlyConfigured('AsKML() stored procedure not available.') - # Is the given field name a geographic field? + # Getting the geographic field. + if not field_name: + field_name = self._get_geofield() + field_col = self._geo_column(field_name) if not field_col: raise TypeError('KML output only available on GeometryFields.') @@ -266,30 +351,40 @@ class GeoQuerySet(QuerySet): # Adding the AsKML function call to SELECT part of the SQL. return self.extra(select={'kml':'%s(%s,%s)' % (ASKML, field_col, precision)}) - def transform(self, field_name, srid=4326): + def transform(self, field_name=None, srid=4326): """ Transforms the given geometry field to the given SRID. If no SRID is provided, the transformation will default to using 4326 (WGS84). """ - # Is the given field name a geographic field? + # Getting the geographic field. + if not field_name: + field_name = self._get_geofield() + elif isinstance(field_name, int): + srid = field_name + field_name = self._get_geofield() + field = self.model._meta.get_field(field_name) if not isinstance(field, GeometryField): raise TypeError('%s() only available for GeometryFields' % TRANSFORM) - # If there's already custom select SQL. + # Why cascading substitutions? Because spatial backends like + # Oracle and MySQL already require a function call to convert to text, thus + # when there's also a transformation we need to cascade the substitutions. + # For example, 'SDO_UTIL.TO_WKTGEOMETRY(SDO_CS.TRANSFORM( ... )' col = self._custom_select.get(field.column, self._field_column(field)) # Setting the key for the field's column with the custom SELECT SQL to - # override the geometry column returned from the database. - if SPATIAL_BACKEND == 'oracle': + # override the geometry column returned from the database. + if oracle: custom_sel = '%s(%s, %s)' % (TRANSFORM, col, srid) + self._ewkt = srid else: custom_sel = '(%s(%s, %s)) AS %s' % \ (TRANSFORM, col, srid, connection.ops.quote_name(field.column)) self._custom_select[field.column] = custom_sel return self._clone() - def union(self, field_name, tolerance=0.0005): + def union(self, field_name=None, tolerance=0.0005): """ Performs an aggregate union on the given geometry field. Returns None if the GeoQuerySet is empty. The `tolerance` keyword is for @@ -300,6 +395,9 @@ class GeoQuerySet(QuerySet): raise ImproperlyConfigured('Union stored procedure not available.') # Getting the geographic field column + if not field_name: + field_name = self._get_geofield() + field_col = self._geo_column(field_name) if not field_col: raise TypeError('Aggregate Union only available on GeometryFields.') @@ -312,7 +410,7 @@ class GeoQuerySet(QuerySet): # Replacing the select with a call to the ST_Union stored procedure # on the geographic field column. - if SPATIAL_BACKEND == 'oracle': + if oracle: union_sql = 'SELECT %s' % self._geo_fmt union_sql = union_sql % ('%s(SDOAGGRTYPE(%s,%s))' % (UNION, field_col, tolerance)) union_sql += sql @@ -323,7 +421,7 @@ class GeoQuerySet(QuerySet): cursor = connection.cursor() cursor.execute(union_sql, params) - if SPATIAL_BACKEND == 'oracle': + if oracle: # On Oracle have to read out WKT from CLOB first. clob = cursor.fetchone()[0] if clob: u = clob.read() diff --git a/django/contrib/gis/models.py b/django/contrib/gis/models.py index 9e1122bb79..233943dcbc 100644 --- a/django/contrib/gis/models.py +++ b/django/contrib/gis/models.py @@ -10,17 +10,24 @@ from django.contrib.gis.gdal import HAS_GDAL if HAS_GDAL: from django.contrib.gis.gdal import SpatialReference -# For pulling out the spheroid from the spatial reference string. This -# regular expression is used only if the user does not have GDAL installed. -# TODO: Flattening not used in all ellipsoids, could also be a minor axis, or 'b' -# parameter. -spheroid_regex = re.compile(r'.+SPHEROID\[\"(?P.+)\",(?P\d+(\.\d+)?),(?P\d{3}\.\d+),') - class SpatialRefSysMixin(object): """ The SpatialRefSysMixin is a class used by the database-dependent SpatialRefSys objects to reduce redundnant code. """ + + # For pulling out the spheroid from the spatial reference string. This + # regular expression is used only if the user does not have GDAL installed. + # TODO: Flattening not used in all ellipsoids, could also be a minor axis, or 'b' + # parameter. + spheroid_regex = re.compile(r'.+SPHEROID\[\"(?P.+)\",(?P\d+(\.\d+)?),(?P\d{3}\.\d+),') + + # For pulling out the units on platforms w/o GDAL installed. + # TODO: Figure out how to pull out angular units of projected coordinate system and + # fix for LOCAL_CS types. GDAL should be highly recommended for performing + # distance queries. + units_regex = re.compile(r'.+UNIT ?\["(?P[\w \'\(\)]+)", ?(?P[\d\.]+)(,AUTHORITY\["(?P[\w \'\(\)]+)","(?P\d+)"\])?\]([\w ]+)?(,AUTHORITY\["(?P[\w \'\(\)]+)","(?P\d+)"\])?\]$') + @property def srs(self): """ @@ -53,7 +60,7 @@ class SpatialRefSysMixin(object): if HAS_GDAL: return self.srs.ellipsoid else: - m = spheroid_regex.match(self.wkt) + m = self.spheroid_regex.match(self.wkt) if m: return (float(m.group('major')), float(m.group('flattening'))) else: return None @@ -75,37 +82,93 @@ class SpatialRefSysMixin(object): @property def projected(self): "Is this Spatial Reference projected?" - return self.srs.projected + if HAS_GDAL: + return self.srs.projected + else: + return self.wkt.startswith('PROJCS') @property def local(self): "Is this Spatial Reference local?" - return self.srs.local + if HAS_GDAL: + return self.srs.local + else: + return self.wkt.startswith('LOCAL_CS') @property def geographic(self): "Is this Spatial Reference geographic?" - return self.srs.geographic + if HAS_GDAL: + return self.srs.geographic + else: + return self.wkt.startswith('GEOGCS') @property def linear_name(self): "Returns the linear units name." - return self.srs.linear_name - + if HAS_GDAL: + return self.srs.linear_name + elif self.geographic: + return None + else: + m = self.units_regex.match(self.wkt) + return m.group('unit_name') + @property def linear_units(self): "Returns the linear units." - return self.srs.linear_units - - @property - def angular_units(self): - "Returns the angular units." - return self.srs.angular_units + if HAS_GDAL: + return self.srs.linear_units + elif self.geographic: + return None + else: + m = self.units_regex.match(self.wkt) + return m.group('unit') @property def angular_name(self): "Returns the name of the angular units." - return self.srs.angular_name + if HAS_GDAL: + return self.srs.angular_name + elif self.projected: + return None + else: + m = self.units_regex.match(self.wkt) + return m.group('unit_name') + + @property + def angular_units(self): + "Returns the angular units." + if HAS_GDAL: + return self.srs.angular_units + elif self.projected: + return None + else: + m = self.units_regex.match(self.wkt) + return m.group('unit') + + @property + def units(self): + "Returns a tuple of the units and the name." + if self.projected or self.local: + return (self.linear_units, self.linear_name) + elif self.geographic: + return (self.angular_units, self.angular_name) + else: + return (None, None) + + @classmethod + def get_units(cls, wkt): + """ + Class method used by GeometryField on initialization to + retrive the units on the given WKT, without having to use + any of the database fields. + """ + if HAS_GDAL: + return SpatialReference(wkt).units + else: + m = cls.units_regex.match(wkt) + return m.group('unit'), m.group('unit_name') def __unicode__(self): """ @@ -115,7 +178,7 @@ class SpatialRefSysMixin(object): try: return unicode(self.srs) except: - return unicode(self.srtext) + return unicode(self.wkt) # The SpatialRefSys and GeometryColumns models if settings.DATABASE_ENGINE == 'postgresql_psycopg2': diff --git a/django/contrib/gis/tests/__init__.py b/django/contrib/gis/tests/__init__.py index 57ce089f0c..40f6f4040a 100644 --- a/django/contrib/gis/tests/__init__.py +++ b/django/contrib/gis/tests/__init__.py @@ -3,9 +3,9 @@ from copy import copy from unittest import TestSuite, TextTestRunner from django.contrib.gis.gdal import HAS_GDAL try: - from django.contrib.gis.tests.utils import mysql + from django.contrib.gis.tests.utils import mysql, oracle except: - mysql = False + mysql, oracle = (False, False) # Tests that require use of a spatial database (e.g., creation of models) test_models = ['geoapp'] @@ -16,7 +16,12 @@ test_suite_names = [ 'test_measure', ] if HAS_GDAL: - test_models += ['layermap'] + if oracle: + # TODO: There is a problem with the `syncdb` SQL for the LayerMapping + # tests on Oracle. + test_models += ['distapp'] + else: + test_models += ['distapp', 'layermap'] test_suite_names += [ 'test_gdal_driver', 'test_gdal_ds', @@ -54,7 +59,7 @@ def run_tests(module_list, verbosity=1, interactive=True): (3) Start this database `pg_ctl -D /path/to/user/db start` On Windows platforms simply use the pgAdmin III utility to add superuser - priviliges to your database user. + privileges to your database user. Make sure your settings.py matches the settings of the user database. For example, set the same port number (`DATABASE_PORT=5433`). diff --git a/django/contrib/gis/tests/distapp/__init__.py b/django/contrib/gis/tests/distapp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/gis/tests/distapp/cities/cities.dbf b/django/contrib/gis/tests/distapp/cities/cities.dbf new file mode 100644 index 0000000000000000000000000000000000000000..660def3214195f39e065f3b9ddf4a1e72d778d4a GIT binary patch literal 1037 zcmb_aO$)*>3@v!@qJO|s{>pUVPO`0tM+sZdU~OUTHumqWIGCHq80beHeUH~9tLu8V zQA#DMi2HAx@JSSJS1#vnH$Q?n1{gpEO4&p=6XMAOAG{-GPA3r%PSiK8Vq#aKhXLeD|9hrh@OM;w^CeL4h@K<2`oiD*F@cWQ zwWElFjNt+i%mvmG8@Y5HHNNi;sFDZ(>1Be+-3qk6QGQy-@wmdJmv@E0azGbA^c`xA z_iVhPD@^Xo{AJdqp1O`7b&h0;F@xpU zU~({j!2AUiXNSq9)!&$OZIhNGZ0V zALKt!_=5dDYZkxW90oPVTa71E9KFD9;fBfOs=OAe(Nl9Q_qu3blmeFHfyuRYAM=r1 Pr{d^wbzz6;ZGT4qf}&u9 literal 0 HcmV?d00001 diff --git a/django/contrib/gis/tests/distapp/cities/cities.shx b/django/contrib/gis/tests/distapp/cities/cities.shx new file mode 100644 index 0000000000000000000000000000000000000000..9189d9ee7c2cee11b67e70cbac5cfa7a269c0fea GIT binary patch literal 196 zcmZQzQ0HR64w7ClGcYg$-104.609252,38.255001,0', ptown.kml) + ptown1 = City.objects.kml('point', precision=9).get(name='Pueblo') + ptown2 = City.objects.kml(precision=9).get(name='Pueblo') + for ptown in [ptown1, ptown2]: + self.assertEqual('-104.609252,38.255001,0', ptown.kml) def test03b_gml(self): "Testing GML output from the database using GeoManager.gml()." + if DISABLE: return # Should throw a TypeError when tyring to obtain GML from a # non-geometry field. qs = City.objects.all() self.assertRaises(TypeError, qs.gml, 'name') - ptown = City.objects.gml('point', precision=9).get(name='Pueblo') + ptown1 = City.objects.gml('point', precision=9).get(name='Pueblo') + ptown2 = City.objects.gml(precision=9).get(name='Pueblo') + if oracle: # No precision parameter for Oracle :-/ import re gml_regex = re.compile(r'-104.60925199\d+,38.25500\d+ ') - self.assertEqual(True, bool(gml_regex.match(ptown.gml))) + for ptown in [ptown1, ptown2]: + self.assertEqual(True, bool(gml_regex.match(ptown.gml))) else: - self.assertEqual('-104.609252,38.255001', ptown.gml) + for ptown in [ptown1, ptown2]: + self.assertEqual('-104.609252,38.255001', ptown.gml) def test04_transform(self): "Testing the transform() GeoManager method." + if DISABLE: return # Pre-transformed points for Houston and Pueblo. htown = fromstr('POINT(1947516.83115183 6322297.06040572)', srid=3084) ptown = fromstr('POINT(992363.390841912 481455.395105533)', srid=2774) @@ -142,13 +164,31 @@ class GeoModelTest(unittest.TestCase): self.assertAlmostEqual(htown.x, h.point.x, 8) self.assertAlmostEqual(htown.y, h.point.y, 8) - p = City.objects.transform('point', srid=ptown.srid).get(name='Pueblo') - self.assertEqual(2774, p.point.srid) - self.assertAlmostEqual(ptown.x, p.point.x, 7) - self.assertAlmostEqual(ptown.y, p.point.y, 7) + p1 = City.objects.transform('point', srid=ptown.srid).get(name='Pueblo') + p2 = City.objects.transform(srid=ptown.srid).get(name='Pueblo') + for p in [p1, p2]: + self.assertEqual(2774, p.point.srid) + self.assertAlmostEqual(ptown.x, p.point.x, 7) + self.assertAlmostEqual(ptown.y, p.point.y, 7) + + def test09_disjoint(self): + "Testing the `disjoint` lookup type." + if DISABLE: return + ptown = City.objects.get(name='Pueblo') + qs1 = City.objects.filter(point__disjoint=ptown.point) + self.assertEqual(7, qs1.count()) + + if not postgis: + # TODO: Do NULL columns bork queries on PostGIS? The following + # error is encountered: + # psycopg2.ProgrammingError: invalid memory alloc request size 4294957297 + qs2 = State.objects.filter(poly__disjoint=ptown.point) + self.assertEqual(1, qs2.count()) + self.assertEqual('Kansas', qs2[0].name) def test10_contains_contained(self): "Testing the 'contained', 'contains', and 'bbcontains' lookup types." + if DISABLE: return # Getting Texas, yes we were a country -- once ;) texas = Country.objects.get(name='Texas') @@ -190,6 +230,7 @@ class GeoModelTest(unittest.TestCase): def test11_lookup_insert_transform(self): "Testing automatic transform for lookups and inserts." + if DISABLE: return # San Antonio in 'WGS84' (SRID 4326) sa_4326 = 'POINT (-98.493183 29.424170)' wgs_pnt = fromstr(sa_4326, srid=4326) # Our reference point in WGS84 @@ -225,8 +266,12 @@ class GeoModelTest(unittest.TestCase): self.assertAlmostEqual(wgs_pnt.x, sa.point.x, 6) self.assertAlmostEqual(wgs_pnt.y, sa.point.y, 6) + # Oracle does not support NULL geometries in its spatial index for + # some routines (e.g., SDO_GEOM.RELATE). + @no_oracle def test12_null_geometries(self): "Testing NULL geometry support, and the `isnull` lookup type." + if DISABLE: return # Querying for both NULL and Non-NULL values. nullqs = State.objects.filter(poly__isnull=True) validqs = State.objects.filter(poly__isnull=False) @@ -250,6 +295,7 @@ class GeoModelTest(unittest.TestCase): @no_oracle # No specific `left` or `right` operators in Oracle. def test13_left_right(self): "Testing the 'left' and 'right' lookup types." + if DISABLE: return # Left: A << B => true if xmax(A) < xmin(B) # Right: A >> B => true if xmin(A) > xmax(B) # See: BOX2D_left() and BOX2D_right() in lwgeom_box2dfloat4.c in PostGIS source. @@ -285,6 +331,7 @@ class GeoModelTest(unittest.TestCase): for c in qs: self.assertEqual(True, c.name in cities) def test14_equals(self): + if DISABLE: return "Testing the 'same_as' and 'equals' lookup types." pnt = fromstr('POINT (-95.363151 29.763374)', srid=4326) c1 = City.objects.get(point=pnt) @@ -292,57 +339,73 @@ class GeoModelTest(unittest.TestCase): c3 = City.objects.get(point__equals=pnt) for c in [c1, c2, c3]: self.assertEqual('Houston', c.name) - @no_oracle # Oracle SDO_RELATE() uses a different system. def test15_relate(self): "Testing the 'relate' lookup type." + if DISABLE: return # To make things more interesting, we will have our Texas reference point in - # different SRIDs. - pnt1 = fromstr('POINT (649287.0363174345111474 4177429.4494686722755432)', srid=2847) + # different SRIDs. + pnt1 = fromstr('POINT (649287.0363174 4177429.4494686)', srid=2847) pnt2 = fromstr('POINT(-98.4919715741052 29.4333344025053)', srid=4326) - # Testing bad argument tuples that should return a TypeError - bad_args = [(pnt1, 0), (pnt2, 'T*T***FF*', 0), (23, 'foo')] - for args in bad_args: - try: - qs = Country.objects.filter(mpoly__relate=args) - cnt = qs.count() - except TypeError: - pass - else: - self.fail('Expected a TypeError') + # Testing bad argument tuples that should return a TypeError or + # a ValueError. + bad_args = [((pnt1, 0), TypeError), + ((pnt2, 'T*T***FF*', 0), ValueError), + ((23, 'foo'), TypeError), + ] + for args, e in bad_args: + qs = Country.objects.filter(mpoly__relate=args) + self.assertRaises(e, qs.count) - # 'T*T***FF*' => Contains() - self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt1, 'T*T***FF*')).name) - self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt2, 'T*T***FF*')).name) + # Relate works differently for the different backends. + if postgis: + contains_mask = 'T*T***FF*' + within_mask = 'T*F**F***' + intersects_mask = 'T********' + elif oracle: + contains_mask = 'contains' + within_mask = 'inside' + # TODO: This is not quite the same as the PostGIS mask above + intersects_mask = 'overlapbdyintersect' - # 'T*F**F***' => Within() + # Testing contains relation mask. + self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt1, contains_mask)).name) + self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt2, contains_mask)).name) + + # Testing within relation mask. ks = State.objects.get(name='Kansas') - self.assertEqual('Lawrence', City.objects.get(point__relate=(ks.poly, 'T*F**F***')).name) + self.assertEqual('Lawrence', City.objects.get(point__relate=(ks.poly, within_mask)).name) - # 'T********' => Intersects() - self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt1, 'T********')).name) - self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt2, 'T********')).name) - self.assertEqual('Lawrence', City.objects.get(point__relate=(ks.poly, 'T********')).name) + # Testing intersection relation mask. + if not oracle: + self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt1, intersects_mask)).name) + self.assertEqual('Texas', Country.objects.get(mpoly__relate=(pnt2, intersects_mask)).name) + self.assertEqual('Lawrence', City.objects.get(point__relate=(ks.poly, intersects_mask)).name) def test16_createnull(self): "Testing creating a model instance and the geometry being None" + if DISABLE: return c = City() self.assertEqual(c.point, None) def test17_union(self): "Testing the 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)') qs = City.objects.filter(point__within=tx) self.assertRaises(TypeError, qs.union, 'name') - u = qs.union('point') - self.assertEqual(True, union.equals_exact(u, 10)) # Going up to 10 digits of precision. + 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)) qs = City.objects.filter(name='NotACity') self.assertEqual(None, qs.union('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))))