mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
FEATURE: Add aggregate calculation to DataObject, allowing (cached) calculation of Max, Min, Count, Avg, etc (from r97390)
git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/trunk@102448 467b73ca-7a2a-4603-9d3b-597d59a354a9
This commit is contained in:
parent
4b59730b1c
commit
4e850f6011
159
core/model/Aggregate.php
Normal file
159
core/model/Aggregate.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Calculate an Aggregate on a particular field of a particular DataObject type (possibly with
|
||||
* an additional filter before the aggregate)
|
||||
*
|
||||
* Implemented as a class to provide a semi-DSL method of calculating Aggregates. DataObject has a function
|
||||
* that will create & return an instance of this class with the DataObject type and filter set,
|
||||
* but at that point we don't yet know the aggregate function or field
|
||||
*
|
||||
* This class captures any XML_val or unknown call, and uses that to get the field & aggregate function &
|
||||
* then return the result
|
||||
*
|
||||
* Two ways of calling
|
||||
*
|
||||
* $aggregate->XML_val(aggregate_function, array(field)) - For templates
|
||||
* $aggregate->aggregate_function(field) - For PHP
|
||||
*
|
||||
* Aggregate functions are uppercased by this class, but are otherwise assumed to be valid SQL functions. Some
|
||||
* examples: Min, Max, Avg
|
||||
*
|
||||
* Aggregates are often used as portions of a cacheblock key. They are therefore cached themselves, in the 'aggregate'
|
||||
* cache, although the invalidation logic prefers speed over keeping valid data.
|
||||
*
|
||||
* NOTE: The cache logic uses tags, and so a backend that supports tags is required. Currently only the File
|
||||
* backend (and the two-level backend with the File backend as the slow store) meets this requirement
|
||||
*
|
||||
* @author hfried
|
||||
*/
|
||||
class Aggregate extends ViewableData {
|
||||
|
||||
static $cache = null;
|
||||
|
||||
/** Build & cache the cache object */
|
||||
protected static function cache() {
|
||||
return self::$cache ? self::$cache : (self::$cache = Cache::factory('aggregate'));
|
||||
}
|
||||
|
||||
/** Clear the aggregate cache for a given type, or pass nothing to clear all aggregate caches */
|
||||
public static function flushCache($class=null) {
|
||||
$cache = self::cache();
|
||||
|
||||
if (!$class || $class == 'DataObject') {
|
||||
$cache->clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array('aggregate'));
|
||||
}
|
||||
else {
|
||||
$cache->clean(Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG, ClassInfo::ancestry($class));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $type The DataObject type we are building an aggregate for
|
||||
* @param string $filter (optional) An SQL filter to apply to the selected rows before calculating the aggregate
|
||||
*/
|
||||
public function __construct($type, $filter = '') {
|
||||
$this->type = $type;
|
||||
$this->filter = $filter;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SQLQuery to calculate the aggregate
|
||||
* This is a seperate function so that subtypes of Aggregate can change just this bit
|
||||
* @param string $attr - the SQL field statement for selection (i.e. "MAX(LastUpdated)")
|
||||
* @return SQLQuery
|
||||
*/
|
||||
protected function query($attr) {
|
||||
$singleton = singleton($this->type);
|
||||
$query = $singleton->buildSQL($this->filter);
|
||||
$query->select = array($attr);
|
||||
$singleton->extend('augmentSQL', $query);
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for being called from a template.
|
||||
*
|
||||
* This gets the aggregate function
|
||||
*
|
||||
*/
|
||||
public function XML_val($name, $args) {
|
||||
$func = strtoupper( strpos($name, 'get') === 0 ? substr($name, 3) : $name );
|
||||
$attribute = $args ? $args[0] : 'ID';
|
||||
|
||||
$table = null;
|
||||
|
||||
foreach (ClassInfo::ancestry($this->type, true) as $class) {
|
||||
$fields = DataObject::custom_database_fields($class);
|
||||
if (array_key_exists($attribute, $fields)) { $table = $class; break; }
|
||||
}
|
||||
|
||||
if (!$table) user_error("Couldn't find table for field $attribute in type {$this->type}", E_USER_ERROR);
|
||||
|
||||
$query = $this->query("$func(\"$table\".\"$attribute\")");
|
||||
|
||||
$cachekey = sha1($query->sql());
|
||||
$cache = self::cache();
|
||||
|
||||
if (!($result = $cache->load($cachekey))) {
|
||||
$result = (string)$query->execute()->value(); if (!$result) $result = '0';
|
||||
$cache->save($result, null, array('aggregate', $this->type));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for being called from PHP.
|
||||
*/
|
||||
public function __call($method, $arguments) {
|
||||
return $this->XML_val($method, $arguments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of Aggregate that calculates aggregates for the result of a has_many query.
|
||||
*
|
||||
* @author hfried
|
||||
*/
|
||||
class Aggregate_Relationship extends Aggregate {
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param DataObject $object The object that has_many somethings that we're calculating the aggregate for
|
||||
* @param string $relationship The name of the relationship
|
||||
* @param string $filter (optional) An SQL filter to apply to the relationship rows before calculating the aggregate
|
||||
*/
|
||||
public function __construct($object, $relationship, $filter = '') {
|
||||
$this->object = $object;
|
||||
$this->relationship = $relationship;
|
||||
|
||||
$this->has_many = $object->has_many($relationship);
|
||||
$this->many_many = $object->many_many($relationship);
|
||||
|
||||
if (!$this->has_many && !$this->many_many) user_error("Could not find relationship $relationship on object class {$object->class} in Aggregate Relationship", E_USER_ERROR);
|
||||
|
||||
parent::__construct($this->has_many ? $this->has_many : $this->many_many[1], $filter);
|
||||
}
|
||||
|
||||
protected function query($attr) {
|
||||
if ($this->has_many) {
|
||||
$query = $this->object->getComponentsQuery($this->relationship, $this->filter);
|
||||
}
|
||||
else {
|
||||
$query = $this->object->getManyManyComponentsQuery($this->relationship, $this->filter);
|
||||
}
|
||||
|
||||
$query->select = array($attr);
|
||||
$query->groupby = array();
|
||||
|
||||
$singleton = singleton($this->type);
|
||||
$singleton->extend('augmentSQL', $query);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
@ -374,7 +374,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
$this->record = null;
|
||||
$this->original = null;
|
||||
$this->changed = null;
|
||||
$this->flushCache();
|
||||
$this->flushCache(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1442,6 +1442,26 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
return "\"$table\".\"$parentField\" = '{$this->ID}'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an aggregate object. An aggregate object returns the result of running some SQL aggregate function on a field of
|
||||
* this dataobject type.
|
||||
*
|
||||
* It can be called with no arguments, in which case it returns an object that calculates aggregates on this object's type,
|
||||
* or with an argument (possibly statically), in which case it returns an object for that type
|
||||
*/
|
||||
function Aggregate($type = null, $filter = '') {
|
||||
return new Aggregate($type ? $type : $this->class, $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an relationship aggregate object. A relationship aggregate does the same thing as an aggregate object, but operates
|
||||
* on a has_many rather than directly on the type specified
|
||||
*/
|
||||
function RelationshipAggregate($object = null, $relationship = '', $filter = '') {
|
||||
if (is_string($object)) { $filter = $relationship; $relationship = $object; $object = $this; }
|
||||
return new Aggregate_Relationship($object ? $object : $this->owner, $relationship, $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and their classes.
|
||||
*
|
||||
@ -2673,8 +2693,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
|
||||
/**
|
||||
* Flush the cached results for all relations (has_one, has_many, many_many)
|
||||
* Also clears any cached aggregate data
|
||||
*
|
||||
* @param boolean $persistant When true will also clear persistant data stored in the Cache system.
|
||||
* When false will just clear session-local cached data
|
||||
*
|
||||
*/
|
||||
public function flushCache() {
|
||||
public function flushCache($persistant=true) {
|
||||
if($persistant) Aggregate::flushCache($this->class);
|
||||
|
||||
if($this->class == 'DataObject') {
|
||||
DataObject::$cache_get_one = array();
|
||||
return;
|
||||
|
161
tests/model/AggregateTest.php
Normal file
161
tests/model/AggregateTest.php
Normal file
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* A hierarchy of data types, to
|
||||
*/
|
||||
class AggregateTest_Foo extends DataObject implements TestOnly {
|
||||
static $db = array(
|
||||
"Foo" => "Int"
|
||||
);
|
||||
|
||||
static $has_one = array('Bar' => 'AggregateTest_Bar');
|
||||
static $belongs_many_many = array('Bazi' => 'AggregateTest_Baz');
|
||||
}
|
||||
|
||||
class AggregateTest_Fab extends AggregateTest_Foo {
|
||||
static $db = array(
|
||||
"Fab" => "Int"
|
||||
);
|
||||
}
|
||||
|
||||
class AggregateTest_Fac extends AggregateTest_Fab {
|
||||
static $db = array(
|
||||
"Fac" => "Int"
|
||||
);
|
||||
}
|
||||
|
||||
class AggregateTest_Bar extends DataObject implements TestOnly {
|
||||
static $db = array(
|
||||
"Bar" => "Int"
|
||||
);
|
||||
|
||||
static $has_many = array(
|
||||
"Foos" => "AggregateTest_Foo"
|
||||
);
|
||||
}
|
||||
|
||||
class AggregateTest_Baz extends DataObject implements TestOnly {
|
||||
static $db = array(
|
||||
"Baz" => "Int"
|
||||
);
|
||||
|
||||
static $many_many = array(
|
||||
"Foos" => "AggregateTest_Foo"
|
||||
);
|
||||
}
|
||||
|
||||
class AggregateTest extends SapphireTest {
|
||||
static $fixture_file = 'sapphire/tests/model/AggregateTest.yml';
|
||||
|
||||
protected $extraDataObjects = array(
|
||||
'AggregateTest_Foo',
|
||||
'AggregateTest_Fab',
|
||||
'AggregateTest_Fac',
|
||||
'AggregateTest_Bar',
|
||||
'AggregateTest_Baz'
|
||||
);
|
||||
|
||||
/**
|
||||
* Test basic aggregation on a passed type
|
||||
*/
|
||||
function testTypeSpecifiedAggregate() {
|
||||
// Template style access
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->XML_val('Max', array('Foo')), 9);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->XML_val('Max', array('Fab')), 3);
|
||||
|
||||
// PHP style access
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->Max('Foo'), 9);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->Max('Fab'), 3);
|
||||
}
|
||||
/* */
|
||||
|
||||
/**
|
||||
* Test basic aggregation on a given dataobject
|
||||
* @return unknown_type
|
||||
*/
|
||||
function testAutoTypeAggregate() {
|
||||
$foo = $this->objFromFixture('AggregateTest_Foo', 'foo1');
|
||||
$fab = $this->objFromFixture('AggregateTest_Fab', 'fab1');
|
||||
|
||||
// Template style access
|
||||
$this->assertEquals($foo->Aggregate()->XML_val('Max', array('Foo')), 9);
|
||||
$this->assertEquals($fab->Aggregate()->XML_val('Max', array('Fab')), 3);
|
||||
|
||||
// PHP style access
|
||||
$this->assertEquals($foo->Aggregate()->Max('Foo'), 9);
|
||||
$this->assertEquals($fab->Aggregate()->Max('Fab'), 3);
|
||||
}
|
||||
/* */
|
||||
|
||||
/**
|
||||
* Test aggregation takes place on the passed type & it's children only
|
||||
*/
|
||||
function testChildAggregate() {
|
||||
|
||||
// For base classes, aggregate is calculcated on it and all children classes
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->Max('Foo'), 9);
|
||||
|
||||
// For subclasses, aggregate is calculated for that subclass and it's children only
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->Max('Foo'), 9);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fac')->Max('Foo'), 6);
|
||||
|
||||
}
|
||||
/* */
|
||||
|
||||
/**
|
||||
* Test aggregates are cached properly
|
||||
*/
|
||||
function testCache() {
|
||||
|
||||
}
|
||||
/* */
|
||||
|
||||
/**
|
||||
* Test cache is correctly flushed on write
|
||||
*/
|
||||
function testCacheFlushing() {
|
||||
|
||||
// For base classes, aggregate is calculcated on it and all children classes
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->Max('Foo'), 9);
|
||||
|
||||
// For subclasses, aggregate is calculated for that subclass and it's children only
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->Max('Foo'), 9);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fac')->Max('Foo'), 6);
|
||||
|
||||
$foo = $this->objFromFixture('AggregateTest_Foo', 'foo1');
|
||||
$foo->Foo = 12;
|
||||
$foo->write();
|
||||
|
||||
// For base classes, aggregate is calculcated on it and all children classes
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->Max('Foo'), 12);
|
||||
|
||||
// For subclasses, aggregate is calculated for that subclass and it's children only
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->Max('Foo'), 9);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fac')->Max('Foo'), 6);
|
||||
|
||||
$fab = $this->objFromFixture('AggregateTest_Fab', 'fab1');
|
||||
$fab->Foo = 15;
|
||||
$fab->write();
|
||||
|
||||
// For base classes, aggregate is calculcated on it and all children classes
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Foo')->Max('Foo'), 15);
|
||||
|
||||
// For subclasses, aggregate is calculated for that subclass and it's children only
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fab')->Max('Foo'), 15);
|
||||
$this->assertEquals(DataObject::Aggregate('AggregateTest_Fac')->Max('Foo'), 6);
|
||||
}
|
||||
/* */
|
||||
|
||||
/**
|
||||
* Test basic relationship aggregation
|
||||
*/
|
||||
function testRelationshipAggregate() {
|
||||
$bar1 = $this->objFromFixture('AggregateTest_Bar', 'bar1');
|
||||
$this->assertEquals($bar1->RelationshipAggregate('Foos')->Max('Foo'), 8);
|
||||
|
||||
$baz1 = $this->objFromFixture('AggregateTest_Baz', 'baz1');
|
||||
$this->assertEquals($baz1->RelationshipAggregate('Foos')->Max('Foo'), 8);
|
||||
}
|
||||
/* */
|
||||
|
||||
}
|
46
tests/model/AggregateTest.yml
Normal file
46
tests/model/AggregateTest.yml
Normal file
@ -0,0 +1,46 @@
|
||||
AggregateTest_Bar:
|
||||
bar1:
|
||||
Bar: 1
|
||||
bar2:
|
||||
Bar: 2
|
||||
AggregateTest_Foo:
|
||||
foo1:
|
||||
Foo: 1
|
||||
Bar: =>AggregateTest_Bar.bar1
|
||||
foo2:
|
||||
Foo: 2
|
||||
Bar: =>AggregateTest_Bar.bar1
|
||||
foo3:
|
||||
Foo: 3
|
||||
Bar: =>AggregateTest_Bar.bar2
|
||||
AggregateTest_Fab:
|
||||
fab1:
|
||||
Foo: 7
|
||||
Fab: 1
|
||||
Bar: =>AggregateTest_Bar.bar1
|
||||
fab2:
|
||||
Foo: 8
|
||||
Fab: 2
|
||||
Bar: =>AggregateTest_Bar.bar1
|
||||
fab3:
|
||||
Foo: 9
|
||||
Fab: 3
|
||||
Bar: =>AggregateTest_Bar.bar2
|
||||
AggregateTest_Fac:
|
||||
fac1:
|
||||
Foo: 4
|
||||
Fac: 1
|
||||
fac2:
|
||||
Foo: 5
|
||||
Fac: 2
|
||||
fac3:
|
||||
Foo: 6
|
||||
Fac: 3
|
||||
AggregateTest_Baz:
|
||||
baz1:
|
||||
Baz: 1
|
||||
Foos: =>AggregateTest_Foo.foo1,=>AggregateTest_Foo.foo2,=>AggregateTest_Fab.fab1,=>AggregateTest_Fab.fab2
|
||||
baz2:
|
||||
Baz: 2
|
||||
Foos: =>AggregateTest_Foo.foo3,=>AggregateTest_Fab.fab3
|
||||
|
Loading…
Reference in New Issue
Block a user