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
*/
-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')),
+ );
+ }
}