diff --git a/django/contrib/gis/db/backends/base.py b/django/contrib/gis/db/backends/base.py index a132e806611ff..c6e48ce0ef542 100644 --- a/django/contrib/gis/db/backends/base.py +++ b/django/contrib/gis/db/backends/base.py @@ -42,19 +42,19 @@ class BaseSpatialFeatures(object): @property def supports_bbcontains_lookup(self): - return 'bbcontains' in self.connection.ops.gis_terms + return 'bbcontains' in self.connection.ops.gis_operators @property def supports_contained_lookup(self): - return 'contained' in self.connection.ops.gis_terms + return 'contained' in self.connection.ops.gis_operators @property def supports_dwithin_lookup(self): - return 'dwithin' in self.connection.ops.distance_functions + return 'dwithin' in self.connection.ops.gis_operators @property def supports_relate_lookup(self): - return 'relate' in self.connection.ops.gis_terms + return 'relate' in self.connection.ops.gis_operators # For each of those methods, the class will have a property named # `has__method` (defined in __init__) which accesses connection.ops @@ -97,12 +97,6 @@ class BaseSpatialOperations(object): instantiated by each spatial database backend with the features it has. """ - distance_functions = {} - geometry_functions = {} - geometry_operators = {} - geography_operators = {} - geography_functions = {} - gis_terms = set() truncate_params = {} # Quick booleans for the type of this spatial backend, and @@ -215,9 +209,6 @@ def get_expression_column(self, evaluator): def spatial_aggregate_sql(self, agg): raise NotImplementedError('Aggregate support not implemented for this spatial backend.') - def spatial_lookup_sql(self, lvalue, lookup_type, value, field): - raise NotImplementedError('subclasses of BaseSpatialOperations must a provide spatial_lookup_sql() method') - # Routines for getting the OGC-compliant models. def geometry_columns(self): raise NotImplementedError('subclasses of BaseSpatialOperations must a provide geometry_columns() method') diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py index a9e264cb02172..bcbd634fd8446 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -2,6 +2,7 @@ from django.contrib.gis.db.backends.adapter import WKTAdapter from django.contrib.gis.db.backends.base import BaseSpatialOperations +from django.contrib.gis.db.backends.utils import SpatialOperator class MySQLOperations(DatabaseOperations, BaseSpatialOperations): @@ -16,27 +17,25 @@ class MySQLOperations(DatabaseOperations, BaseSpatialOperations): Adapter = WKTAdapter Adaptor = Adapter # Backwards-compatibility alias. - geometry_functions = { - 'bbcontains': 'MBRContains', # For consistency w/PostGIS API - 'bboverlaps': 'MBROverlaps', # .. .. - 'contained': 'MBRWithin', # .. .. - 'contains': 'MBRContains', - 'disjoint': 'MBRDisjoint', - 'equals': 'MBREqual', - 'exact': 'MBREqual', - 'intersects': 'MBRIntersects', - 'overlaps': 'MBROverlaps', - 'same_as': 'MBREqual', - 'touches': 'MBRTouches', - 'within': 'MBRWithin', + gis_operators = { + 'bbcontains': SpatialOperator(func='MBRContains'), # For consistency w/PostGIS API + 'bboverlaps': SpatialOperator(func='MBROverlaps'), # .. .. + 'contained': SpatialOperator(func='MBRWithin'), # .. .. + 'contains': SpatialOperator(func='MBRContains'), + 'disjoint': SpatialOperator(func='MBRDisjoint'), + 'equals': SpatialOperator(func='MBREqual'), + 'exact': SpatialOperator(func='MBREqual'), + 'intersects': SpatialOperator(func='MBRIntersects'), + 'overlaps': SpatialOperator(func='MBROverlaps'), + 'same_as': SpatialOperator(func='MBREqual'), + 'touches': SpatialOperator(func='MBRTouches'), + 'within': SpatialOperator(func='MBRWithin'), } - gis_terms = set(geometry_functions) | {'isnull'} - def geo_db_type(self, f): return f.geom_type - def get_geom_placeholder(self, value, srid): + def get_geom_placeholder(self, f, value): """ The placeholder here has to include MySQL's WKT constructor. Because MySQL does not support spatial transformations, there is no need to @@ -47,19 +46,3 @@ def get_geom_placeholder(self, value, srid): else: placeholder = '%s(%%s)' % self.from_text return placeholder - - def spatial_lookup_sql(self, lvalue, lookup_type, value, field, qn): - geo_col, db_type = lvalue - - lookup_info = self.geometry_functions.get(lookup_type, False) - if lookup_info: - sql = "%s(%s, %s)" % (lookup_info, geo_col, - self.get_geom_placeholder(value, field.srid)) - return sql, [] - - # TODO: Is this really necessary? MySQL can't handle NULL geometries - # in its spatial indexes anyways. - if lookup_type == 'isnull': - return "%s IS %sNULL" % (geo_col, ('' if value else 'NOT ')), [] - - raise TypeError("Got invalid lookup_type: %s" % repr(lookup_type)) diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index 45ebdf32d36b8..a305f81f58e88 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -244,6 +244,12 @@ def transform_value(val, srid): else: return 'SDO_GEOMETRY(%%s, %s)' % f.srid + def check_relate_argument(self, arg): + masks = 'TOUCH|OVERLAPBDYDISJOINT|OVERLAPBDYINTERSECT|EQUAL|INSIDE|COVEREDBY|CONTAINS|COVERS|ANYINTERACT|ON' + mask_regex = re.compile(r'^(%s)(\+(%s))*$' % (masks, masks), re.I) + if not self.mask_regex.match(arg): + raise ValueError('Invalid SDO_RELATE mask: "%s"' % (self.relate_func, arg)) + def spatial_lookup_sql(self, lvalue, lookup_type, value, field, qn): "Returns the SQL WHERE clause for use in Oracle spatial SQL construction." geo_col, db_type = lvalue diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py index c41748cca2e37..690194fa9e7fe 100644 --- a/django/contrib/gis/db/backends/postgis/operations.py +++ b/django/contrib/gis/db/backends/postgis/operations.py @@ -1,73 +1,46 @@ import re -from decimal import Decimal from django.conf import settings from django.contrib.gis.db.backends.base import BaseSpatialOperations -from django.contrib.gis.db.backends.utils import SpatialOperation, SpatialFunction from django.contrib.gis.db.backends.postgis.adapter import PostGISAdapter +from django.contrib.gis.db.backends.utils import SpatialOperator from django.contrib.gis.geometry.backend import Geometry from django.contrib.gis.measure import Distance from django.core.exceptions import ImproperlyConfigured from django.db.backends.postgresql_psycopg2.base import DatabaseOperations from django.db.utils import ProgrammingError -from django.utils import six from django.utils.functional import cached_property from .models import PostGISGeometryColumns, PostGISSpatialRefSys -#### Classes used in constructing PostGIS spatial SQL #### -class PostGISOperator(SpatialOperation): - "For PostGIS operators (e.g. `&&`, `~`)." - def __init__(self, operator): - super(PostGISOperator, self).__init__(operator=operator) - - -class PostGISFunction(SpatialFunction): - "For PostGIS function calls (e.g., `ST_Contains(table, geom)`)." - def __init__(self, prefix, function, **kwargs): - super(PostGISFunction, self).__init__(prefix + function, **kwargs) - - -class PostGISFunctionParam(PostGISFunction): - "For PostGIS functions that take another parameter (e.g. DWithin, Relate)." - sql_template = '%(function)s(%(geo_col)s, %(geometry)s, %%s)' - - -class PostGISDistance(PostGISFunction): - "For PostGIS distance operations." - dist_func = 'Distance' - sql_template = '%(function)s(%(geo_col)s, %(geometry)s) %(operator)s %%s' - - def __init__(self, prefix, operator): - super(PostGISDistance, self).__init__(prefix, self.dist_func, - operator=operator) - - -class PostGISSpheroidDistance(PostGISFunction): - "For PostGIS spherical distance operations (using the spheroid)." - dist_func = 'distance_spheroid' - sql_template = '%(function)s(%(geo_col)s, %(geometry)s, %%s) %(operator)s %%s' - - def __init__(self, prefix, operator): - # An extra parameter in `end_subst` is needed for the spheroid string. - super(PostGISSpheroidDistance, self).__init__(prefix, self.dist_func, - operator=operator) - +class PostGISOperator(SpatialOperator): + def __init__(self, geography=False, **kwargs): + # Only a subset of the operators and functions are available + # for the geography type. + self.geography = geography + super(PostGISOperator, self).__init__(**kwargs) -class PostGISSphereDistance(PostGISDistance): - "For PostGIS spherical distance operations." - dist_func = 'distance_sphere' + def as_sql(self, connection, lookup, *args): + if lookup.lhs.source.geography and not self.geography: + raise ValueError('PostGIS geography does not support the "%s" ' + 'function/operator.' % (self.func or self.op,)) + return super(PostGISOperator, self).as_sql(connection, lookup, *args) -class PostGISRelate(PostGISFunctionParam): - "For PostGIS Relate(, ) calls." - pattern_regex = re.compile(r'^[012TF\*]{9}$') +class PostGISDistanceOperator(PostGISOperator): + sql_template = '%(func)s(%(lhs)s, %(rhs)s) %(op)s %%s' - def __init__(self, prefix, pattern): - if not self.pattern_regex.match(pattern): - raise ValueError('Invalid intersection matrix pattern "%s".' % pattern) - super(PostGISRelate, self).__init__(prefix, 'Relate') + def as_sql(self, connection, lookup, template_params, sql_params): + if not lookup.lhs.source.geography and lookup.lhs.source.geodetic(connection): + sql_template = self.sql_template + if len(lookup.rhs) == 3 and lookup.rhs[-1] == 'spheroid': + template_params.update({'op': self.op, 'func': 'ST_Distance_Spheroid'}) + sql_template = '%(func)s(%(lhs)s, %(rhs)s, %%s) %(op)s %%s' + else: + template_params.update({'op': self.op, 'func': 'ST_Distance_Sphere'}) + return sql_template % template_params, sql_params + return super(PostGISDistanceOperator, self).as_sql(connection, lookup, template_params, sql_params) class PostGISOperations(DatabaseOperations, BaseSpatialOperations): @@ -82,104 +55,43 @@ class PostGISOperations(DatabaseOperations, BaseSpatialOperations): Adapter = PostGISAdapter Adaptor = Adapter # Backwards-compatibility alias. + gis_operators = { + 'bbcontains': PostGISOperator(op='~'), + 'bboverlaps': PostGISOperator(op='&&', geography=True), + 'contained': PostGISOperator(op='@'), + 'contains': PostGISOperator(func='ST_Contains'), + 'overlaps_left': PostGISOperator(op='&<'), + 'overlaps_right': PostGISOperator(op='&>'), + 'overlaps_below': PostGISOperator(op='&<|'), + 'overlaps_above': PostGISOperator(op='|&>'), + 'left': PostGISOperator(op='<<'), + 'right': PostGISOperator(op='>>'), + 'strictly_below': PostGISOperator(op='<<|'), + 'stricly_above': PostGISOperator(op='|>>'), + 'same_as': PostGISOperator(op='~='), + 'exact': PostGISOperator(op='~='), # alias of same_as + 'contains_properly': PostGISOperator(func='ST_ContainsProperly'), + 'coveredby': PostGISOperator(func='ST_CoveredBy', geography=True), + 'covers': PostGISOperator(func='ST_Covers', geography=True), + 'crosses': PostGISOperator(func='ST_Crosses)'), + 'disjoint': PostGISOperator(func='ST_Disjoint'), + 'equals': PostGISOperator(func='ST_Equals'), + 'intersects': PostGISOperator(func='ST_Intersects', geography=True), + 'overlaps': PostGISOperator(func='ST_Overlaps'), + 'relate': PostGISOperator(func='ST_Relate'), + 'touches': PostGISOperator(func='ST_Touches'), + 'within': PostGISOperator(func='ST_Within'), + 'dwithin': PostGISOperator(func='ST_DWithin', geography=True), + 'distance_gt': PostGISDistanceOperator(func='ST_Distance', op='>', geography=True), + 'distance_gte': PostGISDistanceOperator(func='ST_Distance', op='>=', geography=True), + 'distance_lt': PostGISDistanceOperator(func='ST_Distance', op='<', geography=True), + 'distance_lte': PostGISDistanceOperator(func='ST_Distance', op='<=', geography=True), + } + def __init__(self, connection): super(PostGISOperations, self).__init__(connection) prefix = self.geom_func_prefix - # PostGIS-specific operators. The commented descriptions of these - # operators come from Section 7.6 of the PostGIS 1.4 documentation. - self.geometry_operators = { - # The "&<" operator returns true if A's bounding box overlaps or - # is to the left of B's bounding box. - 'overlaps_left': PostGISOperator('&<'), - # The "&>" operator returns true if A's bounding box overlaps or - # is to the right of B's bounding box. - 'overlaps_right': PostGISOperator('&>'), - # The "<<" operator returns true if A's bounding box is strictly - # to the left of B's bounding box. - 'left': PostGISOperator('<<'), - # The ">>" operator returns true if A's bounding box is strictly - # to the right of B's bounding box. - 'right': PostGISOperator('>>'), - # The "&<|" operator returns true if A's bounding box overlaps or - # is below B's bounding box. - 'overlaps_below': PostGISOperator('&<|'), - # The "|&>" operator returns true if A's bounding box overlaps or - # is above B's bounding box. - 'overlaps_above': PostGISOperator('|&>'), - # The "<<|" operator returns true if A's bounding box is strictly - # below B's bounding box. - 'strictly_below': PostGISOperator('<<|'), - # The "|>>" operator returns true if A's bounding box is strictly - # above B's bounding box. - 'strictly_above': PostGISOperator('|>>'), - # The "~=" operator is the "same as" operator. It tests actual - # geometric equality of two features. So if A and B are the same feature, - # vertex-by-vertex, the operator returns true. - 'same_as': PostGISOperator('~='), - 'exact': PostGISOperator('~='), - # The "@" operator returns true if A's bounding box is completely contained - # by B's bounding box. - 'contained': PostGISOperator('@'), - # The "~" operator returns true if A's bounding box completely contains - # by B's bounding box. - 'bbcontains': PostGISOperator('~'), - # The "&&" operator returns true if A's bounding box overlaps - # B's bounding box. - 'bboverlaps': PostGISOperator('&&'), - } - - self.geometry_functions = { - 'equals': PostGISFunction(prefix, 'Equals'), - 'disjoint': PostGISFunction(prefix, 'Disjoint'), - 'touches': PostGISFunction(prefix, 'Touches'), - 'crosses': PostGISFunction(prefix, 'Crosses'), - 'within': PostGISFunction(prefix, 'Within'), - 'overlaps': PostGISFunction(prefix, 'Overlaps'), - 'contains': PostGISFunction(prefix, 'Contains'), - 'intersects': PostGISFunction(prefix, 'Intersects'), - 'relate': (PostGISRelate, six.string_types), - 'coveredby': PostGISFunction(prefix, 'CoveredBy'), - 'covers': PostGISFunction(prefix, 'Covers'), - 'contains_properly': PostGISFunction(prefix, 'ContainsProperly'), - } - - # Valid distance types and substitutions - dtypes = (Decimal, Distance, float) + six.integer_types - - def get_dist_ops(operator): - "Returns operations for both regular and spherical distances." - return {'cartesian': PostGISDistance(prefix, operator), - 'sphere': PostGISSphereDistance(prefix, operator), - 'spheroid': PostGISSpheroidDistance(prefix, operator), - } - self.distance_functions = { - 'distance_gt': (get_dist_ops('>'), dtypes), - 'distance_gte': (get_dist_ops('>='), dtypes), - 'distance_lt': (get_dist_ops('<'), dtypes), - 'distance_lte': (get_dist_ops('<='), dtypes), - 'dwithin': (PostGISFunctionParam(prefix, 'DWithin'), dtypes) - } - - # Adding the distance functions to the geometries lookup. - self.geometry_functions.update(self.distance_functions) - - # Only a subset of the operators and functions are available - # for the geography type. - self.geography_functions = self.distance_functions.copy() - self.geography_functions.update({ - 'coveredby': self.geometry_functions['coveredby'], - 'covers': self.geometry_functions['covers'], - 'intersects': self.geometry_functions['intersects'], - }) - self.geography_operators = { - 'bboverlaps': PostGISOperator('&&'), - } - - # Creating a dictionary lookup of all GIS terms for PostGIS. - self.gis_terms = {'isnull'} - self.gis_terms.update(self.geometry_operators) - self.gis_terms.update(self.geometry_functions) self.area = prefix + 'Area' self.bounding_circle = prefix + 'MinimumBoundingCircle' @@ -452,95 +364,6 @@ def proj_version_tuple(self): else: raise Exception('Could not determine PROJ.4 version from PostGIS.') - def num_params(self, lookup_type, num_param): - """ - Helper routine that returns a boolean indicating whether the number of - parameters is correct for the lookup type. - """ - def exactly_two(np): - return np == 2 - - def two_to_three(np): - return np >= 2 and np <= 3 - if (lookup_type in self.distance_functions and - lookup_type != 'dwithin'): - return two_to_three(num_param) - else: - return exactly_two(num_param) - - def spatial_lookup_sql(self, lvalue, lookup_type, value, field, qn): - """ - Constructs spatial SQL from the given lookup value tuple a - (alias, col, db_type), the lookup type string, lookup value, and - the geometry field. - """ - geo_col, db_type = lvalue - - if lookup_type in self.geometry_operators: - if field.geography and lookup_type not in self.geography_operators: - raise ValueError('PostGIS geography does not support the ' - '"%s" lookup.' % lookup_type) - # Handling a PostGIS operator. - op = self.geometry_operators[lookup_type] - return op.as_sql(geo_col, self.get_geom_placeholder(field, value)) - elif lookup_type in self.geometry_functions: - if field.geography and lookup_type not in self.geography_functions: - raise ValueError('PostGIS geography type does not support the ' - '"%s" lookup.' % lookup_type) - - # See if a PostGIS geometry function matches the lookup type. - tmp = self.geometry_functions[lookup_type] - - # Lookup types that are tuples take tuple arguments, e.g., 'relate' and - # distance lookups. - if isinstance(tmp, tuple): - # First element of tuple is the PostGISOperation instance, and the - # second element is either the type or a tuple of acceptable types - # that may passed in as further parameters for the lookup type. - op, arg_type = tmp - - # Ensuring that a tuple _value_ was passed in from the user - if not isinstance(value, (tuple, list)): - raise ValueError('Tuple required for `%s` lookup type.' % lookup_type) - - # Geometry is first element of lookup tuple. - geom = value[0] - - # Number of valid tuple parameters depends on the lookup type. - nparams = len(value) - if not self.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): - raise ValueError('Argument type should be %s, got %s instead.' % (arg_type, type(value[1]))) - - # For lookup type `relate`, the op instance is not yet created (has - # to be instantiated here to check the pattern parameter). - if lookup_type == 'relate': - op = op(self.geom_func_prefix, value[1]) - elif lookup_type in self.distance_functions and lookup_type != 'dwithin': - if not field.geography and field.geodetic(self.connection): - # Setting up the geodetic operation appropriately. - if nparams == 3 and value[2] == 'spheroid': - op = op['spheroid'] - else: - op = op['sphere'] - else: - op = op['cartesian'] - else: - op = tmp - geom = value - - # Calling the `as_sql` function on the operation instance. - return op.as_sql(geo_col, self.get_geom_placeholder(field, geom)) - - elif lookup_type == 'isnull': - # Handling 'isnull' lookup type - return "%s IS %sNULL" % (geo_col, ('' if value else 'NOT ')), [] - - raise TypeError("Got invalid lookup_type: %s" % repr(lookup_type)) - def spatial_aggregate_sql(self, agg): """ Returns the spatial aggregate SQL template and function for the diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index b051b250a4e29..d95b2f6a13e8f 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -1,9 +1,8 @@ import re import sys -from decimal import Decimal from django.contrib.gis.db.backends.base import BaseSpatialOperations -from django.contrib.gis.db.backends.utils import SpatialOperation, SpatialFunction +from django.contrib.gis.db.backends.utils import SpatialOperator from django.contrib.gis.db.backends.spatialite.adapter import SpatiaLiteAdapter from django.contrib.gis.geometry.backend import Geometry from django.contrib.gis.measure import Distance @@ -14,52 +13,6 @@ from django.utils.functional import cached_property -class SpatiaLiteOperator(SpatialOperation): - "For SpatiaLite operators (e.g. `&&`, `~`)." - def __init__(self, operator): - super(SpatiaLiteOperator, self).__init__(operator=operator) - - -class SpatiaLiteFunction(SpatialFunction): - "For SpatiaLite function calls." - def __init__(self, function, **kwargs): - super(SpatiaLiteFunction, self).__init__(function, **kwargs) - - -class SpatiaLiteFunctionParam(SpatiaLiteFunction): - "For SpatiaLite functions that take another parameter." - sql_template = '%(function)s(%(geo_col)s, %(geometry)s, %%s)' - - -class SpatiaLiteDistance(SpatiaLiteFunction): - "For SpatiaLite distance operations." - dist_func = 'Distance' - sql_template = '%(function)s(%(geo_col)s, %(geometry)s) %(operator)s %%s' - - def __init__(self, operator): - super(SpatiaLiteDistance, self).__init__(self.dist_func, - operator=operator) - - -class SpatiaLiteRelate(SpatiaLiteFunctionParam): - "For SpatiaLite Relate(, ) calls." - pattern_regex = re.compile(r'^[012TF\*]{9}$') - - def __init__(self, pattern): - if not self.pattern_regex.match(pattern): - raise ValueError('Invalid intersection matrix pattern "%s".' % pattern) - super(SpatiaLiteRelate, self).__init__('Relate') - - -# Valid distance types and substitutions -dtypes = (Decimal, Distance, float) + six.integer_types - - -def get_dist_ops(operator): - "Returns operations for regular distances; spherical distances are not currently supported." - return (SpatiaLiteDistance(operator),) - - class SpatiaLiteOperations(DatabaseOperations, BaseSpatialOperations): compiler_module = 'django.contrib.gis.db.models.sql.compiler' name = 'spatialite' @@ -101,41 +54,31 @@ def valid_aggregates(self): from_wkb = 'GeomFromWKB' select = 'AsText(%s)' - geometry_functions = { - 'equals': SpatiaLiteFunction('Equals'), - 'disjoint': SpatiaLiteFunction('Disjoint'), - 'touches': SpatiaLiteFunction('Touches'), - 'crosses': SpatiaLiteFunction('Crosses'), - 'within': SpatiaLiteFunction('Within'), - 'overlaps': SpatiaLiteFunction('Overlaps'), - 'contains': SpatiaLiteFunction('Contains'), - 'intersects': SpatiaLiteFunction('Intersects'), - 'relate': (SpatiaLiteRelate, six.string_types), + gis_operators = { + 'equals': SpatialOperator(func='Equals'), + 'disjoint': SpatialOperator(func='Disjoint'), + 'touches': SpatialOperator(func='Touches'), + 'crosses': SpatialOperator(func='Crosses'), + 'within': SpatialOperator(func='Within'), + 'overlaps': SpatialOperator(func='Overlaps'), + 'contains': SpatialOperator(func='Contains'), + 'intersects': SpatialOperator(func='Intersects'), + 'relate': SpatialOperator(func='Relate'), # Returns true if B's bounding box completely contains A's bounding box. - 'contained': SpatiaLiteFunction('MbrWithin'), + 'contained': SpatialOperator(func='MbrWithin'), # Returns true if A's bounding box completely contains B's bounding box. - 'bbcontains': SpatiaLiteFunction('MbrContains'), + 'bbcontains': SpatialOperator(func='MbrContains'), # Returns true if A's bounding box overlaps B's bounding box. - 'bboverlaps': SpatiaLiteFunction('MbrOverlaps'), + 'bboverlaps': SpatialOperator(func='MbrOverlaps'), # These are implemented here as synonyms for Equals - 'same_as': SpatiaLiteFunction('Equals'), - 'exact': SpatiaLiteFunction('Equals'), - } + 'same_as': SpatialOperator(func='Equals'), + 'exact': SpatialOperator(func='Equals'), - distance_functions = { - 'distance_gt': (get_dist_ops('>'), dtypes), - 'distance_gte': (get_dist_ops('>='), dtypes), - 'distance_lt': (get_dist_ops('<'), dtypes), - 'distance_lte': (get_dist_ops('<='), dtypes), + 'distance_gt': SpatialOperator(func='Distance', op='>'), + 'distance_gte': SpatialOperator(func='Distance', op='>='), + 'distance_lt': SpatialOperator(func='Distance', op='<'), + 'distance_lte': SpatialOperator(func='Distance', op='<='), } - geometry_functions.update(distance_functions) - - def __init__(self, connection): - super(DatabaseOperations, self).__init__(connection) - - # Creating the GIS terms dictionary. - self.gis_terms = {'isnull'} - self.gis_terms.update(self.geometry_functions) @cached_property def spatial_version(self): @@ -316,58 +259,6 @@ def spatial_aggregate_sql(self, agg): sql_function = getattr(self, agg_name) return sql_template, sql_function - def spatial_lookup_sql(self, lvalue, lookup_type, value, field, qn): - """ - Returns the SpatiaLite-specific SQL for the given lookup value - [a tuple of (alias, column, db_type)], lookup type, lookup - value, the model field, and the quoting function. - """ - geo_col, db_type = lvalue - - if lookup_type in self.geometry_functions: - # See if a SpatiaLite geometry function matches the lookup type. - tmp = self.geometry_functions[lookup_type] - - # Lookup types that are tuples take tuple arguments, e.g., 'relate' and - # distance lookups. - if isinstance(tmp, tuple): - # First element of tuple is the SpatiaLiteOperation instance, and the - # second element is either the type or a tuple of acceptable types - # that may passed in as further parameters for the lookup type. - op, arg_type = tmp - - # Ensuring that a tuple _value_ was passed in from the user - if not isinstance(value, (tuple, list)): - raise ValueError('Tuple required for `%s` lookup type.' % lookup_type) - - # Geometry is first element of lookup tuple. - geom = value[0] - - # Number of valid tuple parameters depends on the lookup type. - if len(value) != 2: - 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): - raise ValueError('Argument type should be %s, got %s instead.' % (arg_type, type(value[1]))) - - # For lookup type `relate`, the op instance is not yet created (has - # to be instantiated here to check the pattern parameter). - if lookup_type == 'relate': - op = op(value[1]) - elif lookup_type in self.distance_functions: - op = op[0] - else: - op = tmp - geom = value - # Calling the `as_sql` function on the operation instance. - return op.as_sql(geo_col, self.get_geom_placeholder(field, geom)) - elif lookup_type == 'isnull': - # Handling 'isnull' lookup type - return "%s IS %sNULL" % (geo_col, ('' if value else 'NOT ')), [] - - raise TypeError("Got invalid lookup_type: %s" % repr(lookup_type)) - # Routines for getting the OGC-compliant models. def geometry_columns(self): from django.contrib.gis.db.backends.spatialite.models import SpatialiteGeometryColumns diff --git a/django/contrib/gis/db/backends/utils.py b/django/contrib/gis/db/backends/utils.py index b8b5b9525f1e3..9c3b700852cae 100644 --- a/django/contrib/gis/db/backends/utils.py +++ b/django/contrib/gis/db/backends/utils.py @@ -4,43 +4,24 @@ """ -class SpatialOperation(object): +class SpatialOperator(object): """ - Base class for generating spatial SQL. + Class encapsulating the behavior specific to a GIS operation (used by lookups). """ - sql_template = '%(geo_col)s %(operator)s %(geometry)s' - - def __init__(self, function='', operator='', result='', **kwargs): - self.function = function - self.operator = operator - self.result = result - self.extra = kwargs - - def as_sql(self, geo_col, geometry='%s'): - return self.sql_template % self.params(geo_col, geometry), [] - - def params(self, geo_col, geometry): - params = {'function': self.function, - 'geo_col': geo_col, - 'geometry': geometry, - 'operator': self.operator, - 'result': self.result, - } - params.update(self.extra) - return params - - -class SpatialFunction(SpatialOperation): - """ - Base class for generating spatial SQL related to a function. - """ - sql_template = '%(function)s(%(geo_col)s, %(geometry)s)' - - def __init__(self, func, result='', operator='', **kwargs): - # Getting the function prefix. - default = {'function': func, - 'operator': operator, - 'result': result - } - kwargs.update(default) - super(SpatialFunction, self).__init__(**kwargs) + sql_template = None + + def __init__(self, op=None, func=None): + self.op = op + self.func = func + + @property + def default_template(self): + if self.func: + return '%(func)s(%(lhs)s, %(rhs)s)' + else: + return '%(lhs)s %(op)s %(rhs)s' + + def as_sql(self, connection, lookup, template_params, sql_params): + sql_template = self.sql_template or lookup.sql_template or self.default_template + template_params.update({'op': self.op, 'func': self.func}) + return sql_template % template_params, sql_params diff --git a/django/contrib/gis/db/models/constants.py b/django/contrib/gis/db/models/constants.py deleted file mode 100644 index 4ece41415c674..0000000000000 --- a/django/contrib/gis/db/models/constants.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db.models.sql.constants import QUERY_TERMS - -GIS_LOOKUPS = { - 'bbcontains', 'bboverlaps', 'contained', 'contains', - 'contains_properly', 'coveredby', 'covers', 'crosses', 'disjoint', - 'distance_gt', 'distance_gte', 'distance_lt', 'distance_lte', - 'dwithin', 'equals', 'exact', - 'intersects', 'overlaps', 'relate', 'same_as', 'touches', 'within', - 'left', 'right', 'overlaps_left', 'overlaps_right', - 'overlaps_above', 'overlaps_below', - 'strictly_above', 'strictly_below' -} -ALL_TERMS = GIS_LOOKUPS | QUERY_TERMS - -__all__ = ['ALL_TERMS', 'GIS_LOOKUPS'] diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py index c79548ed8a6cf..e260f67cbf93f 100644 --- a/django/contrib/gis/db/models/fields.py +++ b/django/contrib/gis/db/models/fields.py @@ -2,8 +2,7 @@ from django.db.models.sql.expressions import SQLEvaluator from django.utils.translation import ugettext_lazy as _ from django.contrib.gis import forms -from django.contrib.gis.db.models.constants import GIS_LOOKUPS -from django.contrib.gis.db.models.lookups import GISLookup +from django.contrib.gis.db.models.lookups import gis_lookups from django.contrib.gis.db.models.proxy import GeometryProxy from django.contrib.gis.geometry.backend import Geometry, GeometryException from django.utils import six @@ -243,16 +242,15 @@ def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False): parameters into the correct units for the coordinate system of the field. """ - if lookup_type in connection.ops.gis_terms: - # special case for isnull lookup - if lookup_type == 'isnull': - return [] - + # special case for isnull lookup + if lookup_type == 'isnull': + return [] + elif lookup_type in self.class_lookups: # Populating the parameters list, and wrapping the Geometry # with the Adapter of the spatial backend. if isinstance(value, (tuple, list)): params = [connection.ops.Adapter(value[0])] - if lookup_type in connection.ops.distance_functions: + if self.class_lookups[lookup_type].distance: # Getting the distance parameter in the units of the field. params += self.get_distance(value[1:], lookup_type, connection) elif lookup_type in connection.ops.truncate_params: @@ -291,9 +289,9 @@ def get_placeholder(self, value, connection): """ return connection.ops.get_geom_placeholder(self, value) -for lookup_name in GIS_LOOKUPS: - lookup = type(lookup_name, (GISLookup,), {'lookup_name': lookup_name}) - GeometryField.register_lookup(lookup) + +for klass in gis_lookups.values(): + GeometryField.register_lookup(klass) # The OpenGIS Geometry Type Fields diff --git a/django/contrib/gis/db/models/lookups.py b/django/contrib/gis/db/models/lookups.py index 9552881c6cb73..d3b53b32f0fca 100644 --- a/django/contrib/gis/db/models/lookups.py +++ b/django/contrib/gis/db/models/lookups.py @@ -1,10 +1,20 @@ +from __future__ import unicode_literals +import re + from django.db.models.constants import LOOKUP_SEP from django.db.models.fields import FieldDoesNotExist from django.db.models.lookups import Lookup from django.db.models.sql.expressions import SQLEvaluator +from django.utils import six + +gis_lookups = {} class GISLookup(Lookup): + sql_template = None + transform_func = None + distance = False + @classmethod def _check_geo_field(cls, opts, lookup): """ @@ -45,10 +55,19 @@ def _check_geo_field(cls, opts, lookup): else: return False - def as_sql(self, qn, connection): - # We use the same approach as was used by GeoWhereNode. It would - # be a good idea to upgrade GIS to use similar code that is used - # for other lookups. + def get_db_prep_lookup(self, value, connection): + # get_db_prep_lookup is called by process_rhs from super class + if isinstance(value, (tuple, list)): + # First param is assumed to be the geometric object + params = [connection.ops.Adapter(value[0])] + list(value)[1:] + else: + params = [connection.ops.Adapter(value)] + return ('%s', params) + + def process_rhs(self, qn, connection): + rhs, rhs_params = super(GISLookup, self).process_rhs(qn, connection) + + geom = self.rhs if isinstance(self.rhs, SQLEvaluator): # Make sure the F Expression destination field exists, and # set an `srid` attribute with the same as that of the @@ -57,13 +76,256 @@ def as_sql(self, qn, connection): if not geo_fld: raise ValueError('No geographic field found in expression.') self.rhs.srid = geo_fld.srid - db_type = self.lhs.output_field.db_type(connection=connection) - params = self.lhs.output_field.get_db_prep_lookup( - self.lookup_name, self.rhs, connection=connection) - lhs_sql, lhs_params = self.process_lhs(qn, connection) - # lhs_params not currently supported. - assert not lhs_params - data = (lhs_sql, db_type) - spatial_sql, spatial_params = connection.ops.spatial_lookup_sql( - data, self.lookup_name, self.rhs, self.lhs.output_field, qn) - return spatial_sql, spatial_params + params + elif isinstance(self.rhs, (list, tuple)): + geom = self.rhs[0] + + rhs = connection.ops.get_geom_placeholder(self.lhs.source, geom) + return rhs, rhs_params + + def as_sql(self, qn, connection): + lhs_sql, sql_params = self.process_lhs(qn, connection) + rhs_sql, rhs_params = self.process_rhs(qn, connection) + sql_params.extend(rhs_params) + + template_params = {'lhs': lhs_sql, 'rhs': rhs_sql} + backend_op = connection.ops.gis_operators[self.lookup_name] + return backend_op.as_sql(connection, self, template_params, sql_params) + + +# ------------------ +# Geometry operators +# ------------------ + +class OverlapsLeftLookup(GISLookup): + """ + The overlaps_left operator returns true if A's bounding box overlaps or is to the + left of B's bounding box. + """ + lookup_name = 'overlaps_left' +gis_lookups['overlaps_left'] = OverlapsLeftLookup + + +class OverlapsRightLookup(GISLookup): + """ + The 'overlaps_right' operator returns true if A's bounding box overlaps or is to the + right of B's bounding box. + """ + lookup_name = 'overlaps_right' +gis_lookups['overlaps_right'] = OverlapsRightLookup + + +class OverlapsBelowLookup(GISLookup): + """ + The 'overlaps_below' operator returns true if A's bounding box overlaps or is below + B's bounding box. + """ + lookup_name = 'overlaps_below' +gis_lookups['overlaps_below'] = OverlapsBelowLookup + + +class OverlapsAboveLookup(GISLookup): + """ + The 'overlaps_above' operator returns true if A's bounding box overlaps or is above + B's bounding box. + """ + lookup_name = 'overlaps_above' +gis_lookups['overlaps_above'] = OverlapsAboveLookup + + +class LeftLookup(GISLookup): + """ + The 'left' operator returns true if A's bounding box is strictly to the left + of B's bounding box. + """ + lookup_name = 'left' +gis_lookups['left'] = LeftLookup + + +class RightLookup(GISLookup): + """ + The 'right' operator returns true if A's bounding box is strictly to the right + of B's bounding box. + """ + lookup_name = 'right' +gis_lookups['right'] = RightLookup + + +class StrictlyBelowLookup(GISLookup): + """ + The 'strictly_below' operator returns true if A's bounding box is strictly below B's + bounding box. + """ + lookup_name = 'strictly_below' +gis_lookups['strictly_below'] = StrictlyBelowLookup + + +class StrictlyAboveLookup(GISLookup): + """ + The 'strictly_above' operator returns true if A's bounding box is strictly above B's + bounding box. + """ + lookup_name = 'strictly_above' +gis_lookups['strictly_above'] = StrictlyAboveLookup + + +class SameAsLookup(GISLookup): + """ + The "~=" operator is the "same as" operator. It tests actual geometric + equality of two features. So if A and B are the same feature, + vertex-by-vertex, the operator returns true. + """ + lookup_name = 'same_as' +gis_lookups['same_as'] = SameAsLookup + + +class ExactLookup(SameAsLookup): + # Alias of same_as + lookup_name = 'exact' +gis_lookups['exact'] = ExactLookup + + +class BBContainsLookup(GISLookup): + """ + The 'bbcontains' operator returns true if A's bounding box completely contains + by B's bounding box. + """ + lookup_name = 'bbcontains' +gis_lookups['bbcontains'] = BBContainsLookup + + +class BBOverlapsLookup(GISLookup): + """ + The 'bboverlaps' operator returns true if A's bounding box overlaps B's bounding box. + """ + lookup_name = 'bboverlaps' +gis_lookups['bboverlaps'] = BBOverlapsLookup + + +class ContainedLookup(GISLookup): + """ + The 'contained' operator returns true if A's bounding box is completely contained + by B's bounding box. + """ + lookup_name = 'contained' +gis_lookups['contained'] = ContainedLookup + + +# ------------------ +# Geometry functions +# ------------------ + +class ContainsLookup(GISLookup): + lookup_name = 'contains' +gis_lookups['contains'] = ContainsLookup + + +class ContainsProperlyLookup(GISLookup): + lookup_name = 'contains_properly' +gis_lookups['contains_properly'] = ContainsProperlyLookup + + +class CoveredByLookup(GISLookup): + lookup_name = 'coveredby' +gis_lookups['coveredby'] = CoveredByLookup + + +class CoversLookup(GISLookup): + lookup_name = 'covers' +gis_lookups['covers'] = CoversLookup + + +class CrossesLookup(GISLookup): + lookup_name = 'crosses' +gis_lookups['crosses'] = CrossesLookup + + +class DisjointLookup(GISLookup): + lookup_name = 'disjoint' +gis_lookups['disjoint'] = DisjointLookup + + +class EqualsLookup(GISLookup): + lookup_name = 'equals' +gis_lookups['equals'] = EqualsLookup + + +class IntersectsLookup(GISLookup): + lookup_name = 'intersects' +gis_lookups['intersects'] = IntersectsLookup + + +class OverlapsLookup(GISLookup): + lookup_name = 'overlaps' +gis_lookups['overlaps'] = OverlapsLookup + + +class RelateLookup(GISLookup): + lookup_name = 'relate' + sql_template = '%(func)s(%(lhs)s, %(rhs)s, %%s)' + pattern_regex = re.compile(r'^[012TF\*]{9}$') + + def get_db_prep_lookup(self, value, connection): + if len(value) != 2: + raise ValueError('relate must be passed a two-tuple') + # Check the pattern argument + backend_op = connection.ops.gis_operators[self.lookup_name] + if hasattr(backend_op, 'check_relate_argument'): + backend_op.check_relate_argument(value[1]) + else: + pattern = value[1] + if not isinstance(pattern, six.string_types) or not self.pattern_regex.match(pattern): + raise ValueError('Invalid intersection matrix pattern "%s".' % pattern) + return super(RelateLookup, self).get_db_prep_lookup(value, connection) +gis_lookups['relate'] = RelateLookup + + +class TouchesLookup(GISLookup): + lookup_name = 'touches' +gis_lookups['touches'] = TouchesLookup + + +class WithinLookup(GISLookup): + lookup_name = 'within' +gis_lookups['within'] = WithinLookup + + +class DistanceLookupBase(GISLookup): + distance = True + sql_template = '%(func)s(%(lhs)s, %(rhs)s) %(op)s %%s' + + def get_db_prep_lookup(self, value, connection): + if isinstance(value, (tuple, list)): + if not 2 <= len(value) <= 3: + raise ValueError("2 or 3-element tuple required for '%s' lookup." % self.lookup_name) + params = [connection.ops.Adapter(value[0])] + # Getting the distance parameter in the units of the field. + params += connection.ops.get_distance(self.lhs.output_field, value[1:], self.lookup_name) + return ('%s', params) + else: + return super(DistanceLookupBase, self).get_db_prep_lookup(value, connection) + + +class DWithinLookup(DistanceLookupBase): + lookup_name = 'dwithin' + sql_template = '%(func)s(%(lhs)s, %(rhs)s, %%s)' +gis_lookups['dwithin'] = DWithinLookup + + +class DistanceGTLookup(DistanceLookupBase): + lookup_name = 'distance_gt' +gis_lookups['distance_gt'] = DistanceGTLookup + + +class DistanceGTELookup(DistanceLookupBase): + lookup_name = 'distance_gte' +gis_lookups['distance_gte'] = DistanceGTELookup + + +class DistanceLTLookup(DistanceLookupBase): + lookup_name = 'distance_lt' +gis_lookups['distance_lt'] = DistanceLTLookup + + +class DistanceLTELookup(DistanceLookupBase): + lookup_name = 'distance_lte' +gis_lookups['distance_lte'] = DistanceLTELookup diff --git a/django/contrib/gis/db/models/sql/query.py b/django/contrib/gis/db/models/sql/query.py index e82f17b8a316a..d5a7f819c831b 100644 --- a/django/contrib/gis/db/models/sql/query.py +++ b/django/contrib/gis/db/models/sql/query.py @@ -1,7 +1,7 @@ from django.db import connections from django.db.models.query import sql +from django.db.models.sql.constants import QUERY_TERMS -from django.contrib.gis.db.models.constants import ALL_TERMS from django.contrib.gis.db.models.fields import GeometryField from django.contrib.gis.db.models.lookups import GISLookup from django.contrib.gis.db.models.sql import aggregates as gis_aggregates @@ -13,7 +13,7 @@ class GeoQuery(sql.Query): A single spatial SQL query. """ # Overridding the valid query terms. - query_terms = ALL_TERMS + query_terms = QUERY_TERMS | set(GeometryField.class_lookups.keys()) aggregates_module = gis_aggregates compiler = 'GeoSQLCompiler' diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index 631b0bd2f04b2..db117e917e826 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -612,6 +612,9 @@ Miscellaneous :func:`~django.core.urlresolvers.reverse_lazy` now return Unicode strings instead of byte strings. +* GIS-specific lookups have been refactored to use the + :class:`django.db.models.Lookup` API. + .. _deprecated-features-1.8: Features deprecated in 1.8