diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index 74c499e560f53..b6cf489c5c1a0 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -47,17 +47,22 @@ function ($key, $value, $isHit) use ($defaultLifetime) { ); $this->mergeByLifetime = \Closure::bind( function ($deferred, $namespace, &$expiredIds) { - $byLifetime = array(); + $byLifetime = array('value' => array(), 'tags' => array()); $now = time(); $expiredIds = array(); foreach ($deferred as $key => $item) { + $id = $namespace.$key; + if (null === $item->expiry) { - $byLifetime[0][$namespace.$key] = $item->value; + $byLifetime['value'][0][$id] = $item->value; + $byLifetime['tags'][0][$id] = $item->tags; } elseif ($item->expiry > $now) { - $byLifetime[$item->expiry - $now][$namespace.$key] = $item->value; + $byLifetime['value'][$item->expiry - $now][$id] = $item->value; + $byLifetime['tags'][$item->expiry - $now][$id] = $item->tags; } else { - $expiredIds[] = $namespace.$key; + $expiredIds[] = $id; + continue; } } @@ -313,9 +318,13 @@ public function commit() if ($expiredIds) { $this->doDelete($expiredIds); } - foreach ($byLifetime as $lifetime => $values) { + foreach ($byLifetime['value'] as $lifetime => $values) { try { - $e = $this->doSave($values, $lifetime); + if ($this instanceof AbstractTagsInvalidatingAdapter) { + $e = $this->doSaveWithTags($values, $lifetime, $byLifetime['tags'][$lifetime]); + } else { + $e = $this->doSave($values, $lifetime); + } } catch (\Exception $e) { } if (true === $e || array() === $e) { @@ -339,8 +348,12 @@ public function commit() foreach ($retry as $lifetime => $ids) { foreach ($ids as $id) { try { - $v = $byLifetime[$lifetime][$id]; - $e = $this->doSave(array($id => $v), $lifetime); + $v = $byLifetime['value'][$lifetime][$id]; + if ($this instanceof AbstractTagsInvalidatingAdapter) { + $e = $this->doSaveWithTags(array($id => $v), $lifetime, array($id => $byLifetime['tags'][$lifetime][$id])); + } else { + $e = $this->doSave(array($id => $v), $lifetime); + } } catch (\Exception $e) { } if (true === $e || array() === $e) { diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagsInvalidatingAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagsInvalidatingAdapter.php new file mode 100644 index 0000000000000..2aa0a469fc2a6 --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/AbstractTagsInvalidatingAdapter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +/** + * @author Nicolas Grekas + */ +abstract class AbstractTagsInvalidatingAdapter extends AbstractAdapter implements TagsInvalidatingAdapterInterface +{ + /** + * Persists several cache items immediately. + * + * @param array $values The values to cache, indexed by their cache identifier. + * @param int $lifetime The lifetime of the cached values, 0 for persisting until manual cleaning. + * @param array $tags The tags corresponding to each value identifiers. + * + * @return array|bool The identifiers that failed to be cached or a boolean stating if caching succeeded or not. + */ + abstract protected function doSaveWithTags(array $values, $lifetime, array $tags); + + /** + * @internal + */ + protected function doSave(array $values, $lifetime) + { + throw new \BadMethodCallException('Use doSaveWithTags() instead.'); + } +} diff --git a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php index 6b3360c031361..218d7b56adc1f 100644 --- a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php @@ -11,10 +11,12 @@ namespace Symfony\Component\Cache\Adapter; +use Symfony\Component\Cache\CacheItem; + /** * @author Nicolas Grekas */ -class FilesystemAdapter extends AbstractAdapter +class FilesystemAdapter extends AbstractTagsInvalidatingAdapter { use FilesystemAdapterTrait; @@ -24,6 +26,20 @@ public function __construct($namespace = '', $defaultLifetime = 0, $directory = $this->init($namespace, $directory); } + /** + * {@inheritdoc} + */ + public function invalidateTags($tags) + { + $ok = true; + + foreach (CacheItem::normalizeTags($tags) as $tag) { + $ok = $this->doInvalidateTag($tag) && $ok; + } + + return $ok; + } + /** * {@inheritdoc} */ @@ -68,15 +84,125 @@ protected function doHave($id) /** * {@inheritdoc} */ - protected function doSave(array $values, $lifetime) + protected function doSaveWithTags(array $values, $lifetime, array $tags) { $ok = true; $expiresAt = $lifetime ? time() + $lifetime : PHP_INT_MAX; + $newTags = $oldTags = array(); foreach ($values as $id => $value) { + $newIdTags = $tags[$id]; + $file = $this->getFile($id, true); + $tagFile = $this->getFile($id.':tag', $newIdTags); + $hasFile = file_exists($file); + + if ($hadTags = file_exists($tagFile)) { + foreach (file($tagFile, FILE_IGNORE_NEW_LINES) as $tag) { + if (isset($newIdTags[$tag = rawurldecode($tag)])) { + if ($hasFile) { + unset($newIdTags[$tag]); + } + } else { + $oldTags[] = $tag; + } + } + if ($oldTags) { + $this->removeTags($id, $oldTags); + $oldTags = array(); + } + } + foreach ($newIdTags as $tag) { + $newTags[$tag][] = $id; + } + $ok = $this->write($this->getFile($id, true), $expiresAt."\n".rawurlencode($id)."\n".serialize($value), $expiresAt) && $ok; + + if ($tags[$id]) { + $ok = $this->write($tagFile, implode("\n", array_map('rawurlencode', $tags[$id]))."\n") && $ok; + } elseif ($hadTags) { + @unlink($tagFile); + } + } + if ($newTags) { + $ok = $this->doTag($newTags) && $ok; + } + + return $ok; + } + + private function doTag(array $tags) + { + $ok = true; + $linkedTags = array(); + + foreach ($tags as $tag => $ids) { + $file = $this->getFile($tag, true); + $linkedTags[$tag] = file_exists($file) ?: null; + $h = fopen($file, 'ab'); + + foreach ($ids as $id) { + $ok = fwrite($h, rawurlencode($id)."\n") && $ok; + } + fclose($h); + + while (!isset($linkedTags[$tag]) && 0 < $r = strrpos($tag, '/')) { + $linkedTags[$tag] = true; + $parent = substr($tag, 0, $r); + $file = $this->getFile($parent, true); + $linkedTags[$parent] = file_exists($file) ?: null; + $ok = file_put_contents($file, rawurlencode($tag)."\n", FILE_APPEND) && $ok; + $tag = $parent; + } + } + + return $ok; + } + + private function doInvalidateTag($tag) + { + if (!$h = @fopen($this->getFile($tag), 'r+b')) { + return true; + } + $ok = true; + $count = 0; + + while (false !== $id = fgets($h)) { + if ('!' === $id[0]) { + continue; + } + $id = rawurldecode(substr($id, 0, -1)); + + if ('/' === $id[0]) { + $ok = $this->doInvalidateTag($id) && $ok; + } elseif (file_exists($file = $this->getFile($id))) { + $count += $unlink = @unlink($file); + $ok = ($unlink || !file_exists($file)) && $ok; + } } + ftruncate($h, 0); + fclose($h); + CacheItem::log($this->logger, 'Invalidating {count} items tagged as "{tag}"', array('tag' => $tag, 'count' => $count)); + return $ok; } + + private function removeTags($id, $tags) + { + $idLine = rawurlencode($id)."\n"; + $idSeek = -strlen($idLine); + + foreach ($tags as $tag) { + if (!$h = @fopen($this->getFile($tag), 'r+b')) { + continue; + } + while (false !== $line = fgets($h)) { + if ($line === $idLine) { + fseek($h, $idSeek, SEEK_CUR); + fwrite($h, '!'); + } + } + fclose($h); + } + } } diff --git a/src/Symfony/Component/Cache/Adapter/RedisAdapter.php b/src/Symfony/Component/Cache/Adapter/RedisAdapter.php index 92c065ebf7c78..c965619394c57 100644 --- a/src/Symfony/Component/Cache/Adapter/RedisAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/RedisAdapter.php @@ -14,13 +14,14 @@ use Predis\Connection\Factory; use Predis\Connection\Aggregate\PredisCluster; use Predis\Connection\Aggregate\RedisCluster; +use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\Exception\InvalidArgumentException; /** * @author Aurimas Niekis * @author Nicolas Grekas */ -class RedisAdapter extends AbstractAdapter +class RedisAdapter extends AbstractTagsInvalidatingAdapter { private static $defaultConnectionOptions = array( 'class' => null, @@ -30,6 +31,8 @@ class RedisAdapter extends AbstractAdapter 'retry_interval' => 0, ); private $redis; + private $namespace; + private $namespaceLen; /** * @param \Redis|\RedisArray|\RedisCluster|\Predis\Client $redisClient @@ -45,6 +48,8 @@ public function __construct($redisClient, $namespace = '', $defaultLifetime = 0) throw new InvalidArgumentException(sprintf('%s() expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\Client, %s given', __METHOD__, is_object($redisClient) ? get_class($redisClient) : gettype($redisClient))); } $this->redis = $redisClient; + $this->namespace = $namespace; + $this->namespaceLen = strlen($namespace); } /** @@ -129,6 +134,20 @@ public static function createConnection($dsn, array $options = array()) return $redis; } + /** + * {@inheritdoc} + */ + public function invalidateTags($tags) + { + $tags = CacheItem::normalizeTags($tags); + + foreach ($tags as $tag) { + $this->doInvalidateTag($this->namespace.$tag); + } + + return true; + } + /** * {@inheritdoc} */ @@ -217,14 +236,26 @@ protected function doDelete(array $ids) /** * {@inheritdoc} */ - protected function doSave(array $values, $lifetime) + protected function doSaveWithTags(array $values, $lifetime, array $tags) { $serialized = array(); $failed = array(); + $oldTagsById = $oldKeysByTag = $newKeys = array(); foreach ($values as $id => $value) { try { $serialized[$id] = serialize($value); + + $key = substr($id, $this->namespaceLen); + foreach ($this->redis->sMembers($id.':tag') as $tag) { + if (!isset($tags[$id][$tag])) { + $oldTagsById[$id][] = $tag; + $oldKeysByTag[$tag][] = $key; + } + } + foreach ($tags[$id] as $tag) { + $newKeys[$tag][] = $key; + } } catch (\Exception $e) { $failed[] = $id; } @@ -234,21 +265,96 @@ protected function doSave(array $values, $lifetime) return $failed; } - if (0 >= $lifetime) { + if (0 >= $lifetime && !$newKeys && !$oldTagsById) { $this->redis->mSet($serialized); return $failed; } - $this->pipeline(function ($pipe) use (&$serialized, $lifetime) { + $this->pipeline(function ($pipe) use (&$serialized, $lifetime, &$oldTagsById, &$oldKeysByTag, &$tags, &$newKeys) { + foreach ($oldKeysByTag as $tag => $keys) { + $pipe('sRem', $this->namespace.$tag, $keys); + } + foreach ($oldTagsById as $id => $keys) { + $pipe('sRem', $id.':tag', $keys); + } + foreach ($serialized as $id => $value) { - $pipe('setEx', $id, array($lifetime, $value)); + if (0 < $lifetime) { + $pipe('setEx', $id, array($lifetime, $value)); + } else { + $pipe('set', $id, array($value)); + } + if ($tags[$id]) { + $pipe('sAdd', $id.':tag', $tags[$id]); + } + } + + foreach ($newKeys as $tag => $keys) { + $pipe('sAdd', $this->namespace.$tag, $keys); + + while (0 < $r = strrpos($tag, '/')) { + $parent = substr($tag, 0, $r); + $pipe('sAdd', $this->namespace.$parent.':child', array($tag)); + $tag = $parent; + } } }); return $failed; } + private function doInvalidateTag($tag) + { + foreach ($this->sScan($tag.':child') as $children) { + $this->execute('sRem', $tag.':child', $children); + foreach ($children as $child) { + $this->doInvalidateTag($this->namespace.$child); + } + } + + $count = 0; + + foreach ($this->sScan($tag) as $ids) { + $count += count($ids); + $this->execute('sRem', $tag, $ids); + foreach ($ids as $k => $id) { + $ids[$k] = $this->namespace.$id; + } + $this->redis->del($ids); + } + + CacheItem::log($this->logger, 'Invalidating {count} items tagged as "{tag}"', array('tag' => $tag, 'count' => $count)); + } + + private function sScan($id) + { + $redis = $this->redis instanceof \RedisArray ? $this->redis->_instance($this->redis->_target($id)) : $this->redis; + + if (method_exists($redis, 'sScan')) { + try { + $cursor = null; + do { + $ids = $redis->sScan($id, $cursor); + + if (isset($ids[1]) && is_array($ids[1])) { + $cursor = $ids[0]; + $ids = $ids[1]; + } + if ($ids) { + yield $ids; + } + } while ($cursor); + + return; + } catch (\Exception $e) { + } + } + if ($ids = $redis->sMembers($id)) { + yield $ids; + } + } + private function execute($command, $id, array $args, $redis = null) { array_unshift($args, $id); diff --git a/src/Symfony/Component/Cache/Adapter/TagsInvalidatingAdapterInterface.php b/src/Symfony/Component/Cache/Adapter/TagsInvalidatingAdapterInterface.php new file mode 100644 index 0000000000000..3ea8f1c4e2ddc --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/TagsInvalidatingAdapterInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Psr\Cache\CacheItemPoolInterface; +use Psr\Cache\InvalidArgumentException; + +/** + * Interface for invalidating cached items using tag hierarchies. + * + * @author Nicolas Grekas + */ +interface TagsInvalidatingAdapterInterface extends CacheItemPoolInterface +{ + /** + * Invalidates cached items using tag hierarchies. + * + * @param string|string[] $tags A tag or an array of tag hierarchies to invalidate. + * + * @return bool True on success. + * + * @throws InvalidArgumentException When $tags is not valid. + */ + public function invalidateTags($tags); +} diff --git a/src/Symfony/Component/Cache/CacheItem.php b/src/Symfony/Component/Cache/CacheItem.php index e09dfafd6bdd6..86ad7b03c11e2 100644 --- a/src/Symfony/Component/Cache/CacheItem.php +++ b/src/Symfony/Component/Cache/CacheItem.php @@ -11,14 +11,13 @@ namespace Symfony\Component\Cache; -use Psr\Cache\CacheItemInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Cache\Exception\InvalidArgumentException; /** * @author Nicolas Grekas */ -final class CacheItem implements CacheItemInterface +final class CacheItem implements TaggedCacheItemInterface { /** * @internal @@ -30,6 +29,7 @@ final class CacheItem implements CacheItemInterface private $isHit; private $expiry; private $defaultLifetime; + private $tags = array(); /** * {@inheritdoc} @@ -99,6 +99,60 @@ public function expiresAfter($time) return $this; } + /** + * {@inheritdoc} + */ + public function tag($tags) + { + $this->tags += self::normalizeTags($tags); + + return $this; + } + + /** + * Normalizes cache tags. + * + * @param string|string[] $tags The tags to validate. + * + * @throws InvalidArgumentException When $tags is not valid. + */ + public static function normalizeTags($tags) + { + if (!is_array($tags)) { + $tags = array($tags); + } + $normalizedTags = array(); + + foreach ($tags as $tag) { + if (!is_string($tag)) { + throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given', is_object($tag) ? get_class($tag) : gettype($tag))); + } + if (!isset($tag[0])) { + throw new InvalidArgumentException('Cache tag length must be greater than zero'); + } + if (isset($tag[strcspn($tag, '{}()\@:')])) { + throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters {}()*\@:', $tag)); + } + if (false === $r = strrpos($tag, '/')) { + $tag = '/'.$tag; + $normalizedTags[$tag] = $tag; + continue; + } + if (!isset($tag[$r + 1])) { + throw new InvalidArgumentException(sprintf('Cache tag "%s" ends with a slash', $tag)); + } + if (false !== strpos($tag, '//')) { + throw new InvalidArgumentException(sprintf('Cache tag "%s" contains double slashes', $tag)); + } + if ('/' !== $tag[0]) { + $tag = '/'.$tag; + } + $normalizedTags[$tag] = $tag; + } + + return $normalizedTags; + } + /** * Validates a cache key according to PSR-6. * diff --git a/src/Symfony/Component/Cache/TaggedCacheItemInterface.php b/src/Symfony/Component/Cache/TaggedCacheItemInterface.php new file mode 100644 index 0000000000000..aff7685cb3e79 --- /dev/null +++ b/src/Symfony/Component/Cache/TaggedCacheItemInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\InvalidArgumentException; + +/** + * Interface for adding hierarchical tags to cache items. + * + * @author Nicolas Grekas + */ +interface TaggedCacheItemInterface extends CacheItemInterface +{ + /** + * Adds a tag to a cache item. + * + * @param string|string[] $tags A slash separated hierarchical tag or array of tags. + * + * @return static + * + * @throws InvalidArgumentException When $tag is not valid. + */ + public function tag($tags); +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php index 8f1ebf655b356..8141fb2a4c534 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php @@ -11,10 +11,9 @@ namespace Symfony\Component\Cache\Tests\Adapter; -use Cache\IntegrationTests\CachePoolTest; use Symfony\Component\Cache\Adapter\RedisAdapter; -abstract class AbstractRedisAdapterTest extends CachePoolTest +abstract class AbstractRedisAdapterTest extends TagsInvalidatingAdapterTestCase { protected static $redis; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php index 418cbf9ebc29f..d47b7a1f06ee6 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php @@ -11,13 +11,12 @@ namespace Symfony\Component\Cache\Tests\Adapter; -use Cache\IntegrationTests\CachePoolTest; use Symfony\Component\Cache\Adapter\FilesystemAdapter; /** * @group time-sensitive */ -class FilesystemAdapterTest extends CachePoolTest +class FilesystemAdapterTest extends TagsInvalidatingAdapterTestCase { public function createCachePool() { diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagsInvalidatingAdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/TagsInvalidatingAdapterTestCase.php new file mode 100644 index 0000000000000..06d64a1369650 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/TagsInvalidatingAdapterTestCase.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Cache\IntegrationTests\CachePoolTest; + +abstract class TagsInvalidatingAdapterTestCase extends CachePoolTest +{ + /** + * @expectedException Psr\Cache\InvalidArgumentException + */ + public function testInvalidTag() + { + $pool = $this->createCachePool(); + $item = $pool->getItem('foo'); + $item->tag(':'); + } + + public function testInvalidateTags() + { + $pool = $this->createCachePool(); + + $i0 = $pool->getItem('i0'); + $i1 = $pool->getItem('i1'); + $i2 = $pool->getItem('i2'); + $i3 = $pool->getItem('i3'); + $i4 = $pool->getItem('i4'); + $i5 = $pool->getItem('i5'); + $foo = $pool->getItem('foo'); + + $pool->save($i0->tag('bar')); + $pool->save($i1->tag('foo')); + $pool->save($i2->tag('foo')->tag('bar')); + $pool->save($i3->tag('foo')->tag('baz')); + $pool->save($i4->tag('foo/bar')); + $pool->save($i5->tag('foo/baz')); + $pool->save($foo); + + $pool->invalidateTags('bar'); + + $this->assertFalse($pool->getItem('i0')->isHit()); + $this->assertTrue($pool->getItem('i1')->isHit()); + $this->assertFalse($pool->getItem('i2')->isHit()); + $this->assertTrue($pool->getItem('i3')->isHit()); + $this->assertTrue($pool->getItem('i4')->isHit()); + $this->assertTrue($pool->getItem('i5')->isHit()); + $this->assertTrue($pool->getItem('foo')->isHit()); + + $pool->save($i0->tag('bar')); + $pool->save($i2->tag('bar')); + + $pool->invalidateTags('foo/bar'); + + $this->assertTrue($pool->getItem('i0')->isHit()); + $this->assertTrue($pool->getItem('i1')->isHit()); + $this->assertTrue($pool->getItem('i2')->isHit()); + $this->assertTrue($pool->getItem('i3')->isHit()); + $this->assertFalse($pool->getItem('i4')->isHit()); + $this->assertTrue($pool->getItem('i5')->isHit()); + $this->assertTrue($pool->getItem('foo')->isHit()); + + $pool->save($i4->tag('foo/bar')); + + $pool->invalidateTags('foo'); + + $this->assertTrue($pool->getItem('i0')->isHit()); + $this->assertFalse($pool->getItem('i1')->isHit()); + $this->assertFalse($pool->getItem('i2')->isHit()); + $this->assertFalse($pool->getItem('i3')->isHit()); + $this->assertFalse($pool->getItem('i4')->isHit()); + $this->assertFalse($pool->getItem('i5')->isHit()); + $this->assertTrue($pool->getItem('foo')->isHit()); + } + + public function testTagsAreCleanedOnSave() + { + $pool = $this->createCachePool(); + + $i = $pool->getItem('k'); + $pool->save($i->tag('foo')); + + $i = $pool->getItem('k'); + $pool->save($i->tag('bar')); + + $pool->invalidateTags('foo'); + $this->assertTrue($pool->getItem('k')->isHit()); + } + + public function testTagsAreCleanedOnDelete() + { + $pool = $this->createCachePool(); + + $i = $pool->getItem('k'); + $pool->save($i->tag('foo')); + $pool->deleteItem('k'); + + $pool->save($pool->getItem('k')); + $pool->invalidateTags('foo'); + + $this->assertTrue($pool->getItem('k')->isHit()); + } +} diff --git a/src/Symfony/Component/Cache/Tests/CacheItemTest.php b/src/Symfony/Component/Cache/Tests/CacheItemTest.php index 7b20a41ba2fe0..af2617edb1e4e 100644 --- a/src/Symfony/Component/Cache/Tests/CacheItemTest.php +++ b/src/Symfony/Component/Cache/Tests/CacheItemTest.php @@ -50,4 +50,44 @@ public function provideInvalidKey() array(new \Exception('foo')), ); } + + public function testNormalizeTag() + { + $this->assertSame(array('/foo' => '/foo'), CacheItem::normalizeTags('foo')); + $this->assertSame(array('/foo/bar' => '/foo/bar'), CacheItem::normalizeTags(array('/foo/bar'))); + } + + /** + * @dataProvider provideInvalidTag + * @expectedException Symfony\Component\Cache\Exception\InvalidArgumentException + * @expectedExceptionMessage Cache tag + */ + public function testInvalidTag($tag) + { + CacheItem::normalizeTags($tag); + } + + public function provideInvalidTag() + { + return array( + array('/'), + array('foo/'), + array('//foo'), + array('foo//bar'), + array(''), + array('{'), + array('}'), + array('('), + array(')'), + array('\\'), + array('@'), + array(':'), + array(true), + array(null), + array(1), + array(1.1), + array(array(array())), + array(new \Exception('foo')), + ); + } }