Permalink
Browse files

Fixed #3566 -- Added support for aggregation to the ORM. See the docu…

…mentation for details on usage.

Many thanks to:
 * Nicolas Lara, who worked on this feature during the 2008 Google Summer of Code.
 * Alex Gaynor for his help debugging and fixing a number of issues.
 * Justin Bronn for his help integrating with contrib.gis.
 * Karen Tracey for her help with cross-platform testing.
 * Ian Kelly for his help testing and fixing Oracle support.
 * Malcolm Tredinnick for his invaluable review notes.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@9742 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
1 parent 50a293a commit cc4e4d9aee0b3ebfb45bee01aec79edc9e144c78 @freakboy3742 freakboy3742 committed Jan 15, 2009
View
@@ -31,6 +31,7 @@ answer newbie questions, and generally made Django that much better:
AgarFu <heaven@croasanaso.sytes.net>
Dagur Páll Ammendrup <dagurp@gmail.com>
Collin Anderson <cmawebsite@gmail.com>
+ Nicolas Lara <nicolaslara@gmail.com>
Jeff Anderson <jefferya@programmerq.net>
Marian Andre <django@andre.sk>
Andreas
@@ -0,0 +1,10 @@
+from django.db.models import Aggregate
+
+class Extent(Aggregate):
+ name = 'Extent'
+
+class MakeLine(Aggregate):
+ name = 'MakeLine'
+
+class Union(Aggregate):
+ name = 'Union'

Large diffs are not rendered by default.

Oops, something went wrong.
@@ -0,0 +1,36 @@
+from django.db.models.sql.aggregates import *
+
+from django.contrib.gis.db.models.fields import GeometryField
+from django.contrib.gis.db.backend import SpatialBackend
+
+if SpatialBackend.oracle:
+ geo_template = '%(function)s(SDOAGGRTYPE(%(field)s,%(tolerance)s))'
+else:
+ geo_template = '%(function)s(%(field)s)'
+
+class GeoAggregate(Aggregate):
+ # Overriding the SQL template with the geographic one.
+ sql_template = geo_template
+
+ is_extent = False
+
+ def __init__(self, col, source=None, is_summary=False, **extra):
+ super(GeoAggregate, self).__init__(col, source, is_summary, **extra)
+
+ # Can't use geographic aggregates on non-geometry fields.
+ if not isinstance(self.source, GeometryField):
+ raise ValueError('Geospatial aggregates only allowed on geometry fields.')
+
+ # Making sure the SQL function is available for this spatial backend.
+ if not self.sql_function:
+ raise NotImplementedError('This aggregate functionality not implemented for your spatial backend.')
+
+class Extent(GeoAggregate):
+ is_extent = True
+ sql_function = SpatialBackend.extent
+
+class MakeLine(GeoAggregate):
+ sql_function = SpatialBackend.make_line
+
+class Union(GeoAggregate):
+ sql_function = SpatialBackend.unionagg
@@ -5,27 +5,50 @@
from django.contrib.gis.db.backend import SpatialBackend
from django.contrib.gis.db.models.fields import GeometryField
+from django.contrib.gis.db.models.sql import aggregates as gis_aggregates_module
from django.contrib.gis.db.models.sql.where import GeoWhereNode
from django.contrib.gis.measure import Area, Distance
# Valid GIS query types.
ALL_TERMS = sql.constants.QUERY_TERMS.copy()
ALL_TERMS.update(SpatialBackend.gis_terms)
+# Conversion functions used in normalizing geographic aggregates.
+if SpatialBackend.postgis:
+ def convert_extent(box):
+ # TODO: Parsing of BOX3D, Oracle support (patches welcome!)
+ # Box text will be something like "BOX(-90.0 30.0, -85.0 40.0)";
+ # parsing out and returning as a 4-tuple.
+ ll, ur = box[4:-1].split(',')
+ xmin, ymin = map(float, ll.split())
+ xmax, ymax = map(float, ur.split())
+ return (xmin, ymin, xmax, ymax)
+
+ def convert_geom(hex, geo_field):
+ if hex: return SpatialBackend.Geometry(hex)
+ else: return None
+else:
+ def convert_extent(box):
+ raise NotImplementedError('Aggregate extent not implemented for this spatial backend.')
+
+ def convert_geom(clob, geo_field):
+ if clob: return SpatialBackend.Geometry(clob.read(), geo_field._srid)
+ else: return None
+
class GeoQuery(sql.Query):
"""
A single spatial SQL query.
"""
# Overridding the valid query terms.
query_terms = ALL_TERMS
+ aggregates_module = gis_aggregates_module
#### Methods overridden from the base Query class ####
def __init__(self, model, conn):
super(GeoQuery, self).__init__(model, conn, where=GeoWhereNode)
# The following attributes are customized for the GeoQuerySet.
# The GeoWhereNode and SpatialBackend classes contain backend-specific
# routines and functions.
- self.aggregate = False
self.custom_select = {}
self.transformed_srid = None
self.extra_select_fields = {}
@@ -34,7 +57,6 @@ def clone(self, *args, **kwargs):
obj = super(GeoQuery, self).clone(*args, **kwargs)
# Customized selection dictionary and transformed srid flag have
# to also be added to obj.
- obj.aggregate = self.aggregate
obj.custom_select = self.custom_select.copy()
obj.transformed_srid = self.transformed_srid
obj.extra_select_fields = self.extra_select_fields.copy()
@@ -50,12 +72,12 @@ def get_columns(self, with_aliases=False):
(without the table names) are given unique aliases. This is needed in
some cases to avoid ambiguitity with nested queries.
- This routine is overridden from Query to handle customized selection of
+ This routine is overridden from Query to handle customized selection of
geometry columns.
"""
qn = self.quote_name_unless_alias
qn2 = self.connection.ops.quote_name
- result = ['(%s) AS %s' % (self.get_extra_select_format(alias) % col[0], qn2(alias))
+ result = ['(%s) AS %s' % (self.get_extra_select_format(alias) % col[0], qn2(alias))
for alias, col in self.extra_select.iteritems()]
aliases = set(self.extra_select.keys())
if with_aliases:
@@ -67,38 +89,53 @@ def get_columns(self, with_aliases=False):
for col, field in izip(self.select, self.select_fields):
if isinstance(col, (list, tuple)):
r = self.get_field_select(field, col[0])
- if with_aliases and col[1] in col_aliases:
- c_alias = 'Col%d' % len(col_aliases)
- result.append('%s AS %s' % (r, c_alias))
- aliases.add(c_alias)
- col_aliases.add(c_alias)
+ if with_aliases:
+ if col[1] in col_aliases:
+ c_alias = 'Col%d' % len(col_aliases)
+ result.append('%s AS %s' % (r, c_alias))
+ aliases.add(c_alias)
+ col_aliases.add(c_alias)
+ else:
+ result.append('%s AS %s' % (r, col[1]))
+ aliases.add(r)
+ col_aliases.add(col[1])
else:
result.append(r)
aliases.add(r)
col_aliases.add(col[1])
else:
result.append(col.as_sql(quote_func=qn))
+
if hasattr(col, 'alias'):
aliases.add(col.alias)
col_aliases.add(col.alias)
+
elif self.default_cols:
cols, new_aliases = self.get_default_columns(with_aliases,
col_aliases)
result.extend(cols)
aliases.update(new_aliases)
+
+ result.extend([
+ '%s%s' % (
+ aggregate.as_sql(quote_func=qn),
+ alias is not None and ' AS %s' % alias or ''
+ )
+ for alias, aggregate in self.aggregate_select.items()
+ ])
+
# This loop customized for GeoQuery.
- if not self.aggregate:
- for (table, col), field in izip(self.related_select_cols, self.related_select_fields):
- r = self.get_field_select(field, table)
- if with_aliases and col in col_aliases:
- c_alias = 'Col%d' % len(col_aliases)
- result.append('%s AS %s' % (r, c_alias))
- aliases.add(c_alias)
- col_aliases.add(c_alias)
- else:
- result.append(r)
- aliases.add(r)
- col_aliases.add(col)
+ for (table, col), field in izip(self.related_select_cols, self.related_select_fields):
+ r = self.get_field_select(field, table)
+ if with_aliases and col in col_aliases:
+ c_alias = 'Col%d' % len(col_aliases)
+ result.append('%s AS %s' % (r, c_alias))
+ aliases.add(c_alias)
+ col_aliases.add(c_alias)
+ else:
+ result.append(r)
+ aliases.add(r)
+ col_aliases.add(col)
self._select_aliases = aliases
return result
@@ -112,7 +149,7 @@ def get_default_columns(self, with_aliases=False, col_aliases=None,
Returns a list of strings, quoted appropriately for use in SQL
directly, as well as a set of aliases used in the select statement.
- This routine is overridden from Query to handle customized selection of
+ This routine is overridden from Query to handle customized selection of
geometry columns.
"""
result = []
@@ -154,20 +191,10 @@ def get_default_columns(self, with_aliases=False, col_aliases=None,
return result, None
return result, aliases
- def get_ordering(self):
- """
- This routine is overridden to disable ordering for aggregate
- spatial queries.
- """
- if not self.aggregate:
- return super(GeoQuery, self).get_ordering()
- else:
- return ()
-
def resolve_columns(self, row, fields=()):
"""
This routine is necessary so that distances and geometries returned
- from extra selection SQL get resolved appropriately into Python
+ from extra selection SQL get resolved appropriately into Python
objects.
"""
values = []
@@ -183,7 +210,7 @@ def resolve_columns(self, row, fields=()):
# Converting any extra selection values (e.g., geometries and
# distance objects added by GeoQuerySet methods).
- values = [self.convert_values(v, self.extra_select_fields.get(a, None))
+ values = [self.convert_values(v, self.extra_select_fields.get(a, None))
for v, a in izip(row[rn_offset:index_start], aliases)]
if SpatialBackend.oracle:
# This is what happens normally in OracleQuery's `resolve_columns`.
@@ -212,6 +239,19 @@ def convert_values(self, value, field):
value = SpatialBackend.Geometry(value)
return value
+ def resolve_aggregate(self, value, aggregate):
+ """
+ Overridden from GeoQuery's normalize to handle the conversion of
+ GeoAggregate objects.
+ """
+ if isinstance(aggregate, self.aggregates_module.GeoAggregate):
+ if aggregate.is_extent:
+ return convert_extent(value)
+ else:
+ return convert_geom(value, aggregate.source)
+ else:
+ return super(GeoQuery, self).resolve_aggregate(value, aggregate)
+
#### Routines unique to GeoQuery ####
def get_extra_select_format(self, alias):
sel_fmt = '%s'
@@ -222,9 +262,9 @@ def get_extra_select_format(self, alias):
def get_field_select(self, fld, alias=None):
"""
Returns the SELECT SQL string for the given field. Figures out
- if any custom selection SQL is needed for the column The `alias`
- keyword may be used to manually specify the database table where
- the column exists, if not in the model associated with this
+ if any custom selection SQL is needed for the column The `alias`
+ keyword may be used to manually specify the database table where
+ the column exists, if not in the model associated with this
`GeoQuery`.
"""
sel_fmt = self.get_select_format(fld)
@@ -263,15 +303,15 @@ def _check_geo_field(self, model, name_param):
"""
Recursive utility routine for checking the given name parameter
on the given model. Initially, the name parameter is a string,
- of the field on the given model e.g., 'point', 'the_geom'.
- Related model field strings like 'address__point', may also be
+ of the field on the given model e.g., 'point', 'the_geom'.
+ Related model field strings like 'address__point', may also be
used.
- If a GeometryField exists according to the given name parameter
+ If a GeometryField exists according to the given name parameter
it will be returned, otherwise returns False.
"""
if isinstance(name_param, basestring):
- # This takes into account the situation where the name is a
+ # This takes into account the situation where the name is a
# lookup to a related geographic field, e.g., 'address__point'.
name_param = name_param.split(sql.constants.LOOKUP_SEP)
name_param.reverse() # Reversing so list operates like a queue of related lookups.
@@ -284,7 +324,7 @@ def _check_geo_field(self, model, name_param):
except (FieldDoesNotExist, IndexError):
return False
# TODO: ManyToManyField?
- if isinstance(fld, GeometryField):
+ if isinstance(fld, GeometryField):
return fld # A-OK.
elif isinstance(fld, ForeignKey):
# ForeignKey encountered, return the output of this utility called
@@ -297,12 +337,12 @@ def _field_column(self, field, table_alias=None):
"""
Helper function that returns the database column for the given field.
The table and column are returned (quoted) in the proper format, e.g.,
- `"geoapp_city"."point"`. If `table_alias` is not specified, the
+ `"geoapp_city"."point"`. If `table_alias` is not specified, the
database table associated with the model of this `GeoQuery` will be
used.
"""
if table_alias is None: table_alias = self.model._meta.db_table
- return "%s.%s" % (self.quote_name_unless_alias(table_alias),
+ return "%s.%s" % (self.quote_name_unless_alias(table_alias),
self.connection.ops.quote_name(field.column))
def _geo_field(self, field_name=None):
@@ -333,5 +373,5 @@ def __init__(self, distance_att):
# Rather than use GeometryField (which requires a SQL query
# upon instantiation), use this lighter weight class.
-class GeomField(object):
+class GeomField(object):
pass
@@ -10,6 +10,12 @@
# Python 2.3 compat
from sets import Set as set
+try:
+ import decimal
+except ImportError:
+ # Python 2.3 fallback
+ from django.utils import _decimal as decimal
+
from django.db.backends import util
from django.utils import datetime_safe
@@ -62,6 +68,7 @@ def make_debug_cursor(self, cursor):
return util.CursorDebugWrapper(cursor, self)
class BaseDatabaseFeatures(object):
+ allows_group_by_pk = False
# True if django.db.backend.utils.typecast_timestamp is used on values
# returned from dates() calls.
needs_datetime_string_cast = True
@@ -376,6 +383,22 @@ def year_lookup_bounds_for_date_field(self, value):
"""
return self.year_lookup_bounds(value)
+ def convert_values(self, value, field):
+ """Coerce the value returned by the database backend into a consistent type that
+ is compatible with the field type.
+ """
+ internal_type = field.get_internal_type()
+ if internal_type == 'DecimalField':
+ return value
+ elif internal_type and internal_type.endswith('IntegerField') or internal_type == 'AutoField':
+ return int(value)
+ elif internal_type in ('DateField', 'DateTimeField', 'TimeField'):
+ return value
+ # No field, or the field isn't known to be a decimal or integer
+ # Default to a float
+ return float(value)
+
+
class BaseDatabaseIntrospection(object):
"""
This class encapsulates all backend-specific introspection utilities
@@ -110,6 +110,7 @@ def __iter__(self):
class DatabaseFeatures(BaseDatabaseFeatures):
empty_fetchmany_value = ()
update_can_self_select = False
+ allows_group_by_pk = True
related_fields_match_type = True
class DatabaseOperations(BaseDatabaseOperations):
Oops, something went wrong.

0 comments on commit cc4e4d9

Please sign in to comment.