Skip to content

Commit

Permalink
[11.x] Reduce the number of queries with Cache::many and `Cache::pu…
Browse files Browse the repository at this point in the history
…tMany` methods in the database driver (laravel#52209)

* Adds integration test for database store multiget

* Tweaks the test

* Tweaks the test and adds the many test with expired keys

* Adds test for the putMany method

* Tweaks the test

* Reduce the number of database calls in the many methods of the database store cache driver

* Fix tests

* Make the get and put use the many and putMany methods so the logic is the same

* Adds a test for fetching many with associative arrays

* Tweaks types and fixes docblocks on non-interface methods

* formatting

---------

Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
tonysm and taylorotwell authored Jul 22, 2024
1 parent ee1166c commit 7363052
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 36 deletions.
116 changes: 95 additions & 21 deletions src/Illuminate/Cache/DatabaseStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\QueryException;
use Illuminate\Database\SqlServerConnection;
use Illuminate\Support\Arr;
use Illuminate\Support\InteractsWithTime;
use Illuminate\Support\Str;

class DatabaseStore implements LockProvider, Store
{
use InteractsWithTime, RetrievesMultipleKeys;
use InteractsWithTime;

/**
* The database connection instance.
Expand Down Expand Up @@ -98,29 +99,56 @@ public function __construct(ConnectionInterface $connection,
*/
public function get($key)
{
$prefixed = $this->prefix.$key;

$cache = $this->table()->where('key', '=', $prefixed)->first();
return $this->many([$key])[$key];
}

// If we have a cache record we will check the expiration time against current
// time on the system and see if the record has expired. If it has, we will
// remove the records from the database table so it isn't returned again.
if (is_null($cache)) {
return;
/**
* Retrieve multiple items from the cache by key.
*
* Items not found in the cache will have a null value.
*
* @return array
*/
public function many(array $keys)
{
if (count($keys) === 0) {
return [];
}

$cache = is_array($cache) ? (object) $cache : $cache;
$results = array_fill_keys($keys, null);

// First we will retrieve all of the items from the cache using their keys and
// the prefix value. Then we will need to iterate through each of the items
// and convert them to an object when they are currently in array format.
$values = $this->table()
->whereIn('key', array_map(function ($key) {
return $this->prefix.$key;
}, $keys))
->get()
->map(function ($value) {
return is_array($value) ? (object) $value : $value;
});

$currentTime = $this->currentTime();

// If this cache expiration date is past the current time, we will remove this
// item from the cache. Then we will return a null value since the cache is
// expired. We will use "Carbon" to make this comparison with the column.
if ($this->currentTime() >= $cache->expiration) {
$this->forgetIfExpired($key);
[$values, $expired] = $values->partition(function ($cache) use ($currentTime) {
return $cache->expiration > $currentTime;
});

return;
if ($expired->isNotEmpty()) {
$this->forgetManyIfExpired($expired->pluck('key')->all(), prefixed: true);
}

return $this->unserialize($cache->value);
return Arr::map($results, function ($value, $key) use ($values) {
if ($cache = $values->firstWhere('key', $this->prefix.$key)) {
return $this->unserialize($cache->value);
}

return $value;
});
}

/**
Expand All @@ -133,11 +161,30 @@ public function get($key)
*/
public function put($key, $value, $seconds)
{
$key = $this->prefix.$key;
$value = $this->serialize($value);
return $this->putMany([$key => $value], $seconds);
}

/**
* Store multiple items in the cache for a given number of seconds.
*
* @param int $seconds
* @return bool
*/
public function putMany(array $values, $seconds)
{
$serializedValues = [];

$expiration = $this->getTime() + $seconds;

return $this->table()->upsert(compact('key', 'value', 'expiration'), 'key') > 0;
foreach ($values as $key => $value) {
$serializedValues[] = [
'key' => $this->prefix.$key,
'value' => $this->serialize($value),
'expiration' => $expiration,
];
}

return $this->table()->upsert($serializedValues, 'key') > 0;
}

/**
Expand Down Expand Up @@ -309,9 +356,7 @@ public function restoreLock($name, $owner)
*/
public function forget($key)
{
$this->table()->where('key', '=', $this->prefix.$key)->delete();

return true;
return $this->forgetMany([$key]);
}

/**
Expand All @@ -321,9 +366,38 @@ public function forget($key)
* @return bool
*/
public function forgetIfExpired($key)
{
return $this->forgetManyIfExpired([$key]);
}

/**
* Remove all items from the cache.
*
* @param array $keys
* @return bool
*/
protected function forgetMany(array $keys)
{
$this->table()->whereIn('key', array_map(function ($key) {
return $this->prefix.$key;
}, $keys))->delete();

return true;
}

/**
* Remove all expired items from the given set from the cache.
*
* @param array $keys
* @param bool $prefixed
* @return bool
*/
protected function forgetManyIfExpired(array $keys, bool $prefixed = false)
{
$this->table()
->where('key', '=', $this->prefix.$key)
->whereIn('key', $prefixed ? $keys : array_map(function ($key) {
return $this->prefix.$key;
}, $keys))
->where('expiration', '<=', $this->getTime())
->delete();

Expand Down
34 changes: 20 additions & 14 deletions tests/Cache/CacheDatabaseStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,26 @@ public function testNullIsReturnedWhenItemNotFound()
$store = $this->getStore();
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table);
$table->shouldReceive('first')->once()->andReturn(null);
$table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table);
$table->shouldReceive('get')->once()->andReturn(collect([]));

$this->assertNull($store->get('foo'));
}

public function testNullIsReturnedAndItemDeletedWhenItemIsExpired()
{
$store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['forgetIfExpired'])->setConstructorArgs($this->getMocks())->getMock();
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table);
$table->shouldReceive('first')->once()->andReturn((object) ['expiration' => 1]);
$store->expects($this->once())->method('forgetIfExpired')->with($this->equalTo('foo'))->willReturn(null);

$getQuery = m::mock(stdClass::class);
$getQuery->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($getQuery);
$getQuery->shouldReceive('get')->once()->andReturn(collect([(object) ['key' => 'prefixfoo', 'expiration' => 1]]));

$deleteQuery = m::mock(stdClass::class);
$deleteQuery->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($deleteQuery);
$deleteQuery->shouldReceive('where')->once()->with('expiration', '<=', m::any())->andReturn($deleteQuery);
$deleteQuery->shouldReceive('delete')->once()->andReturnNull();

$store->getConnection()->shouldReceive('table')->twice()->with('table')->andReturn($getQuery, $deleteQuery);

$this->assertNull($store->get('foo'));
}
Expand All @@ -45,8 +51,8 @@ public function testDecryptedValueIsReturnedWhenItemIsValid()
$store = $this->getStore();
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table);
$table->shouldReceive('first')->once()->andReturn((object) ['value' => serialize('bar'), 'expiration' => 999999999999999]);
$table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table);
$table->shouldReceive('get')->once()->andReturn(collect([(object) ['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 999999999999999]]));

$this->assertSame('bar', $store->get('foo'));
}
Expand All @@ -56,8 +62,8 @@ public function testValueIsReturnedOnPostgres()
$store = $this->getPostgresStore();
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table);
$table->shouldReceive('first')->once()->andReturn((object) ['value' => base64_encode(serialize('bar')), 'expiration' => 999999999999999]);
$table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table);
$table->shouldReceive('get')->once()->andReturn(collect([(object) ['key' => 'prefixfoo', 'value' => base64_encode(serialize('bar')), 'expiration' => 999999999999999]]));

$this->assertSame('bar', $store->get('foo'));
}
Expand All @@ -68,7 +74,7 @@ public function testValueIsUpserted()
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$store->expects($this->once())->method('getTime')->willReturn(1);
$table->shouldReceive('upsert')->once()->with(['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61], 'key')->andReturnTrue();
$table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturnTrue();

$result = $store->put('foo', 'bar', 60);
$this->assertTrue($result);
Expand All @@ -80,7 +86,7 @@ public function testValueIsUpsertedOnPostgres()
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$store->expects($this->once())->method('getTime')->willReturn(1);
$table->shouldReceive('upsert')->once()->with(['key' => 'prefixfoo', 'value' => base64_encode(serialize("\0")), 'expiration' => 61], 'key')->andReturn(1);
$table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => base64_encode(serialize("\0")), 'expiration' => 61]], 'key')->andReturn(1);

$result = $store->put('foo', "\0", 60);
$this->assertTrue($result);
Expand All @@ -99,7 +105,7 @@ public function testItemsMayBeRemovedFromCache()
$store = $this->getStore();
$table = m::mock(stdClass::class);
$store->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($table);
$table->shouldReceive('where')->once()->with('key', '=', 'prefixfoo')->andReturn($table);
$table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table);
$table->shouldReceive('delete')->once();

$store->forget('foo');
Expand Down
73 changes: 72 additions & 1 deletion tests/Integration/Database/DatabaseCacheStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,77 @@ public function testForgetIfExpiredOperationShouldNotDeleteUnExpired()
$this->assertDatabaseHas($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]);
}

public function testMany()
{
$this->insertToCacheTable('first', 'a', 60);
$this->insertToCacheTable('second', 'b', 60);

$store = $this->getStore();

$this->assertEquals([
'first' => 'a',
'second' => 'b',
'third' => null,
], $store->get(['first', 'second', 'third']));

$this->assertEquals([
'first' => 'a',
'second' => 'b',
'third' => null,
], $store->many(['first', 'second', 'third']));
}

public function testManyWithExpiredKeys()
{
$this->insertToCacheTable('first', 'a', 0);
$this->insertToCacheTable('second', 'b', 60);

$this->assertEquals([
'first' => null,
'second' => 'b',
'third' => null,
], $this->getStore()->many(['first', 'second', 'third']));

$this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('first')]);
}

public function testManyAsAssociativeArray()
{
$this->insertToCacheTable('first', 'cached', 60);

$result = $this->getStore()->many([
'first' => 'aa',
'second' => 'bb',
'third',
]);

$this->assertEquals([
'first' => 'cached',
'second' => 'bb',
'third' => null,
], $result);
}

public function testPutMany()
{
$store = $this->getStore();

$store->putMany($data = [
'first' => 'a',
'second' => 'b',
], 60);

$this->assertEquals($data, $store->many(['first', 'second']));
$this->assertDatabaseHas($this->getCacheTableName(), [
'key' => $this->withCachePrefix('first'),
'value' => serialize('a'),
]);
$this->assertDatabaseHas($this->getCacheTableName(), [
'key' => $this->withCachePrefix('second'),
'value' => serialize('b'),
]);
}

public function testResolvingSQLiteConnectionDoesNotThrowExceptions()
{
$originalConfiguration = config('database');
Expand Down Expand Up @@ -203,7 +274,7 @@ protected function insertToCacheTable(string $key, $value, $ttl = 60)
->insert(
[
'key' => $this->withCachePrefix($key),
'value' => $value,
'value' => serialize($value),
'expiration' => Carbon::now()->addSeconds($ttl)->getTimestamp(),
]
);
Expand Down

0 comments on commit 7363052

Please sign in to comment.