Skip to content

Commit

Permalink
Merge pull request #2599 from markstory/3.0-query-cache
Browse files Browse the repository at this point in the history
3.0 make query caching simpler
  • Loading branch information
lorenzo committed Jan 4, 2014
2 parents 84263ae + d29c7a2 commit 51fa76c
Show file tree
Hide file tree
Showing 6 changed files with 389 additions and 6 deletions.
2 changes: 1 addition & 1 deletion Cake/Cache/CacheRegistry.php
Expand Up @@ -83,7 +83,7 @@ protected function _create($class, $alias, $config) {

if (!$instance->init($config)) {
throw new Error\Exception(
sprintf('Cache engine %s is not properly configured.', $class)
sprintf('Cache engine %s is not properly configured.', get_class($instance))
);
}

Expand Down
1 change: 1 addition & 0 deletions Cake/Model/Behavior/CounterCacheBehavior.php
Expand Up @@ -76,6 +76,7 @@
* ]
* ]
* }}}
*
*/
class CounterCacheBehavior extends Behavior {

Expand Down
76 changes: 71 additions & 5 deletions Cake/ORM/Query.php
Expand Up @@ -21,6 +21,8 @@
use Cake\Database\Statement\BufferedStatement;
use Cake\Database\Statement\CallbackStatement;
use Cake\Event\Event;
use Cake\ORM\QueryCacher;
use Cake\ORM\Table;

/**
* Extends the base Query class to provide new methods related to association
Expand Down Expand Up @@ -125,6 +127,13 @@ class Query extends DatabaseQuery {
*/
protected $_hydrate = true;

/**
* A query cacher instance if this query has caching enabled.
*
* @var Cake\ORM\QueryCacher
*/
protected $_cache;

/**
* @param Cake\Database\Connection $connection
* @param Cake\ORM\Table $table
Expand Down Expand Up @@ -400,6 +409,56 @@ public function setResult($results) {
return $this;
}

/**
* Enable result caching for this query.
*
* If a query has caching enabled, it will do the following when executed:
*
* - Check the cache for $key. If there are results no SQL will be executed.
* Instead the cached results will be returned.
* - When the cached data is stale/missing the result set will be cached as the query
* is executed.
*
* ## Usage
*
* {{{
* // Simple string key + config
* $query->cache('my_key', 'db_results');
*
* // Function to generate key.
* $query->cache(function($q) {
* $key = serialize($q->clause('select'));
* $key .= serialize($q->clause('where'));
* return md5($key);
* });
*
* // Using a pre-built cache engine.
* $query->cache('my_key', $engine);
*
*
* // Disable caching
* $query->cache(false);
* }}}
*
* @param false|string|Closure $key Either the cache key or a function to generate the cache key.
* When using a function, this query instance will be supplied as an argument.
* @param string|CacheEngine $config Either the name of the cache config to use, or
* a cache config instance.
* @return Query The query instance.
* @throws \RuntimeException When you attempt to cache a non-select query.
*/
public function cache($key, $config = 'default') {
if ($this->_type !== 'select' && $this->_type !== null) {
throw new \RuntimeException('You cannot cache the results of non-select queries.');
}
if ($key === false) {
$this->_cache = null;
return $this;
}
$this->_cache = new QueryCacher($key, $config);
return $this;
}

/**
* Executes this query and returns a results iterator. This function is required
* for implementing the IteratorAggregate interface and allows the query to be
Expand Down Expand Up @@ -451,11 +510,18 @@ public function getResults() {
if (isset($this->_results)) {
return $this->_results;
}

$this->_results = $this->_decorateResults(
new ResultSet($this, $this->execute())
);

if ($this->_cache) {
$results = $this->_cache->fetch($this);
}
if (!isset($results)) {
$results = $this->_decorateResults(
new ResultSet($this, $this->execute())
);
if ($this->_cache) {
$this->_cache->store($this, $results);
}
}
$this->_results = $results;
return $this->_results;
}

Expand Down
112 changes: 112 additions & 0 deletions Cake/ORM/QueryCacher.php
@@ -0,0 +1,112 @@
<?php
/**
* PHP Version 5.4
*
* CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
* @link http://cakephp.org CakePHP(tm) Project
* @since CakePHP(tm) v 3.0.0
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/
namespace Cake\ORM;

use Cake\Cache\Cache;
use Cake\Cache\CacheEngine;
use Cake\ORM\Query;
use Cake\ORM\ResultSet;
use RuntimeException;

/**
* Handles caching queries and loading results from the cache.
*
* Used by Cake\ORM\Query internally.
*
* @see Cake\ORM\Query::cache() for the public interface.
*/
class QueryCacher {

/**
* Constructor.
*
* @param string|Closure $key
* @param string|CacheEngine $config
*/
public function __construct($key, $config) {
if (!is_string($key) && !is_callable($key)) {
throw new RuntimeException('Cache keys must be strings or callables.');
}
$this->_key = $key;

if (!is_string($config) && !($config instanceof CacheEngine)) {
throw new RuntimeException('Cache configs must be strings or CacheEngine instances.');
}
$this->_config = $config;
}

/**
* Load the cached results from the cache or run the query.
*
* @param Query $query The query the cache read is for.
* @return ResultSet|null Either the cached results or null.
*/
public function fetch(Query $query) {
$key = $this->_resolveKey($query);
$storage = $this->_resolveCacher();
$result = $storage->read($key);
if (empty($result)) {
return null;
}
return $result;
}

/**
* Store the result set into the cache.
*
* @param Query $query The query the cache read is for.
* @param ResultSet The result set to store.
* @return void
*/
public function store(Query $query, ResultSet $results) {
$key = $this->_resolveKey($query);
$storage = $this->_resolveCacher();
return $storage->write($key, $results);
}

/**
* Get/generate the cache key.
*
* @param Query $query
* @return string
*/
protected function _resolveKey($query) {
if (is_string($this->_key)) {
return $this->_key;
}
$func = $this->_key;
$key = $func($query);
if (!is_string($key)) {
$msg = sprintf('Cache key functions must return a string. Got %s.', var_export($key, true));
throw new RuntimeException($msg);
}
return $key;
}

/**
* Get the cache engine.
*
* @return Cake\Cache\CacheEngine
*/
protected function _resolveCacher() {
if (is_string($this->_config)) {
return Cache::engine($this->_config);
}
return $this->_config;
}

}
138 changes: 138 additions & 0 deletions Cake/Test/TestCase/ORM/QueryCacherTest.php
@@ -0,0 +1,138 @@
<?php
/**
* PHP Version 5.4
*
* CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
* @link http://cakephp.org CakePHP(tm) Project
* @since CakePHP(tm) v 3.0.0
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/
namespace Cake\Test\TestCase\ORM;

use Cake\Cache\Cache;
use Cake\ORM\QueryCacher;
use Cake\TestSuite\TestCase;

/**
* Query cacher test
*/
class QueryCacherTest extends TestCase {

/**
* Setup method
*
* @return void
*/
public function setUp() {
parent::setUp();
$this->engine = $this->getMock('Cake\Cache\CacheEngine');
$this->engine->expects($this->any())
->method('init')
->will($this->returnValue(true));

Cache::config('queryCache', $this->engine);
}

/**
* Teardown method
*
* @return void
*/
public function tearDown() {
parent::tearDown();
Cache::drop('queryCache');
}

/**
* Test fetching with a function to generate the key.
*
* @return void
*/
public function testFetchFunctionKey() {
$this->_mockRead('my_key', 'A winner');
$query = $this->getMock('Cake\ORM\Query', [], [], '', false);

$cacher = new QueryCacher(function($q) use ($query) {
$this->assertSame($query, $q);
return 'my_key';
}, 'queryCache');

$result = $cacher->fetch($query);
$this->assertEquals('A winner', $result);
}

/**
* Test fetching with a function to generate the key but the function is poop.
*
* @expectedException \RuntimeException
* @expectedExceptionMessage Cache key functions must return a string. Got false.
* @return void
*/
public function testFetchFunctionKeyNoString() {
$this->_mockRead('my_key', 'A winner');
$query = $this->getMock('Cake\ORM\Query', [], [], '', false);

$cacher = new QueryCacher(function($q) {
return false;
}, 'queryCache');

$cacher->fetch($query);
}

/**
* Test fetching with a cache instance.
*
* @return void
*/
public function testFetchCacheHitStringEngine() {
$this->_mockRead('my_key', 'A winner');
$cacher = new QueryCacher('my_key', 'queryCache');
$query = $this->getMock('Cake\ORM\Query', [], [], '', false);
$result = $cacher->fetch($query);
$this->assertEquals('A winner', $result);
}

/**
* Test fetching with a cache hit.
*
* @return void
*/
public function testFetchCacheHit() {
$this->_mockRead('my_key', 'A winner');
$cacher = new QueryCacher('my_key', $this->engine);
$query = $this->getMock('Cake\ORM\Query', [], [], '', false);
$result = $cacher->fetch($query);
$this->assertEquals('A winner', $result);
}

/**
* Test fetching with a cache miss.
*
* @return void
*/
public function testFetchCacheMiss() {
$this->_mockRead('my_key', false);
$cacher = new QueryCacher('my_key', $this->engine);
$query = $this->getMock('Cake\ORM\Query', [], [], '', false);
$result = $cacher->fetch($query);
$this->assertNull($result, 'Cache miss should not have an isset() return.');
}

/**
* Helper for building mocks.
*/
protected function _mockRead($key, $value = false) {
$this->engine->expects($this->any())
->method('read')
->with($key)
->will($this->returnValue($value));
}

}

0 comments on commit 51fa76c

Please sign in to comment.