Skip to content

Instantly share code, notes, and snippets.

@skrajewski
Last active March 31, 2022 11:30
Show Gist options
  • Save skrajewski/2801cd20e8f41d4e3dbde74b8a6c9fa8 to your computer and use it in GitHub Desktop.
Save skrajewski/2801cd20e8f41d4e3dbde74b8a6c9fa8 to your computer and use it in GitHub Desktop.

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