From 8b279b63bef5c1348cc27c50633fc2d5ef09d7c1 Mon Sep 17 00:00:00 2001 From: Jeremy Dunck Date: Fri, 23 Mar 2007 16:32:00 +0000 Subject: [PATCH] gis: checked in latest from jbronn from Mar 19. git-svn-id: http://code.djangoproject.com/svn/django/branches/gis@4785 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/gis/__init__.py | 71 -------- django/contrib/gis/manager.py | 13 ++ django/contrib/gis/postgis.py | 302 +++++++++++++++++++++++++++++++++ django/contrib/gis/query.py | 36 ++++ 4 files changed, 351 insertions(+), 71 deletions(-) create mode 100644 django/contrib/gis/manager.py create mode 100644 django/contrib/gis/postgis.py create mode 100644 django/contrib/gis/query.py diff --git a/django/contrib/gis/__init__.py b/django/contrib/gis/__init__.py index 2266ce4b95..e69de29bb2 100644 --- a/django/contrib/gis/__init__.py +++ b/django/contrib/gis/__init__.py @@ -1,71 +0,0 @@ -from geos import geomFromWKT, geomToWKT -from decimal import Decimal -from django.db import models -from django.db.models.query import QuerySet - - -class GeoQuerySet(QuerySet): - # The list of valid query terms - # override the local QUERY_TERMS in the namespace - # not sure how to do that locals() hackery - # possibly in the init change its locals() variables - # not sure if that will work - - QUERY_TERMS = ( - 'exact', 'iexact', 'contains', 'icontains', 'overlaps', - 'gt', 'gte', 'lt', 'lte', 'in', - 'startswith', 'istartswith', 'endswith', 'iendswith', - 'range', 'year', 'month', 'day', 'isnull', 'search', -) - - -def dprint(arg): - import re - import inspect - print re.match("^\s*dprint\(\s*(.+)\s*\)", inspect.stack()[1][4][0]).group(1) + ": " + repr(arg) - - - -class GeometryManager(models.Manager): - #def filter(self, *args, **kwargs): - # super(Manager, self).filter(*args, **kwargs) - # return self.get_query_set().filter(*args, **kwargs) - - def get_query_set(self): - return GeoQuerySet(self.model) - - - - -class BoundingBox: - - def _geom(self): - return geomToWKT(self._g) - - geom = property(_geom) - - def _area(self): - return self._g.area() - - area = property(_area) - - - def __init__(self, ne, sw): - """ - Create a bounding box using two points - This points come from a JSON request, so they are strings - """ - ne = [Decimal(i.strip(' ')) for i in ne[1:-1].split(',')] - sw = [Decimal(i.strip(' ')) for i in sw[1:-1].split(',')] - ne_lat = ne[0] - ne_lng = ne[1] - sw_lat = sw[0] - sw_lng = sw[1] - bb = 'POLYGON((' - bb += str(ne_lng) + " " + str(ne_lat) + "," - bb += str(ne_lng) + " " + str(sw_lat) + "," - bb += str(sw_lng) + " " + str(sw_lat) + "," - bb += str(sw_lng) + " " + str(ne_lat) + "," - bb += str(ne_lng) + " " + str(ne_lat) - bb += '))' - self._g = geomFromWKT(bb) diff --git a/django/contrib/gis/manager.py b/django/contrib/gis/manager.py new file mode 100644 index 0000000000..10c6ec7018 --- /dev/null +++ b/django/contrib/gis/manager.py @@ -0,0 +1,13 @@ +from django.db.models.manager import Manager +from django.contrib.gis.db.models.query import GeoQuerySet + +class GeoManager(Manager): + + def get_query_set(self): + return GeoQuerySet(model=self.model) + + def geo_filter(self, *args, **kwargs): + return self.get_query_set().geo_filter(*args, **kwargs) + + def geo_exclude(self, *args, **kwargs): + return self.get_query_set().geo_exclude(*args, **kwargs) diff --git a/django/contrib/gis/postgis.py b/django/contrib/gis/postgis.py new file mode 100644 index 0000000000..e4fd492dbf --- /dev/null +++ b/django/contrib/gis/postgis.py @@ -0,0 +1,302 @@ +# This module is meant to re-define the helper routines used by the +# django.db.models.query objects to be customized for PostGIS. +from copy import copy +from django.db import backend +from django.db.models.query import \ + LOOKUP_SEPARATOR, QUERY_TERMS, \ + find_field, FieldFound, get_where_clause +from django.utils.datastructures import SortedDict + +# PostGIS-specific operators. The commented descriptions of these +# operators come from Section 6.2.2 of the official PostGIS documentation. +POSTGIS_OPERATORS = { + # The "&<" operator returns true if A's bounding box overlaps or is to the left of B's bounding box. + 'overlapsleft' : '&< %s', + # The "&>" operator returns true if A's bounding box overlaps or is to the right of B's bounding box. + 'overlapsright' : '&> %s', + # The "<<" operator returns true if A's bounding box is strictly to the left of B's bounding box. + 'left' : '<< %s', + # The ">>" operator returns true if A's bounding box is strictly to the right of B's bounding box. + 'right' : '>> %s', + # The "&<|" operator returns true if A's bounding box overlaps or is below B's bounding box. + 'overlapsbelow' : '&<| %s', + # The "|&>" operator returns true if A's bounding box overlaps or is above B's bounding box. + 'overlapsabove' : '|&> %s', + # The "<<|" operator returns true if A's bounding box is strictly below B's bounding box. + 'strictlybelow' : '<<| %s', + # The "|>>" operator returns true if A's bounding box is strictly above B's bounding box. + 'strictlyabove' : '|>> %s', + # 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. + 'sameas' : '~= %s', + # The "@" operator returns true if A's bounding box is completely contained by B's bounding box. + 'contained' : '@ %s', + # The "~" operator returns true if A's bounding box completely contains B's bounding box. + 'bbcontains' : '~ %s', + # The "&&" operator is the "overlaps" operator. If A's bounding boux overlaps B's bounding box the + # operator returns true. + 'bboverlaps' : '&& %s', + } + +# PostGIS Geometry Functions -- most of these use GEOS. +POSTGIS_GEOMETRY_FUNCTIONS = { + 'distance' : 'Distance', + 'equals' : 'Equals', + 'disjoint' : 'Disjoint', + 'intersects' : 'Intersects', + 'touches' : 'Touches', + 'crosses' : 'Crosses', + 'within' : 'Within', + 'overlaps' : 'Overlaps', + 'contains' : 'Contains', + 'intersects' : 'Intersects', + 'relate' : 'Relate', + } + +# These are the PostGIS-customized QUERY_TERMS, combines both the operators +# and the geometry functions. +POSTGIS_TERMS = list(POSTGIS_OPERATORS.keys()) # Getting the operators first +POSTGIS_TERMS.extend(list(POSTGIS_GEOMETRY_FUNCTIONS.keys())) # Adding on the Geometry Functions + +def get_geo_where_clause(lookup_type, table_prefix, field_name, value): + if table_prefix.endswith('.'): + table_prefix = backend.quote_name(table_prefix[:-1])+'.' + field_name = backend.quote_name(field_name) + + # See if a PostGIS operator matches the lookup type first + try: + return '%s%s %s' % (table_prefix, field_name, (POSTGIS_OPERATORS[lookup_type] % '%s')) + except KeyError: + pass + + # See if a PostGIS Geometry function matches the lookup type next + try: + return '%s(%s%s, %%s)' % (POSTGIS_GEOMETRY_FUNCTIONS[lookup_type], table_prefix, field_name) + except KeyError: + pass + + # For any other 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) + +def geo_parse_lookup(kwarg_items, opts): + # Helper function that handles converting API kwargs + # (e.g. "name__exact": "tom") to SQL. + # Returns a tuple of (tables, joins, where, params). + + # 'joins' is a sorted dictionary describing the tables that must be joined + # to complete the query. The dictionary is sorted because creation order + # is significant; it is a dictionary to ensure uniqueness of alias names. + # + # Each key-value pair follows the form + # alias: (table, join_type, condition) + # where + # alias is the AS alias for the joined table + # table is the actual table name to be joined + # join_type is the type of join (INNER JOIN, LEFT OUTER JOIN, etc) + # condition is the where-like statement over which narrows the join. + # alias will be derived from the lookup list name. + # + # At present, this method only every returns INNER JOINs; the option is + # there for others to implement custom Q()s, etc that return other join + # types. + joins, where, params = SortedDict(), [], [] + + for kwarg, value in kwarg_items: + path = kwarg.split(LOOKUP_SEPARATOR) + # Extract the last elements of the kwarg. + # The very-last is the lookup_type (equals, like, etc). + # The second-last is the table column on which the lookup_type is + # to be performed. If this name is 'pk', it will be substituted with + # the name of the primary key. + # If there is only one part, or the last part is not a query + # term, assume that the query is an __exact + lookup_type = path.pop() + if lookup_type == 'pk': + lookup_type = 'exact' + path.append(None) + elif len(path) == 0 or lookup_type not in POSTGIS_TERMS: + path.append(lookup_type) + lookup_type = 'exact' + + if len(path) < 1: + raise TypeError, "Cannot parse keyword query %r" % kwarg + + if value is None: + # Interpret '__exact=None' as the sql '= NULL'; otherwise, reject + # all uses of None as a query value. + if lookup_type != 'exact': + raise ValueError, "Cannot use None as a query value" + + joins2, where2, params2 = lookup_inner(path, lookup_type, value, opts, opts.db_table, None) + joins.update(joins2) + where.extend(where2) + params.extend(params2) + return joins, where, params + +def lookup_inner(path, lookup_type, value, opts, table, column): + qn = backend.quote_name + joins, where, params = SortedDict(), [], [] + current_opts = opts + current_table = table + current_column = column + intermediate_table = None + join_required = False + + name = path.pop(0) + # Has the primary key been requested? If so, expand it out + # to be the name of the current class' primary key + if name is None or name == 'pk': + name = current_opts.pk.name + + # Try to find the name in the fields associated with the current class + try: + # Does the name belong to a defined many-to-many field? + field = find_field(name, current_opts.many_to_many, False) + if field: + new_table = current_table + '__' + name + new_opts = field.rel.to._meta + new_column = new_opts.pk.column + + # Need to create an intermediate table join over the m2m table + # This process hijacks current_table/column to point to the + # intermediate table. + current_table = "m2m_" + new_table + intermediate_table = field.m2m_db_table() + join_column = field.m2m_reverse_name() + intermediate_column = field.m2m_column_name() + + raise FieldFound + + # Does the name belong to a reverse defined many-to-many field? + field = find_field(name, current_opts.get_all_related_many_to_many_objects(), True) + if field: + new_table = current_table + '__' + name + new_opts = field.opts + new_column = new_opts.pk.column + + # Need to create an intermediate table join over the m2m table. + # This process hijacks current_table/column to point to the + # intermediate table. + current_table = "m2m_" + new_table + intermediate_table = field.field.m2m_db_table() + join_column = field.field.m2m_column_name() + intermediate_column = field.field.m2m_reverse_name() + + raise FieldFound + + # Does the name belong to a one-to-many field? + field = find_field(name, current_opts.get_all_related_objects(), True) + if field: + new_table = table + '__' + name + new_opts = field.opts + new_column = field.field.column + join_column = opts.pk.column + + # 1-N fields MUST be joined, regardless of any other conditions. + join_required = True + + raise FieldFound + + # Does the name belong to a one-to-one, many-to-one, or regular field? + field = find_field(name, current_opts.fields, False) + if field: + if field.rel: # One-to-One/Many-to-one field + new_table = current_table + '__' + name + new_opts = field.rel.to._meta + new_column = new_opts.pk.column + join_column = field.column + raise FieldFound + elif path: + # For regular fields, if there are still items on the path, + # an error has been made. We munge "name" so that the error + # properly identifies the cause of the problem. + name += LOOKUP_SEPARATOR + path[0] + else: + raise FieldFound + + except FieldFound: # Match found, loop has been shortcut. + pass + else: # No match found. + raise TypeError, "Cannot resolve keyword '%s' into field" % name + + # Check whether an intermediate join is required between current_table + # and new_table. + if intermediate_table: + joins[qn(current_table)] = ( + qn(intermediate_table), "LEFT OUTER JOIN", + "%s.%s = %s.%s" % (qn(table), qn(current_opts.pk.column), qn(current_table), qn(intermediate_column)) + ) + + if path: + # There are elements left in the path. More joins are required. + if len(path) == 1 and path[0] in (new_opts.pk.name, None) \ + and lookup_type in ('exact', 'isnull') and not join_required: + # If the next and final name query is for a primary key, + # and the search is for isnull/exact, then the current + # (for N-1) or intermediate (for N-N) table can be used + # for the search. No need to join an extra table just + # to check the primary key. + new_table = current_table + else: + # There are 1 or more name queries pending, and we have ruled out + # any shortcuts; therefore, a join is required. + joins[qn(new_table)] = ( + qn(new_opts.db_table), "INNER JOIN", + "%s.%s = %s.%s" % (qn(current_table), qn(join_column), qn(new_table), qn(new_column)) + ) + # If we have made the join, we don't need to tell subsequent + # recursive calls about the column name we joined on. + join_column = None + + # There are name queries remaining. Recurse deeper. + joins2, where2, params2 = lookup_inner(path, lookup_type, value, new_opts, new_table, join_column) + + joins.update(joins2) + where.extend(where2) + params.extend(params2) + else: + # No elements left in path. Current element is the element on which + # the search is being performed. + + if join_required: + # Last query term is a RelatedObject + if field.field.rel.multiple: + # RelatedObject is from a 1-N relation. + # Join is required; query operates on joined table. + column = new_opts.pk.name + joins[qn(new_table)] = ( + qn(new_opts.db_table), "INNER JOIN", + "%s.%s = %s.%s" % (qn(current_table), qn(join_column), qn(new_table), qn(new_column)) + ) + current_table = new_table + else: + # RelatedObject is from a 1-1 relation, + # No need to join; get the pk value from the related object, + # and compare using that. + column = current_opts.pk.name + elif intermediate_table: + # Last query term is a related object from an N-N relation. + # Join from intermediate table is sufficient. + column = join_column + elif name == current_opts.pk.name and lookup_type in ('exact', 'isnull') and current_column: + # Last query term is for a primary key. If previous iterations + # introduced a current/intermediate table that can be used to + # optimize the query, then use that table and column name. + column = current_column + else: + # Last query term was a normal field. + column = field.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'): + where.append(get_geo_where_clause(lookup_type, current_table + '.', column, value)) + else: + raise TypeError, 'Field "%s" (%s) is not a Geometry Field.' % (column, field.__class__.__name__) + params.extend(field.get_db_prep_lookup(lookup_type, value)) + + return joins, where, params + diff --git a/django/contrib/gis/query.py b/django/contrib/gis/query.py new file mode 100644 index 0000000000..c2a2e66677 --- /dev/null +++ b/django/contrib/gis/query.py @@ -0,0 +1,36 @@ +from django.db.models.query import * +from django.contrib.gis.db.models.postgis import geo_parse_lookup + +class GeoQ(Q): + "Geographical query encapsulation object." + + def get_sql(self, opts): + "Overloaded to use the geo_parse_lookup() function instead of parse_lookup()" + return geo_parse_lookup(self.kwargs.items(), opts) + +class GeoQuerySet(QuerySet): + "Geographical-enabled QuerySet object." + + def geo_filter(self, *args, **kwargs): + "Returns a new GeoQuerySet instance with the args ANDed to the existing set." + return self._geo_filter_or_exclude(None, *args, **kwargs) + + def geo_exclude(self, *args, **kwargs): + "Returns a new GeoQuerySet instance with NOT (args) ANDed to the existing set." + return self._geo_filter_or_exclude(QNot, *args, **kwargs) + + def _geo_filter_or_exclude(self, mapper, *args, **kwargs): + # mapper is a callable used to transform Q objects, + # or None for identity transform + if mapper is None: + mapper = lambda x: x + if len(args) > 0 or len(kwargs) > 0: + assert self._limit is None and self._offset is None, \ + "Cannot filter a query once a slice has been taken." + + clone = self._clone() + if len(kwargs) > 0: + clone._filters = clone._filters & mapper(GeoQ(**kwargs)) # Using the GeoQ object for our filters instead + if len(args) > 0: + clone._filters = clone._filters & reduce(operator.and_, map(mapper, args)) + return clone