Skip to content

Instantly share code, notes, and snippets.

@skrajewski
Last active April 27, 2025 21:44
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.

Revisions

  1. skrajewski revised this gist Nov 12, 2020. 1 changed file with 7 additions and 0 deletions.
    7 changes: 7 additions & 0 deletions AmbiguousElementException.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,7 @@
    <?php

    declare(strict_types=1);

    class AmbiguousElementException extends \InvalidArgumentException
    {
    }
  2. skrajewski revised this gist Nov 12, 2020. No changes.
  3. skrajewski revised this gist Nov 12, 2020. 1 changed file with 1 addition and 2 deletions.
    3 changes: 1 addition & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -18,5 +18,4 @@ $cartCollection = new SynchronizedCollection($cart, $policy);
    $newCartCollection = $cartCollection->sync($newCart, fn($newProduct, $product) => $newProduct["id"] === $product->getId());
    ```

    Ideas, comments, and improvements are welcome.

    Ideas, comments, and improvements are welcome.
  4. skrajewski revised this gist Nov 12, 2020. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -1,6 +1,6 @@
    # SynchronizedCollection

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

    ## Example usage

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

    Ideas, comments and improvements are welcome.
    Ideas, comments, and improvements are welcome.

  5. skrajewski revised this gist Nov 12, 2020. 1 changed file with 18 additions and 1 deletion.
    19 changes: 18 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -2,4 +2,21 @@

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

    Ideas, comments and improvements are welcome.
    ## Example usage

    ```php
    $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.

  6. skrajewski created this gist Nov 12, 2020.
    32 changes: 32 additions & 0 deletions DynamicSynchronizationPolicy.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,32 @@
    <?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);
    }
    }
    5 changes: 5 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,5 @@
    # SynchronizedCollection

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

    Ideas, comments and improvements are welcome.
    18 changes: 18 additions & 0 deletions SynchronizationPolicy.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,18 @@
    <?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);
    }
    62 changes: 62 additions & 0 deletions SynchronizedCollection.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,62 @@
    <?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;
    }
    }
    194 changes: 194 additions & 0 deletions SynchronizedCollectionTest.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,194 @@
    <?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);
    });
    }
    }