Skip to content

Commit

Permalink
Merge pull request #128 from acelaya-forks/feature/namespaced-store
Browse files Browse the repository at this point in the history
Create NamespacedStore to prefix lock resources from symfony/lock
  • Loading branch information
acelaya authored Sep 12, 2023
2 parents f522458 + 8fb70e8 commit 5a8bd5a
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 0 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]
### Added
* Add a `NamespaceStore` class that can be used to wrap `symfony/lock` stores and prefix key resources.

### Changed
* *Nothing*

### Deprecated
* *Nothing*

### Removed
* *Nothing*

### Fixed
* *Nothing*


## [5.5.1] - 2023-05-28
### Added
* *Nothing*
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,4 @@ $helper->publishUpdate(Update::forTopicAndPayload('some_queue', ['foo' => 'bar']
* `Paginator`: An object extending `Pagerfanta`, that makes it behave as laminas' Paginator object on regards to be able to set `-1` as the max results and get all the results in that case. It requires that you install `pagerfanta/core`.
* `DateRange`: An immutable value object wrapping two `Chronos` date objects that can be used to represent a time period between two dates.
* `IpAddress`: An immutable value object representing an IP address that can be copied into an anonymized instance which removes the last octet.
* `NamespacedStore`: A `symfony/lock` store that can wrap another store instance but making sure keys are prefixed with a namespace and namespace separator.
77 changes: 77 additions & 0 deletions src/Lock/NamespacedStore.php
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);
}
}
54 changes: 54 additions & 0 deletions test/Lock/NamespacedStoreTest.php
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'];
}
}

0 comments on commit 5a8bd5a

Please sign in to comment.