-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #128 from acelaya-forks/feature/namespaced-store
Create NamespacedStore to prefix lock resources from symfony/lock
- Loading branch information
Showing
4 changed files
with
149 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Shlinkio\Shlink\Common\Lock; | ||
|
||
use Closure; | ||
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\SharedLockStoreInterface; | ||
|
||
use function sprintf; | ||
use function str_starts_with; | ||
|
||
/** | ||
* Wraps a symfony/lock store and prefixes resources with a namespace. | ||
*/ | ||
class NamespacedStore implements SharedLockStoreInterface | ||
{ | ||
public function __construct( | ||
private readonly SharedLockStoreInterface $wrappedStore, | ||
private readonly ?string $namespace = null, | ||
/** Some stores may not allow default separator value. Make sure you provide the appropriate one */ | ||
private readonly string $namespaceSeparator = ':', | ||
) { | ||
} | ||
|
||
public function save(Key $key): void | ||
{ | ||
$this->wrappedStore->save($this->namespaceKey($key)); | ||
} | ||
|
||
public function delete(Key $key): void | ||
{ | ||
$this->wrappedStore->delete($this->namespaceKey($key)); | ||
} | ||
|
||
public function exists(Key $key): bool | ||
{ | ||
return $this->wrappedStore->exists($this->namespaceKey($key)); | ||
} | ||
|
||
public function putOffExpiration(Key $key, float $ttl): void | ||
{ | ||
$this->wrappedStore->putOffExpiration($this->namespaceKey($key), $ttl); | ||
} | ||
|
||
public function saveRead(Key $key): void | ||
{ | ||
$this->wrappedStore->saveRead($this->namespaceKey($key)); | ||
} | ||
|
||
private function namespaceKey(Key $key): Key | ||
{ | ||
// If no namespace was provided, just use provided key verbatim | ||
if ($this->namespace === null) { | ||
return $key; | ||
} | ||
|
||
// If already prefixed, just use provided key verbatim | ||
$unprefixedResource = $key->__toString(); | ||
$prefix = $this->namespace . $this->namespaceSeparator; | ||
if (str_starts_with($unprefixedResource, $prefix)) { | ||
return $key; | ||
} | ||
|
||
// Sadly, the key is mutated by wrapped store, and callers take this for granted. Creating a new instance would | ||
// make the reference get detached and things stop working. | ||
// Instead, we need to mutate provided key object to make sure things keep working. | ||
// | ||
// Using Closure::bind we can run a closure using provided key as $this context, and therefore, allowing private | ||
// props to be accessed | ||
return Closure::bind(function (Key $mutableKey) use ($prefix, $unprefixedResource) { | ||
$mutableKey->resource = sprintf('%s%s', $prefix, $unprefixedResource); | ||
return $mutableKey; | ||
}, null, $key)($key); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace ShlinkioTest\Shlink\Common\Lock; | ||
|
||
use PHPUnit\Framework\Attributes\DataProvider; | ||
use PHPUnit\Framework\Attributes\Test; | ||
use PHPUnit\Framework\MockObject\MockObject; | ||
use PHPUnit\Framework\TestCase; | ||
use Shlinkio\Shlink\Common\Lock\NamespacedStore; | ||
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\SharedLockStoreInterface; | ||
|
||
class NamespacedStoreTest extends TestCase | ||
{ | ||
private MockObject & SharedLockStoreInterface $wrappedStore; | ||
|
||
public function setUp(): void | ||
{ | ||
$this->wrappedStore = $this->createMock(SharedLockStoreInterface::class); | ||
} | ||
|
||
#[Test, DataProvider('provideKeysAndNamespaces')] | ||
public function keyIsReturnedVerbatimWhenNoNamespacesIsProvided( | ||
?string $namespace, | ||
Key $key, | ||
string $expectedResource, | ||
): void { | ||
$store = new NamespacedStore($this->wrappedStore, $namespace); | ||
$methods = [ | ||
'save' => [$key], | ||
'delete' => [$key], | ||
'exists' => [$key], | ||
'putOffExpiration' => [$key, 123], | ||
'saveRead' => [$key], | ||
]; | ||
|
||
foreach ($methods as $method => $args) { | ||
$this->wrappedStore->expects($this->once())->method($method)->with( | ||
$this->callback(fn(Key $arg) => $arg->__toString() === $expectedResource), | ||
); | ||
|
||
$store->{$method}(...$args); | ||
} | ||
} | ||
|
||
public static function provideKeysAndNamespaces(): iterable | ||
{ | ||
yield 'no namespace' => [null, new Key($expectedKey = 'base_resource'), $expectedKey]; | ||
yield 'namespace already set' => ['shlink', new Key('shlink:base_resource'), 'shlink:base_resource']; | ||
yield 'namespace not set' => ['shlink', new Key('base_resource'), 'shlink:base_resource']; | ||
} | ||
} |