diff --git a/src/Illuminate/Cache/ApcStore.php b/src/Illuminate/Cache/ApcStore.php index 89c31a3f7f0c..541099132b82 100755 --- a/src/Illuminate/Cache/ApcStore.php +++ b/src/Illuminate/Cache/ApcStore.php @@ -103,6 +103,20 @@ public function forget($key) return $this->apc->delete($this->prefix.$key); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + $value = $this->apc->get($key = $this->getPrefix().$key); + + if (is_null($value)) { + return false; + } + + return $this->apc->put($key, $value, $ttl); + } + /** * Remove all items from the cache. * diff --git a/src/Illuminate/Cache/ArrayStore.php b/src/Illuminate/Cache/ArrayStore.php index 112501831822..64e127939395 100644 --- a/src/Illuminate/Cache/ArrayStore.php +++ b/src/Illuminate/Cache/ArrayStore.php @@ -3,6 +3,7 @@ namespace Illuminate\Cache; use Illuminate\Contracts\Cache\LockProvider; +use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\InteractsWithTime; @@ -130,6 +131,24 @@ public function forever($key, $value) return $this->put($key, $value, 0); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + $item = Arr::get($this->storage, $key = $this->getPrefix().$key, null); + + if (is_null($item)) { + return false; + } + + $item['expiresAt'] = $this->calculateExpiration($ttl); + + $this->storage = array_merge($this->storage, [$key => $item]); + + return true; + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/DatabaseStore.php b/src/Illuminate/Cache/DatabaseStore.php index 04c52e45922d..828b9bf51ef7 100755 --- a/src/Illuminate/Cache/DatabaseStore.php +++ b/src/Illuminate/Cache/DatabaseStore.php @@ -411,6 +411,17 @@ protected function forgetManyIfExpired(array $keys, bool $prefixed = false) return true; } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + return (bool) $this->table() + ->where('key', '=', $this->getPrefix().$key) + ->where('expiration', '>', $now = $this->getTime()) + ->update(['expiration' => $now + $ttl]); + } + /** * Remove all items from the cache. * diff --git a/src/Illuminate/Cache/DynamoDbStore.php b/src/Illuminate/Cache/DynamoDbStore.php index 1bc7aa879865..ea342be6dc17 100644 --- a/src/Illuminate/Cache/DynamoDbStore.php +++ b/src/Illuminate/Cache/DynamoDbStore.php @@ -448,6 +448,39 @@ public function forget($key) return true; } + /** + * Set the expiration time of a cached item. + * + * @throws DynamoDbException + */ + public function touch(string $key, int $ttl): bool + { + try { + $this->dynamo->updateItem([ + 'TableName' => $this->table, + 'Key' => [$this->keyAttribute => ['S' => $this->getPrefix().$key]], + 'UpdateExpression' => 'SET #expiry = :expiry', + 'ConditionExpression' => 'attribute_exists(#key) AND #expiry > :now', + 'ExpressionAttributeNames' => [ + '#key' => $this->keyAttribute, + '#expiry' => $this->expirationAttribute, + ], + 'ExpressionAttributeValues' => [ + ':expiry' => ['N' => (string) $this->toTimestamp($ttl)], + ':now' => ['N' => (string) $this->currentTime()], + ], + ]); + } catch (DynamoDbException $e) { + if (str_contains($e->getMessage(), 'ConditionalCheckFailed')) { + return false; + } + + throw $e; + } + + return true; + } + /** * Remove all items from the cache. * diff --git a/src/Illuminate/Cache/FileStore.php b/src/Illuminate/Cache/FileStore.php index d445f5fc7c23..21a104abb109 100755 --- a/src/Illuminate/Cache/FileStore.php +++ b/src/Illuminate/Cache/FileStore.php @@ -257,6 +257,20 @@ public function forget($key) return false; } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + $payload = $this->getPayload($this->getPrefix().$key); + + if (is_null($payload['data'])) { + return false; + } + + return $this->put($key, $payload['data'], $ttl); + } + /** * Remove all items from the cache. * diff --git a/src/Illuminate/Cache/MemcachedStore.php b/src/Illuminate/Cache/MemcachedStore.php index b05560e1a986..2afe2bdb46a0 100755 --- a/src/Illuminate/Cache/MemcachedStore.php +++ b/src/Illuminate/Cache/MemcachedStore.php @@ -202,6 +202,14 @@ public function restoreLock($name, $owner) return $this->lock($name, 0, $owner); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + return $this->memcached->touch($this->getPrefix().$key, $this->calculateExpiration($ttl)); + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/MemoizedStore.php b/src/Illuminate/Cache/MemoizedStore.php index fc6313db2a1a..c1abc9fbf43a 100644 --- a/src/Illuminate/Cache/MemoizedStore.php +++ b/src/Illuminate/Cache/MemoizedStore.php @@ -208,6 +208,16 @@ public function forget($key) return $this->repository->forget($key); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + unset($this->cache[$this->prefix($key)]); + + return $this->repository->touch($key, $ttl); + } + /** * Remove all items from the cache. * diff --git a/src/Illuminate/Cache/NullStore.php b/src/Illuminate/Cache/NullStore.php index 6c35ee386c26..5555a4869e94 100755 --- a/src/Illuminate/Cache/NullStore.php +++ b/src/Illuminate/Cache/NullStore.php @@ -93,6 +93,14 @@ public function restoreLock($name, $owner) return $this->lock($name, 0, $owner); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + return false; + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index 33cdf87307c7..d8909687d54a 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -248,6 +248,14 @@ public function restoreLock($name, $owner) return $this->lock($name, 0, $owner); } + /** + * Set the expiration time of a cached item. + */ + public function touch(string $key, int $ttl): bool + { + return (bool) $this->connection()->expire($this->getPrefix().$key, (int) max(1, $ttl)); + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index 3eb6f700ed01..4a195cc0e788 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -525,6 +525,22 @@ public function flexible($key, $ttl, $callback, $lock = null) return $value; } + /** + * Set the expiration of a cached item; null TTL will retain indefinitely. + */ + public function touch(string $key, \DateTimeInterface|\DateInterval|int|null $ttl = null): bool + { + $value = $this->get($key); + + if (is_null($value)) { + return false; + } + + return is_null($ttl) + ? $this->forever($key, $value) + : $this->store->touch($this->itemKey($key), $this->getSeconds($ttl)); + } + /** * Remove an item from the cache. * diff --git a/src/Illuminate/Contracts/Cache/Repository.php b/src/Illuminate/Contracts/Cache/Repository.php index 4bc4638e46fe..beffc6b25cf6 100644 --- a/src/Illuminate/Contracts/Cache/Repository.php +++ b/src/Illuminate/Contracts/Cache/Repository.php @@ -3,6 +3,8 @@ namespace Illuminate\Contracts\Cache; use Closure; +use DateInterval; +use DateTimeInterface; use Psr\SimpleCache\CacheInterface; interface Repository extends CacheInterface @@ -88,6 +90,11 @@ public function remember($key, $ttl, Closure $callback); */ public function sear($key, Closure $callback); + /** + * Set the expiration of a cached item; null TTL will retain indefinitely. + */ + public function touch(string $key, DateTimeInterface|DateInterval|int|null $ttl = null): bool; + /** * Get an item from the cache, or execute the given Closure and store the result forever. * diff --git a/src/Illuminate/Contracts/Cache/Store.php b/src/Illuminate/Contracts/Cache/Store.php index 4ededd4efbc8..b29b74193ad5 100644 --- a/src/Illuminate/Contracts/Cache/Store.php +++ b/src/Illuminate/Contracts/Cache/Store.php @@ -83,6 +83,11 @@ public function forget($key); */ public function flush(); + /** + * Set the expiration of a cached item. + */ + public function touch(string $key, int $ttl): bool; + /** * Get the cache key prefix. * diff --git a/src/Illuminate/Support/Facades/Cache.php b/src/Illuminate/Support/Facades/Cache.php index 07553d8bb812..0d49bbe1d3a3 100755 --- a/src/Illuminate/Support/Facades/Cache.php +++ b/src/Illuminate/Support/Facades/Cache.php @@ -36,6 +36,7 @@ * @method static mixed flexible(string $key, array $ttl, callable $callback, array|null $lock = null) * @method static bool forget(string $key) * @method static bool delete(string $key) + * @method static bool touch(string $key, \DateTimeInterface|\DateInterval|int|null $ttl = null) * @method static bool deleteMultiple(iterable $keys) * @method static bool clear() * @method static \Illuminate\Cache\TaggedCache tags(array|mixed $names) diff --git a/tests/Cache/CacheApcStoreTest.php b/tests/Cache/CacheApcStoreTest.php index e13d2adf63ec..d366a660e671 100755 --- a/tests/Cache/CacheApcStoreTest.php +++ b/tests/Cache/CacheApcStoreTest.php @@ -124,6 +124,19 @@ public function testForgetMethodProperlyCallsAPC() $this->assertTrue($result); } + public function testTouchMethodProperlyCallsAPC(): void + { + $key = 'key'; + $ttl = 60; + + $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['get', 'put'])->getMock(); + + $apc->expects($this->once())->method('get')->with($this->equalTo($key))->willReturn('bar'); + $apc->expects($this->once())->method('put')->with($this->equalTo($key), $this->equalTo('bar'), $this->equalTo($ttl))->willReturn(true); + + $this->assertTrue((new ApcStore($apc))->touch($key, $ttl)); + } + public function testFlushesCached() { $apc = $this->getMockBuilder(ApcWrapper::class)->onlyMethods(['flush'])->getMock(); diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index 08326e4a4a8a..daf287cd7621 100755 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -69,6 +69,23 @@ public function testItemsCanExpire() $this->assertNull($result); } + public function testTouchExtendsTtl(): void + { + $key = 'key'; + $value = 'value'; + + $store = new ArrayStore; + + Carbon::setTestNow($now = Carbon::now()); + + $store->put($key, $value, 30); + $store->touch($key, 60); + + Carbon::setTestNow($now->addSeconds(45)); + + $this->assertSame($value, $store->get($key)); + } + public function testStoreItemForeverProperlyStoresInArray() { $mock = $this->getMockBuilder(ArrayStore::class)->onlyMethods(['put'])->getMock(); diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index e069421b2c43..af52ca677eab 100755 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -224,6 +224,50 @@ public function testDecrementReturnsCorrectValues() $this->assertEquals(2, $store->decrement('bar')); } + public function testTouchExtendsTtl() + { + $ttl = 60; + + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); + $table = m::mock(stdClass::class); + + $store->getConnection()->shouldReceive('table')->with('table')->andReturn($table); + $store->expects($this->once())->method('getTime')->willReturn(0); + $table->shouldReceive('where')->twice()->andReturn($table); + $table->shouldReceive('update')->once()->with(['expiration' => $ttl])->andReturn(1); + + $this->assertTrue($store->touch('foo', $ttl)); + } + public function testTouchExtendsTtlOnPostgres(): void + { + $ttl = 60; + + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getPostgresMocks())->getMock(); + $table = m::mock(stdClass::class); + + $store->getConnection()->shouldReceive('table')->with('table')->andReturn($table); + $store->expects($this->once())->method('getTime')->willReturn(0); + $table->shouldReceive('where')->twice()->andReturn($table); + $table->shouldReceive('update')->once()->with(['expiration' => $ttl])->andReturn(1); + + $this->assertTrue($store->touch('foo', $ttl)); + } + + public function testTouchExtendsTtlOnSqlite() + { + $ttl = 60; + + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getSqliteMocks())->getMock(); + $table = m::mock(stdClass::class); + + $store->getConnection()->shouldReceive('table')->with('table')->andReturn($table); + $store->expects($this->once())->method('getTime')->willReturn(0); + $table->shouldReceive('where')->twice()->andReturn($table); + $table->shouldReceive('update')->once()->with(['expiration' => $ttl])->andReturn(1); + + $this->assertTrue($store->touch('foo', $ttl)); + } + protected function getStore() { return new DatabaseStore(m::mock(Connection::class), 'table', 'prefix'); diff --git a/tests/Cache/CacheDynamoDbStoreTest.php b/tests/Cache/CacheDynamoDbStoreTest.php new file mode 100755 index 000000000000..a7945edc7ccc --- /dev/null +++ b/tests/Cache/CacheDynamoDbStoreTest.php @@ -0,0 +1,46 @@ +assertTrue((new DynamoDbStore($dynamo = new TestDynamo, $table))->touch($key, $ttl)); + + $this->assertTrue( + isset($dynamo->args['UpdateExpression'], $dynamo->args['TableName'], $dynamo->args['Key']['key']['S']) + && $dynamo->args['TableName'] === $table + && $dynamo->args['Key']['key']['S'] === $key + && str_contains($dynamo->args['UpdateExpression'], 'SET') + ); + + $this->assertTrue( + $ttl === $dynamo->args['ExpressionAttributeValues'][':expiry']['N'] + - $dynamo->args['ExpressionAttributeValues'][':now']['N'] + ); + } +} + +class TestDynamo extends DynamoDbClient +{ + public array $args; + + public function __construct() {} + + public function updateItem(array $args): bool + { + $this->args = $args; + + return true; + } +} diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 66ff0b94a8f7..513bc867b1ea 100755 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -111,6 +111,39 @@ public function testStoreItemProperlyStoresValues() $this->assertTrue($result); } + public function testTouchExtendsTtl(): void + { + $files = $this->mockFilesystem(); + $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['expiration', 'get', 'getPayload'])->setConstructorArgs([$files, __DIR__])->getMock(); + + $now = Carbon::now(); + + $key = 'foo'; + $content = 'Hello World'; + $ttl = 60; + $hash = sha1($key); + $path = __DIR__.'/'.substr($hash, 0, 2).'/'.substr($hash, 2, 2).'/'.$hash; + + $store->expects($this->once()) + ->method('expiration') + ->with($this->equalTo($ttl)) + ->willReturn($now->clone()->addSeconds($ttl)->getTimestamp()); + $store->expects($this->once()) + ->method('getPayload') + ->with($key) + ->willReturn(['data' => $content, 'expiration' => $now->clone()->addSeconds($ttl)->getTimestamp()]); + $files->expects($this->once()) + ->method('put') + ->with( + $this->equalTo($path), + $this->equalTo(($now->clone()->addSeconds($ttl)->getTimestamp()).serialize($content)), + $this->equalTo(true) + ) + ->willReturn(1); + + $this->assertTrue($store->touch($key, $ttl)); + } + public function testStoreItemProperlySetsPermissions() { $files = m::mock(Filesystem::class); diff --git a/tests/Cache/CacheMemcachedStoreTest.php b/tests/Cache/CacheMemcachedStoreTest.php index cb5dadc8f9bb..70fdcfa025b1 100755 --- a/tests/Cache/CacheMemcachedStoreTest.php +++ b/tests/Cache/CacheMemcachedStoreTest.php @@ -67,6 +67,20 @@ public function testSetMethodProperlyCallsMemcache() Carbon::setTestNow(null); } + public function testTouchMethodProperlyCallsMemcache(): void + { + $key = 'key'; + $ttl = 60; + + $now = Carbon::now(); + + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['touch'])->getMock(); + + $memcache->expects($this->once())->method('touch')->with($this->equalTo($key), $this->equalTo($now->addSeconds($ttl)->getTimestamp()))->willReturn(true); + + $this->assertTrue((new MemcachedStore($memcache))->touch($key, $ttl)); + } + public function testIncrementMethodProperlyCallsMemcache() { $memcached = m::mock(Memcached::class); diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php new file mode 100644 index 000000000000..c848970708f0 --- /dev/null +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -0,0 +1,29 @@ +put('foo', 'bar', 30); + $store->touch('foo', 60); + + Carbon::setTestNow($now->addSeconds(45)); + + $this->assertSame('bar', $store->get('foo')); + } +} diff --git a/tests/Cache/CacheNullStoreTest.php b/tests/Cache/CacheNullStoreTest.php index 545c9621bc24..f30bedce39d0 100644 --- a/tests/Cache/CacheNullStoreTest.php +++ b/tests/Cache/CacheNullStoreTest.php @@ -33,4 +33,9 @@ public function testIncrementAndDecrementReturnFalse() $this->assertFalse($store->increment('foo')); $this->assertFalse($store->decrement('foo')); } + + public function testTouchReturnsFalse(): void + { + $this->assertFalse((new NullStore)->touch('foo', 30)); + } } diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php index 30a100b22e8f..38df3c62d13f 100755 --- a/tests/Cache/CacheRedisStoreTest.php +++ b/tests/Cache/CacheRedisStoreTest.php @@ -121,6 +121,19 @@ public function testStoreItemForeverProperlyCallsRedis() $this->assertTrue($result); } + public function testTouchMethodProperlyCallsRedis(): void + { + $key = 'key'; + $ttl = 60; + + $redis = $this->getRedis(); + + $redis->getRedis()->shouldReceive('connection')->once()->with('default')->andReturn($redis->getRedis()); + $redis->getRedis()->shouldReceive('expire')->once()->with("prefix:$key", $ttl)->andReturn(true); + + $this->assertTrue($redis->touch($key, $ttl)); + } + public function testForgetMethodProperlyCallsRedis() { $redis = $this->getRedis(); diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 5097a2797795..4be0094f5dc1 100755 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -433,6 +433,52 @@ public function testNonTaggableRepositoryDoesNotSupportTags() $this->assertFalse($nonTaggableRepo->supportsTags()); } + public function testTouchWithNullTTLRemembersItemForever(): void + { + $key = 'key'; + $ttl = null; + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with($key)->andReturn('bar'); + $repo->getStore()->shouldReceive('forever')->once()->with($key, 'bar')->andReturn(true); + $this->assertTrue($repo->touch($key, $ttl)); + } + + public function testTouchWithSecondsTtlCorrectlyProxiesToStore(): void + { + $key = 'key'; + $ttl = 60; + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with($key)->andReturn('bar'); + $repo->getStore()->shouldReceive('touch')->once()->with($key, $ttl)->andReturn(true); + $this->assertTrue($repo->touch($key, $ttl)); + } + + public function testTouchWithDatetimeTtlCorrectlyProxiesToStore(): void + { + $key = 'key'; + $ttl = 60; + + Carbon::setTestNow($now = Carbon::now()); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with($key)->andReturn('bar'); + $repo->getStore()->shouldReceive('touch')->once()->with($key, $ttl)->andReturn(true); + $this->assertTrue($repo->touch($key, $now->addSeconds($ttl))); + } + + public function testTouchWithDateIntervalTtlCorrectlyProxiesToStore(): void + { + $key = 'key'; + $ttl = 60; + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->with($key)->andReturn('bar'); + $repo->getStore()->shouldReceive('touch')->once()->with($key, $ttl)->andReturn(true); + $this->assertTrue($repo->touch($key, DateInterval::createFromDateString("$ttl seconds"))); + } + protected function getRepository() { $dispatcher = new Dispatcher(m::mock(Container::class)); diff --git a/tests/Integration/Cache/MemoizedStoreTest.php b/tests/Integration/Cache/MemoizedStoreTest.php index 009906f1555f..a6cd10bd17a7 100644 --- a/tests/Integration/Cache/MemoizedStoreTest.php +++ b/tests/Integration/Cache/MemoizedStoreTest.php @@ -461,6 +461,11 @@ public function forget($key) return Cache::forget(...func_get_args()); } + public function touch(string $key, int $ttl): bool + { + return Cache::touch(...func_get_args()); + } + public function flush() { return Cache::flush(...func_get_args());