Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CachingConverter #35

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/caching-converter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## Conversion with Caching

In some situations - especially if you are transforming objects with relation to objects - it may be helpful
to use caching to avoid conversion of same object instances again and again.

Therefore, we offer a `CachingConverter`.

Before you can directly use it you have to implement a cache key strategy for your source objects;
i.e. you have to determine how one can differentiate the source objects.

This is the task of the `CacheKeyFactory`.

### CacheKeyFactory

Maybe in our `User` example there will be a unique user ID (uuid) then your `CacheKeyFactory`
should be the following:

```php
use Neusta\ConverterBundle\Converter\Cache\CacheKeyFactory;

/**
* @implements CacheKeyFactory<User>
*/
class UserKeyFactory implements CacheKeyFactory
{
public function createCacheKey(object $source): string
{
return (string) $source->getUuid();
}
}
```

### Configuration of cached conversion

To put things together register the cache key factory as a service:

```yaml
# config/services.yaml
services:
...
YourNamespace\UserKeyFactory: ~
```

And then add it to the converter in your package config via the `cached` keyword:

```yaml
# config/packages/neusta_converter.yaml
neusta_converter:
converter:
person.converter:
...
cached:
key_factory: YourNamespace\UserKeyFactory
```

This will use the `InMemoryCache`, which is offering a simple array-based cache of converted objects
using the `key_factory` to determine the cache key. This allows you to implement very domain-specific identifications
of your object conversions.

> Note: You can also use a custom implementation of the `Cache` interface by using the `service`
> instead of the `key_factory` keyword.

## Why?!

Maybe you will ask yourself why not implement the Converter-and-Populator-pattern by yourself and use this extension
instead. The answer is quite simple:

It's a pattern, and it should be done always in the same manner so that other developers will recognize the structure
and be able to focus on the real important things:
the populations.

But if you find your "own" way you can not expect others to come into your mind asap.

Of course if you miss something here, just feel free to add it but be aware of compatibility with older
versions.
2 changes: 2 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,5 @@ if ($ctx && $ctx->hasKey('locale')) {

Internally the `GenericContext` is only an associative array but the interface allows you to adapt your own
implementation of a domain-oriented context and use it in your populators as you like.

## [Conversion with Caching](caching-converter.md)
30 changes: 30 additions & 0 deletions src/Converter/Cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Converter;

use Neusta\ConverterBundle\Converter\Cache\CacheAwareContext;

/**
* @template TSource of object
* @template TTarget of object
* @template TContext of CacheAwareContext|null
*/
interface Cache
{
/**
* @param TSource $source
* @param TContext $ctx
*
* @return TTarget|null
*/
public function get(object $source, ?CacheAwareContext $ctx = null): ?object;

/**
* @param TSource $source
* @param TTarget $target
* @param TContext $ctx
*/
public function set(object $source, object $target, ?CacheAwareContext $ctx = null): void;
}
13 changes: 13 additions & 0 deletions src/Converter/Cache/CacheAwareContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Converter\Cache;

interface CacheAwareContext
{
/**
* @return non-empty-string
*/
public function getHash(): string;
}
19 changes: 19 additions & 0 deletions src/Converter/Cache/CacheKeyFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);


namespace Neusta\ConverterBundle\Converter\Cache;

/**
* @template TSource of object
*/
interface CacheKeyFactory
{
/**
* @param TSource $source
*
* @return non-empty-string
*/
public function createCacheKeyFor(object $source): string;
}
48 changes: 48 additions & 0 deletions src/Converter/Cache/InMemoryCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Converter\Cache;

use Neusta\ConverterBundle\Converter\Cache;

/**
* @template TSource of object
* @template TTarget of object
* @template TContext of CacheAwareContext|null
*
* @implements Cache<TSource, TTarget, TContext>
*/
final class InMemoryCache implements Cache
{
/**
* @var array<string, TTarget>
*/
private array $targets = [];

/**
* @param CacheKeyFactory<TSource> $keyFactory
*/
public function __construct(
private CacheKeyFactory $keyFactory,
) {
}

public function get(object $source, ?CacheAwareContext $ctx = null): ?object
{
return $this->targets[$this->createCacheKeyFor($source, $ctx)] ?? null;
}

public function set(object $source, object $target, ?CacheAwareContext $ctx = null): void
{
$this->targets[$this->createCacheKeyFor($source, $ctx)] = $target;
}

/**
* @param TSource $source
*/
private function createCacheKeyFor(object $source, ?CacheAwareContext $ctx = null): string
{
return $this->keyFactory->createCacheKeyFor($source) . $ctx?->getHash();
}
}
47 changes: 47 additions & 0 deletions src/Converter/CachingConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Converter;

use Neusta\ConverterBundle\Converter\Cache\CacheAwareContext;
use Neusta\ConverterBundle\Converter;

/**
* @template TSource of object
* @template TTarget of object
* @template TContext of CacheAwareContext|null
*
* @implements Converter<TSource, TTarget, TContext>
*/
final class CachingConverter implements Converter
{
/**
* @param Converter<TSource, TTarget, TContext> $inner
* @param Cache<TSource, TTarget, TContext> $cache
*/
public function __construct(
private Converter $inner,
private Cache $cache,
) {
}

/**
* @param TSource $source
* @param TContext $ctx
*
* @return TTarget
*/
public function convert(object $source, ?object $ctx = null): object
{
if ($target = $this->cache->get($source, $ctx)) {
return $target;
}

$target = $this->inner->convert($source, $ctx);

$this->cache->set($source, $target, $ctx);

return $target;
}
}
32 changes: 31 additions & 1 deletion src/Converter/Context/GenericContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Neusta\ConverterBundle\Converter\Context;

class GenericContext
use Neusta\ConverterBundle\Converter\Cache\CacheAwareContext;

class GenericContext implements CacheAwareContext
{
/** @var array<string, mixed> */
protected array $values;
Expand All @@ -28,4 +30,32 @@ public function setValue(string $key, mixed $value): static

return $this;
}

/**
* Returns a hash of the values that can be used for building a cache key.
*/
public function getHash(): string
{
return md5(serialize($this->replaceObjectsWithHashes($this->values)));
}

/**
* @param array<string, mixed> $array
*
* @return array<string, mixed>
*/
private function replaceObjectsWithHashes(array $array): array
{
foreach ($array as $key => $value) {
$array[$key] = match (true) {
is_array($value) => $this->replaceObjectsWithHashes($value),
is_object($value) => spl_object_hash($value),
default => $value,
};
}

ksort($array);

return $array;
}
}
20 changes: 20 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void
->useAttributeAsKey('target')
->prototype('scalar')->end()
->end()
->arrayNode('cache')
->info('Whether the result should be cached')
->children()
->scalarNode('service')
->info('Service id to override the cache entirely')
->defaultNull()
->end()
->scalarNode('key_factory')
->info('Service id of the "CacheKeyFactory"')
->end()
->end()
->validate()
->ifTrue(fn (array $c) => isset($c['service'], $c['key_factory']))
->thenInvalid('You cannot use "service" and "key_factory" at the same time.')
->end()
->validate()
->ifTrue(fn (array $c) => !isset($c['service']) && !isset($c['key_factory']))
->thenInvalid('Either "service" or "key_factory" must be defined.')
->end()
->end()
->end()
->validate()
->ifTrue(fn (array $c) => empty($c['populators']) && empty($c['properties']))
Expand Down
18 changes: 18 additions & 0 deletions src/DependencyInjection/NeustaConverterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Neusta\ConverterBundle\DependencyInjection;

use Neusta\ConverterBundle\Converter\Cache\InMemoryCache;
use Neusta\ConverterBundle\Converter\CachingConverter;
use Neusta\ConverterBundle\Converter;
use Neusta\ConverterBundle\Populator\PropertyMappingPopulator;
use Symfony\Component\Config\FileLocator;
Expand Down Expand Up @@ -52,6 +54,22 @@ private function registerConverterConfiguration(string $id, array $config, Conta
$config['populators'],
),
]);

if (isset($config['cache'])) {
if (!$cacheId = $config['cache']['service'] ?? null) {
$container->register($cacheId = "{$id}.cache", InMemoryCache::class)
->setArguments([
'$keyFactory' => new Reference($config['cache']['key_factory']),
]);
}

$container->register("{$id}.caching_converter", CachingConverter::class)
->setDecoratedService($id)
->setArguments([
'$inner' => new Reference('.inner'),
'$cache' => new Reference($cacheId),
]);
}
}

private function appendSuffix(string $value, string $suffix): string
Expand Down
44 changes: 44 additions & 0 deletions tests/Converter/CachingConverterIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Tests\Converter;

use Neusta\ConverterBundle\Converter;
use Neusta\ConverterBundle\Converter\Context\GenericContext;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Person;
use Neusta\ConverterBundle\Tests\Fixtures\Model\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class CachingConverterIntegrationTest extends KernelTestCase
{
/** @var Converter<User, Person, GenericContext> */
private Converter $converter;

protected function setUp(): void
{
parent::setUp();
$this->converter = self::getContainer()->get('test.person.converter.with.cache');
}

public function testConvert(): void
{
// Test Fixture
$source = (new User())->setUuid(17)->setFirstname('Max')->setLastname('Mustermann');
// Test Execution
$target = $this->converter->convert($source);
// Test Assertion
self::assertEquals('Max Mustermann', $target->getFullName());
}

public function testConvertWithContext(): void
{
// Test Fixture
$source = (new User())->setUuid(17)->setFirstname('Max')->setLastname('Mustermann');
$ctx = (new GenericContext())->setValue('separator', ', ');
// Test Execution
$target = $this->converter->convert($source, $ctx);
// Test Assertion
self::assertEquals('Max, Mustermann', $target->getFullName());
}
}
Loading