diff --git a/core/model/Aggregate.php b/core/model/Aggregate.php new file mode 100644 index 000000000..cfb2dbf1e --- /dev/null +++ b/core/model/Aggregate.php @@ -0,0 +1,159 @@ +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; + } +} diff --git a/core/model/DataObject.php b/core/model/DataObject.php index a046e5078..edf8bb31d 100755 --- a/core/model/DataObject.php +++ b/core/model/DataObject.php @@ -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; diff --git a/tests/model/AggregateTest.php b/tests/model/AggregateTest.php new file mode 100644 index 000000000..17795ad21 --- /dev/null +++ b/tests/model/AggregateTest.php @@ -0,0 +1,161 @@ + "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); + } + /* */ + +} diff --git a/tests/model/AggregateTest.yml b/tests/model/AggregateTest.yml new file mode 100644 index 000000000..4a8bc7242 --- /dev/null +++ b/tests/model/AggregateTest.yml @@ -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 + \ No newline at end of file