diff --git a/core/SSViewer.php b/core/SSViewer.php index 79877ce36..23def2511 100755 --- a/core/SSViewer.php +++ b/core/SSViewer.php @@ -462,13 +462,13 @@ class SSViewer { $content = preg_replace(array_keys($replacements), array_values($replacements), $content); $content = str_replace('{dlr}','$',$content); + // Cache block + $content = SSViewer_PartialParser::process($template, $content); + // legacy $content = ereg_replace('', '<' . '% control \\1 %' . '>', $content); $content = ereg_replace('', '<' . '% end_control %' . '>', $content); - // < % cacheblock key, key.. % > - $content = SSViewer_PartialParser::parse($template, $content); - // < % control Foo % > $content = ereg_replace('<' . '% +control +([A-Za-z0-9_]+) +%' . '>', 'obj("\\1")) foreach($loop as $key => $item) { ?>', $content); // < % control Foo.Bar % > @@ -666,91 +666,219 @@ class SSViewer_FromString extends SSViewer { */ 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* - ( (\w+) \s* ( \( ([^\)]*) \) )? ) | # A property lookup or a function call - ( \' [^\']+ \' ) | # A string surrounded by \' - ( " [^"]+ " ) # A string surrounded by " - \s*/xS'; + static $argument_splitter = '/^\s* + # The argument itself + ( + (?P if | unless ) | # The if or unless keybreak + (?P (?P \w+) \s* # A property lookup or a function call + ( \( (?P [^\)]*) \) )? + ) | + (?P \' (\\\'|[^\'])+ \' ) | # A string surrounded by \' + (?P " (\\"|[^"])+ " ) # A string surrounded by " + ) + # Some seperator after the argument + ( + \s*(?P,)\s* | # A comma (maybe with whitespace before or after) + (?P\.) # A period (no whitespace before) + )? + /xS'; - static $closing_tag = '/< % [ \t]+ end_cacheblock [ \t]+ % >/xS'; - - static function parse($template, $content) { - $parser = new SSViewer_PartialParser($template); - - $content = $parser->replaceOpeningTags($content); - $content = $parser->replaceClosingTags($content); - return $content; + static function process($template, $content) { + $parser = new SSViewer_PartialParser($template, $content, 0, array(), 'if', 'false'); + $parser->parse(); + return $parser->generate(); } - function __construct($template) { + function __construct($template, $content, $offset, $keyparts, $conditional, $condition) { $this->template = $template; - $this->cacheblocks = 0; - } - - 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 'load('.$key.')) { $val .= $partial; } else { $valStack[] = $val; $val = ""; ?>'; - } - - function key($matches) { - $parts = array(); - $parts[] = "'".preg_replace('/[^\w+]/', '_', $this->template)."'"; + $this->content = $content; + $this->offset = $offset; - // If there weren't any arguments, that'll do - if (!@$matches[1]) return $parts[0]; + $this->keyparts = $keyparts; + $this->conditional = $conditional; + $this->condition = $condition; - $current = 'preg_replace(\'/[^\w+]/\', \'_\', $item->'; - $keyspec = $matches[1]; + $this->blocks = array(); + } + + 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)) { - $joiner = substr($keyspec, strlen($submatch[0]), 1); - $keyspec = substr($keyspec, strlen($submatch[0]) + 1); - + while (preg_match(self::$tag, $this->content, $matches, PREG_OFFSET_CAPTURE, $this->offset)) { + $tag = $matches[1][0]; + + $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 ($submatch[1]) { + if (@$match['property']) { // Get the property - $what = $submatch[2]; + $what = $match['identifier']; $args = array(); - + // Extract any arguments passed to the function call - if (@$submatch[3]) { - foreach (explode(',', $submatch[4]) as $arg) { + if (@$match['arguments']) { + foreach (explode(',', $match['arguments']) as $arg) { $args[] = is_numeric($arg) ? (string)$arg : '"'.$arg.'"'; } } - + $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 ($joiner == '.') { - $current .= "obj(\"$what\", $args, true)->"; + if (@$match['fullstop']) { + $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 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 if ($submatch[5] || $submatch[6]) { - $parts[] = $submatch[5] ? $submatch[5] : $submatch[6]; - } - + else if (@$match['sqstring']) $parts[] = $match['sqstring']; + else if (@$match['dqstring']) $parts[] = $match['dqstring']; } - - return implode(".'_'.", $parts); - } - - function replaceClosingTags($content) { - return preg_replace(self::$closing_tag, 'save($val); $val = array_pop($valStack) . $val; } ?>', $content); + + if ($conditional && !$condition) { + throw new Exception("You need to have a condition after the conditional $conditional in your cache block"); + } + + 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[] = "load('.$partialkey.'))) $val .= $partial;'."\n"; + + // Cache miss - regenerate + $res[] = "else {\n"; + $res[] = '$oldval = $val; $val = "";'."\n"; + $res[] = "\n?>" . $block . "save($val); $val = $oldval . $val ;'."\n"; + $res[] = "}\n?>"; + } + } + } + + return implode('', $res); } } diff --git a/tests/SSViewerCacheBlockTest.php b/tests/SSViewerCacheBlockTest.php index fad4614fc..856fff3c5 100644 --- a/tests/SSViewerCacheBlockTest.php +++ b/tests/SSViewerCacheBlockTest.php @@ -10,7 +10,14 @@ class SSViewerCacheBlockTest_Model extends DataObject implements TestOnly { function Foo() { return 'Bar'; } - + + function True() { + return true; + } + + function False() { + return false; + } } class SSViewerCacheBlockTest extends SapphireTest { @@ -28,45 +35,179 @@ class SSViewerCacheBlockTest extends SapphireTest { protected function _runtemplate($template, $data = null) { 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); return $viewer->process($data); } 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->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 $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 $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 $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 */ 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->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', 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' => 1)), '1'); + $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 2)), '2'); // Then twice with caching, should get same result each time $this->_reset(true); - $this->assertEquals($this->_runtemplate('<% cacheblock %>$Foo<% end_cacheblock %>', 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' => 1)), '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 %>'); + } + } \ No newline at end of file