API CHANGE: Partial cache adjustments - now supports nested cache blocks (which are independant of their containing cache block), conditionals to control if a given cache block is active, and includes hash of template code in key (so template changes mean cache is invalidated). Changes template control for cache block to <% cached %>, to which the now deprecated <% cacheblock %> is aliased, and an additional template control <% uncached %> has been added.

git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/branches/2.4@101137 467b73ca-7a2a-4603-9d3b-597d59a354a9
This commit is contained in:
Hamish Friedlander 2010-03-16 04:41:44 +00:00 committed by Sam Minnee
parent b37fede7d1
commit 267de8887a
2 changed files with 346 additions and 77 deletions

View File

@ -462,13 +462,13 @@ class SSViewer {
$content = preg_replace(array_keys($replacements), array_values($replacements), $content); $content = preg_replace(array_keys($replacements), array_values($replacements), $content);
$content = str_replace('{dlr}','$',$content); $content = str_replace('{dlr}','$',$content);
// Cache block
$content = SSViewer_PartialParser::process($template, $content);
// legacy // legacy
$content = ereg_replace('<!-- +pc +([A-Za-z0-9_(),]+) +-->', '<' . '% control \\1 %' . '>', $content); $content = ereg_replace('<!-- +pc +([A-Za-z0-9_(),]+) +-->', '<' . '% control \\1 %' . '>', $content);
$content = ereg_replace('<!-- +pc_end +-->', '<' . '% end_control %' . '>', $content); $content = ereg_replace('<!-- +pc_end +-->', '<' . '% end_control %' . '>', $content);
// < % cacheblock key, key.. % >
$content = SSViewer_PartialParser::parse($template, $content);
// < % control Foo % > // < % control Foo % >
$content = ereg_replace('<' . '% +control +([A-Za-z0-9_]+) +%' . '>', '<? array_push($itemStack, $item); if($loop = $item->obj("\\1")) foreach($loop as $key => $item) { ?>', $content); $content = ereg_replace('<' . '% +control +([A-Za-z0-9_]+) +%' . '>', '<? array_push($itemStack, $item); if($loop = $item->obj("\\1")) foreach($loop as $key => $item) { ?>', $content);
// < % control Foo.Bar % > // < % control Foo.Bar % >
@ -666,91 +666,219 @@ class SSViewer_FromString extends SSViewer {
*/ */
class SSViewer_PartialParser { class SSViewer_PartialParser {
static $opening_tag = '/< % [ \t]+ cacheblock [ \t]+ ([^%]+ [ \t]+)? % >/xS'; static $tag = '/< % [ \t]+ (cached|cacheblock|uncached|end_cached|end_cacheblock|end_uncached) [ \t]+ ([^%]+ [ \t]+)? % >/xS';
static $argument_splitter = '/^\s* static $argument_splitter = '/^\s*
( (\w+) \s* ( \( ([^\)]*) \) )? ) | # A property lookup or a function call # The argument itself
( \' [^\']+ \' ) | # A string surrounded by \' (
( " [^"]+ " ) # A string surrounded by " (?P<conditional> if | unless ) | # The if or unless keybreak
\s*/xS'; (?P<property> (?P<identifier> \w+) \s* # A property lookup or a function call
( \( (?P<arguments> [^\)]*) \) )?
) |
(?P<sqstring> \' (\\\'|[^\'])+ \' ) | # A string surrounded by \'
(?P<dqstring> " (\\"|[^"])+ " ) # A string surrounded by "
)
# Some seperator after the argument
(
\s*(?P<comma>,)\s* | # A comma (maybe with whitespace before or after)
(?P<fullstop>\.) # A period (no whitespace before)
)?
/xS';
static $closing_tag = '/< % [ \t]+ end_cacheblock [ \t]+ % >/xS'; static function process($template, $content) {
$parser = new SSViewer_PartialParser($template, $content, 0, array(), 'if', 'false');
static function parse($template, $content) { $parser->parse();
$parser = new SSViewer_PartialParser($template); return $parser->generate();
$content = $parser->replaceOpeningTags($content);
$content = $parser->replaceClosingTags($content);
return $content;
} }
function __construct($template) { function __construct($template, $content, $offset, $keyparts, $conditional, $condition) {
$this->template = $template; $this->template = $template;
$this->cacheblocks = 0; $this->content = $content;
} $this->offset = $offset;
function replaceOpeningTags($content) {
return preg_replace_callback(self::$opening_tag, array($this, 'replaceOpeningTagsCallback'), $content);
}
function replaceOpeningTagsCallback($matches) {
$this->cacheblocks += 1;
$key = $this->key($matches);
return '<? if ($partial = $cache->load('.$key.')) { $val .= $partial; } else { $valStack[] = $val; $val = ""; ?>';
}
function key($matches) {
$parts = array();
$parts[] = "'".preg_replace('/[^\w+]/', '_', $this->template)."'";
// If there weren't any arguments, that'll do $this->keyparts = $keyparts;
if (!@$matches[1]) return $parts[0]; $this->conditional = $conditional;
$this->condition = $condition;
$current = 'preg_replace(\'/[^\w+]/\', \'_\', $item->'; $this->blocks = array();
$keyspec = $matches[1]; }
function controlcheck($text) {
$ifs = preg_match_all('/<'.'% +if +/', $text, $matches);
$end_ifs = preg_match_all('/<'.'% +end_if +/', $text, $matches);
if ($ifs != $end_ifs) throw new Exception('You can\'t have cached or uncached blocks within condition structures');
$controls = preg_match_all('/<'.'% +control +/', $text, $matches);
$end_controls = preg_match_all('/<'.'% +end_control +/', $text, $matches);
if ($controls != $end_controls) throw new Exception('You can\'t have cached or uncached blocks within control structures');
}
function parse() {
$current_tag_offset = 0;
while (strlen($keyspec) && preg_match(self::$argument_splitter, $keyspec, $submatch)) { while (preg_match(self::$tag, $this->content, $matches, PREG_OFFSET_CAPTURE, $this->offset)) {
$joiner = substr($keyspec, strlen($submatch[0]), 1); $tag = $matches[1][0];
$keyspec = substr($keyspec, strlen($submatch[0]) + 1);
$startpos = $matches[0][1];
$endpos = $matches[0][1] + strlen($matches[0][0]);
switch($tag) {
case 'cached':
case 'uncached':
case 'cacheblock':
$pretext = substr($this->content, $this->offset, $startpos - $this->offset);
$this->controlcheck($pretext);
$this->blocks[] = $pretext;
if ($tag == 'cached' || $tag == 'cacheblock') {
list($keyparts, $conditional, $condition) = $this->parseargs(@$matches[2][0]);
}
else {
$keyparts = array(); $conditional = 'if'; $condition = 'false';
}
$parser = new SSViewer_PartialParser($this->template, $this->content, $endpos, $keyparts, $conditional, $condition);
$parser->parse();
$this->blocks[] = $parser;
$this->offset = $parser->offset;
break;
case 'end_cached':
case 'end_cacheblock':
case 'end_uncached':
$this->blocks[] = substr($this->content, $this->offset, $startpos - $this->offset);
$this->content = null;
$this->offset = $endpos;
return $this;
}
}
$this->blocks[] = substr($this->content, $this->offset);
$this->content = null;
}
function parseargs($string) {
preg_match_all(self::$argument_splitter, $string, $matches, PREG_SET_ORDER);
$parts = array();
$conditional = null; $condition = null;
$current = '$item->';
while (strlen($string) && preg_match(self::$argument_splitter, $string, $match)) {
$string = substr($string, strlen($match[0]));
// If this is a conditional keyword, break, and the next loop will grab the conditional
if (@$match['conditional']) {
$conditional = $match['conditional'];
continue;
}
// If it's a property lookup or a function call // If it's a property lookup or a function call
if ($submatch[1]) { if (@$match['property']) {
// Get the property // Get the property
$what = $submatch[2]; $what = $match['identifier'];
$args = array(); $args = array();
// Extract any arguments passed to the function call // Extract any arguments passed to the function call
if (@$submatch[3]) { if (@$match['arguments']) {
foreach (explode(',', $submatch[4]) as $arg) { foreach (explode(',', $match['arguments']) as $arg) {
$args[] = is_numeric($arg) ? (string)$arg : '"'.$arg.'"'; $args[] = is_numeric($arg) ? (string)$arg : '"'.$arg.'"';
} }
} }
$args = empty($args) ? 'null' : 'array('.implode(',',$args).')'; $args = empty($args) ? 'null' : 'array('.implode(',',$args).')';
// If this fragment ended with '.', then there's another lookup coming, so return an obj for that lookup // If this fragment ended with '.', then there's another lookup coming, so return an obj for that lookup
if ($joiner == '.') { if (@$match['fullstop']) {
$current .= "obj(\"$what\", $args, true)->"; $current .= "obj('$what', $args, true)->";
} }
// Otherwise this is the end of the lookup chain, so add the resultant value to the key array and reset the key-get php fragement // Otherwise this is the end of the lookup chain, so add the resultant value to the key array and reset the key-get php fragement
else { else {
$parts[] = $current . "XML_val(\"$what\", $args, true))"; $current = 'preg_replace(\'/[^\w+]/\', \'_\', $item->'; $accessor = $current . "XML_val('$what', $args, true)"; $current = '$item->';
// If we've hit a conditional already, this is the condition. Set it and be done.
if ($conditional) {
$condition = $accessor;
break;
}
// Otherwise we're another key component. Add it to array.
else $parts[] = $accessor;
} }
} }
// Else it's a quoted string of some kind // Else it's a quoted string of some kind
else if ($submatch[5] || $submatch[6]) { else if (@$match['sqstring']) $parts[] = $match['sqstring'];
$parts[] = $submatch[5] ? $submatch[5] : $submatch[6]; else if (@$match['dqstring']) $parts[] = $match['dqstring'];
}
} }
return implode(".'_'.", $parts); if ($conditional && !$condition) {
} throw new Exception("You need to have a condition after the conditional $conditional in your cache block");
}
function replaceClosingTags($content) {
return preg_replace(self::$closing_tag, '<? $cache->save($val); $val = array_pop($valStack) . $val; } ?>', $content); return array($parts, $conditional, $condition);
}
function key() {
if (empty($this->keyparts)) return "''";
return 'sha1(' . implode(".'_'.", $this->keyparts) . ')';
}
function generate() {
$res = array();
$key = $this->key();
$condition = "";
switch ($this->conditional) {
case 'if':
$condition = "{$this->condition} && ";
break;
case 'unless':
$condition = "!({$this->condition}) && ";
break;
}
/* Output this set of blocks */
foreach ($this->blocks as $i => $block) {
if ($block instanceof SSViewer_PartialParser)
$res[] = $block->generate();
else {
// Include the template name and this cache block's current contents as a sha hash, so we get auto-seperation
// of cache blocks, and invalidation of the cache when the template changes
$partialkey = "'".sha1($this->template . $block)."_'.$key.'_$i'";
$knownUncached = array(
'if' => array('false', '0'),
'unless' => array('true', '1')
);
// Optimized version if we know condition is false
if ($this->conditional && in_array($this->condition, $knownUncached[$this->conditional])) {
$res[] = $block;
}
else {
// Try to load from cache
$res[] = "<?\n".'if ('.$condition.' ($partial = $cache->load('.$partialkey.'))) $val .= $partial;'."\n";
// Cache miss - regenerate
$res[] = "else {\n";
$res[] = '$oldval = $val; $val = "";'."\n";
$res[] = "\n?>" . $block . "<?\n";
$res[] = $condition . ' $cache->save($val); $val = $oldval . $val ;'."\n";
$res[] = "}\n?>";
}
}
}
return implode('', $res);
} }
} }

View File

@ -10,7 +10,14 @@ class SSViewerCacheBlockTest_Model extends DataObject implements TestOnly {
function Foo() { function Foo() {
return 'Bar'; return 'Bar';
} }
function True() {
return true;
}
function False() {
return false;
}
} }
class SSViewerCacheBlockTest extends SapphireTest { class SSViewerCacheBlockTest extends SapphireTest {
@ -28,45 +35,179 @@ class SSViewerCacheBlockTest extends SapphireTest {
protected function _runtemplate($template, $data = null) { protected function _runtemplate($template, $data = null) {
if ($data === null) $data = $this->data; if ($data === null) $data = $this->data;
if (is_array($data)) $data = new ArrayData($data); if (is_array($data)) $data = $this->data->customise($data);
$viewer = SSViewer::fromString($template); $viewer = SSViewer::fromString($template);
return $viewer->process($data); return $viewer->process($data);
} }
function testParsing() { function testParsing() {
// Make sure an empty cacheblock parses
// ** Trivial checks **
// Make sure an empty cached block parses
$this->_reset();
$this->assertEquals($this->_runtemplate('<% cached %><% end_cached %>'), '');
// Make sure an empty cacheblock block parses
$this->_reset(); $this->_reset();
$this->assertEquals($this->_runtemplate('<% cacheblock %><% end_cacheblock %>'), ''); $this->assertEquals($this->_runtemplate('<% cacheblock %><% end_cacheblock %>'), '');
// Make sure an empty uncached block parses
$this->_reset();
$this->assertEquals($this->_runtemplate('<% uncached %><% end_uncached %>'), '');
// ** Argument checks **
// Make sure a simple cacheblock parses // Make sure a simple cacheblock parses
$this->_reset(); $this->_reset();
$this->assertEquals($this->_runtemplate('<% cacheblock %>Yay<% end_cacheblock %>'), 'Yay'); $this->assertEquals($this->_runtemplate('<% cached %>Yay<% end_cached %>'), 'Yay');
// Make sure a moderately complicated cacheblock parses // Make sure a moderately complicated cacheblock parses
$this->_reset(); $this->_reset();
$this->assertEquals($this->_runtemplate('<% cacheblock \'block\', Foo, "jumping" %>Yay<% end_cacheblock %>'), 'Yay'); $this->assertEquals($this->_runtemplate('<% cached \'block\', Foo, "jumping" %>Yay<% end_cached %>'), 'Yay');
// Make sure a complicated cacheblock parses // Make sure a complicated cacheblock parses
$this->_reset(); $this->_reset();
$this->assertEquals($this->_runtemplate('<% cacheblock \'block\', Foo, Test.Test(4).Test(jumping).Foo %>Yay<% end_cacheblock %>'), 'Yay'); $this->assertEquals($this->_runtemplate('<% cached \'block\', Foo, Test.Test(4).Test(jumping).Foo %>Yay<% end_cached %>'), 'Yay');
// ** Conditional Checks **
// Make sure a cacheblock with a simple conditional parses
$this->_reset();
$this->assertEquals($this->_runtemplate('<% cached if true %>Yay<% end_cached %>'), 'Yay');
// Make sure a cacheblock with a complex conditional parses
$this->_reset();
$this->assertEquals($this->_runtemplate('<% cached if Test.Test(yank).Foo %>Yay<% end_cached %>'), 'Yay');
// Make sure a cacheblock with a complex conditional and arguments parses
$this->_reset();
$this->assertEquals($this->_runtemplate('<% cached Foo, Test.Test(4).Test(jumping).Foo if Test.Test(yank).Foo %>Yay<% end_cached %>'), 'Yay');
} }
/** /**
* Test that cacheblocks actually cache * Test that cacheblocks actually cache
*/ */
function testBlocksCache() { function testBlocksCache() {
// First, run twice without caching, to prove $Increment actually increments // First, run twice without caching, to prove we get two different values
$this->_reset(false); $this->_reset(false);
$this->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', array('Foo' => 1)), '1'); $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', array('Foo' => 2)), '2'); $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 2)), '2');
// Then twice with caching, should get same result each time // Then twice with caching, should get same result each time
$this->_reset(true); $this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', array('Foo' => 1)), '1'); $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', array('Foo' => 2)), '1'); $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 2)), '1');
} }
/**
* Test that cacheblocks conditionally cache with if
*/
function testBlocksConditionallyCacheWithIf() {
// First, run twice with caching
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached if True %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cached if True %>$Foo<% end_cached %>', array('Foo' => 2)), '1');
// Then twice without caching
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached if False %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cached if False %>$Foo<% end_cached %>', array('Foo' => 2)), '2');
// Then once cached, once not (and the opposite)
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached if Cache %>$Foo<% end_cached %>', array('Foo' => 1, 'Cache' => true )), '1');
$this->assertEquals($this->_runtemplate('<% cached if Cache %>$Foo<% end_cached %>', array('Foo' => 2, 'Cache' => false)), '2');
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached if Cache %>$Foo<% end_cached %>', array('Foo' => 1, 'Cache' => false)), '1');
$this->assertEquals($this->_runtemplate('<% cached if Cache %>$Foo<% end_cached %>', array('Foo' => 2, 'Cache' => true )), '2');
}
/**
* Test that cacheblocks conditionally cache with unless
*/
function testBlocksConditionallyCacheWithUnless() {
// First, run twice with caching
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached unless False %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cached unless False %>$Foo<% end_cached %>', array('Foo' => 2)), '1');
// Then twice without caching
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached unless True %>$Foo<% end_cached %>', array('Foo' => 1)), '1');
$this->assertEquals($this->_runtemplate('<% cached unless True %>$Foo<% end_cached %>', array('Foo' => 2)), '2');
}
/**
* Test that nested uncached blocks work
*/
function testNestedUncachedBlocks() {
// First, run twice with caching, to prove we get the same result back normally
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached %> A $Foo B <% end_cached %>', array('Foo' => 1)), ' A 1 B ');
$this->assertEquals($this->_runtemplate('<% cached %> A $Foo B <% end_cached %>', array('Foo' => 2)), ' A 1 B ');
// Then add uncached to the nested block
$this->_reset(true);
$this->assertEquals($this->_runtemplate('<% cached %> A <% uncached %>$Foo<% end_uncached %> B <% end_cached %>', array('Foo' => 1)), ' A 1 B ');
$this->assertEquals($this->_runtemplate('<% cached %> A <% uncached %>$Foo<% end_uncached %> B <% end_cached %>', array('Foo' => 2)), ' A 2 B ');
}
/**
* Test that nested blocks with different keys works
*/
function testNestedBlocks() {
$this->_reset(true);
$template = '<% cached Foo %> $Fooa <% cached Bar %>$Bara<% end_cached %> $Foob <% end_cached %>';
// Do it the first time to load the cache
$this->assertEquals($this->_runtemplate($template, array('Foo' => 1, 'Fooa' => 1, 'Foob' => 3, 'Bar' => 1, 'Bara' => 2)), ' 1 2 3 ');
// Do it again, the input values are ignored as the cache is hit for both elements
$this->assertEquals($this->_runtemplate($template, array('Foo' => 1, 'Fooa' => 9, 'Foob' => 9, 'Bar' => 1, 'Bara' => 9)), ' 1 2 3 ');
// Do it again with a new key for Bar, Bara is picked up, Fooa and Foob are not
$this->assertEquals($this->_runtemplate($template, array('Foo' => 1, 'Fooa' => 9, 'Foob' => 9, 'Bar' => 2, 'Bara' => 9)), ' 1 9 3 ');
// Do it again with a new key for Foo, Fooa and Foob are picked up, Bara are not
$this->assertEquals($this->_runtemplate($template, array('Foo' => 2, 'Fooa' => 9, 'Foob' => 9, 'Bar' => 2, 'Bara' => 1)), ' 9 9 9 ');
}
/**
* @expectedException Exception
*/
function testErrorMessageForCacheWithinControl() {
$this->_reset(true);
$this->_runtemplate('<% control Foo %><% cached %>$Bar<% end_cached %><% end_control %>');
}
/**
* @expectedException Exception
*/
function testErrorMessageForCacheWithinIf() {
$this->_reset(true);
$this->_runtemplate('<% if Foo %><% cached %>$Bar<% end_cached %><% end_if %>');
}
/**
* @expectedException Exception
*/
function testErrorMessageForInvalidConditional() {
$this->_reset(true);
$this->_runtemplate('<% cached Foo if %>$Bar<% end_cached %>');
}
} }