Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

SynchronizedCollection

This is the code of the component called SynchronizedCollection described in the article https://szymonkrajewski.pl/synchronized-collection/.

Example usage

$cart = $this->getCart();
$newCart = $request->request->get("cart");

$policy = new DynamicSynchronizationPolicy(
    fn($data) => $cart->addProduct(new ProductRef($data["id"], $data["quantity"])),
    fn($origin, $data) => $origin->setQuantity($data["quantity"]),
    fn($origin) => $cart->removeProduct($origin)
};

$cartCollection = new SynchronizedCollection($cart, $policy);
$newCartCollection = $cartCollection->sync($newCart, fn($newProduct, $product) => $newProduct["id"] === $product->getId());

Ideas, comments, and improvements are welcome.

<?php
declare(strict_types=1);
class AmbiguousElementException extends \InvalidArgumentException
{
}
<?php
declare(strict_types=1);
final class DynamicSynchronizationPolicy implements SynchronizationPolicy
{
private $addCallback;
private $updateCallback;
private $removeCallback;
public function __construct(Callable $addCallback, Callable $updateCallback, Callable $removeCallback)
{
$this->addCallback = $addCallback;
$this->updateCallback = $updateCallback;
$this->removeCallback = $removeCallback;
}
public function handleAdd($newData)
{
return call_user_func($this->addCallback, $newData);
}
public function handleUpdate($origin, $updatedData)
{
return call_user_func($this->updateCallback, $origin, $updatedData);
}
public function handleRemove($origin)
{
call_user_func($this->removeCallback, $origin);
}
}
<?php
declare(strict_types=1);
interface SynchronizationPolicy
{
/**
* @return mixed added element
*/
public function handleAdd($data);
/**
* @return mixed updated element
*/
public function handleUpdate($origin, $data);
public function handleRemove($origin);
}
<?php
declare(strict_types=1);
class SynchronizedCollection
{
private array $collection;
private SynchronizationPolicy $policy;
public function __construct(array $collection, SynchronizationPolicy $policy)
{
$this->collection = $collection;
$this->policy = $policy;
}
/**
* @param array $elements Array of elements to synchronize
* @param callable $matcher Function that match element from collection to coresponding element
* @return $this
*/
public function sync(array $elements, Callable $matcher): self
{
$copiedCollection = $this->collection;
foreach ($this->collection as $key => $origin) {
// find updated version of origin in provided array
$updatedVersion = array_filter($elements, fn($element) => $matcher($element, $origin));
// if origin is not found, then handle removal
if (count($updatedVersion) === 0) {
$this->policy->handleRemove($origin);
unset($copiedCollection[$key]);
continue;
}
// if origin is found then handle update
if (count($updatedVersion) === 1) {
$index = array_key_first($updatedVersion);
$copiedCollection[$key] = $this->policy->handleUpdate($origin, reset($updatedVersion));
unset($elements[$index]);
continue;
}
// if origin is matched against more than one element, then throw exception
throw new AmbiguousElementException("Provided array contains ambiguous element.");
}
array_walk($elements, function ($addData) use (&$copiedCollection)
$copiedCollection[] = $this->policy->handleAdd($addData);
});
return new static(array_values($copiedCollection), $this->policy);
}
public function toArray(): array
{
return $this->collection;
}
}
<?php
use Codeception\Stub\Expected;
use Codeception\Test\Unit;
class SynchronizedCollectionTest extends TestUnit
{
/**
* @var UnitTester
*/
protected $tester;
/**
* @test
*/
public function itShouldReturnEntryCollectionIfAnotherCollectionIsTheSame()
{
$entry = [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::never(),
'handleUpdate' => Expected::exactly(2, fn ($origin, $updateData) => array_merge($origin, $updateData)),
'handleRemove' => Expected::never(),
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$synchronized = $collection->sync([
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
], $matcher);
$this->assertEquals([
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
], $synchronized->toArray());
}
/**
* @test
*/
public function itShouldAddNewElementToSynchronizedCollection()
{
$entry = [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => isset($prop['id']) && $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::once(fn ($newData) => array_merge(['id' => 3], $newData)),
'handleUpdate' => Expected::exactly(2, fn ($origin, $updateData) => array_merge($origin, $updateData)),
'handleRemove' => Expected::never(),
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$synchronized = $collection->sync([
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane'],
['name' => 'Marry']
], $matcher);
$this->assertEquals([
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane'],
['id' => 3, 'name' => 'Marry'],
], $synchronized->toArray());
}
/**
* @test
*/
public function itShouldRemoveExistingElementFromSynchronizedCollection()
{
$entry = [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => isset($prop['id']) && $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::never(),
'handleUpdate' => Expected::exactly(1, fn ($origin, $updateData) => array_merge($origin, $updateData)),
'handleRemove' => Expected::once(),
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$synchronized = $collection->sync([
['id' => 2, 'name' => 'Wane']
], $matcher);
$this->assertEquals([
['id' => 2, 'name' => 'Wane']
], $synchronized->toArray());
}
/**
* @test
*/
public function itShouldAddNewElementAndRemoveExistingElementFromSynchronizedCollection()
{
$entry = [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => isset($prop['id']) && $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::once(fn ($newData) => array_merge(['id' => 3], $newData)),
'handleUpdate' => Expected::exactly(1, fn ($origin, $updateData) => array_merge($origin, $updateData)),
'handleRemove' => Expected::once(),
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$synchronized = $collection->sync([
['name' => 'Tim'],
['id' => 2, 'name' => 'Wane']
], $matcher);
$this->assertEquals([
['id' => 2, 'name' => 'Wane'],
['id' => 3, 'name' => 'Tim']
], $synchronized->toArray());
}
/**
* @test
*/
public function itShouldUpdateElementAndAddNewElementAndRemoveExistingElementFromSynchronizedCollection()
{
$entry = [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => isset($prop['id']) && $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::once(fn ($newData) => array_merge(['id' => 3], $newData)),
'handleUpdate' => Expected::exactly(1, fn ($origin, $updateData) => array_merge($origin, $updateData)),
'handleRemove' => Expected::once(),
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$synchronized = $collection->sync([
['name' => 'Tim'],
['id' => 2, 'name' => 'Wane_UPDATED']
], $matcher);
$this->assertEquals([
['id' => 2, 'name' => 'Wane_UPDATED'],
['id' => 3, 'name' => 'Tim']
], $synchronized->toArray());
}
/**
* @test
*/
public function itShouldThrowExceptionIfProvidedArrayContainsAmbiguousElement()
{
$entry = [
['id' => 2, 'name' => 'Wane']
];
$matcher = fn($prop, $origin) => isset($prop['id']) && $prop['id'] === $origin['id'];
$mockedPolicy = $this->makeEmpty(SynchronizationPolicy::class, [
'handleAdd' => Expected::never(),
'handleUpdate' => Expected::never(),
'handleRemove' => Expected::never()
]);
$collection = new SynchronizedCollection($entry, $mockedPolicy);
$this->tester->expectThrowable(AmbiguousElementException::class, function () use ($collection, $matcher) {
$collection->sync([
['id' => 2, 'name' => 'Wane'],
['id' => 2, 'name' => 'Tim']
], $matcher);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment