diff --git a/CHANGELOG.md b/CHANGELOG.md index ab155e3..67c8878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ### Unreleased +### v0.1.6 (2018-09-06) + +* Add AbstractArrayRepository + ### v0.1.5(2018-08-16) * Add MysqlSession session handler diff --git a/src/Repository/AbstractArrayRepository.php b/src/Repository/AbstractArrayRepository.php new file mode 100644 index 0000000..532d234 --- /dev/null +++ b/src/Repository/AbstractArrayRepository.php @@ -0,0 +1,292 @@ + + * @licence BSD-3-Clause + */ + +namespace Ingenerator\PHPUtils\Repository; + +use Doctrine\Common\Collections\Collection; +use Ingenerator\PHPUtils\Object\ObjectPropertyPopulator; +use PHPUnit\Framework\Assert; + +/** + * Base class for an array-based memory repository for use in unit tests. Provides helpers to allow + * easy and quick creation of array-backed implementations of a project-specific repository interface. + * + * @package Ingenerator\PHPUtils\Repository + */ +abstract class AbstractArrayRepository +{ + /** + * @var array + */ + protected $entities; + + /** + * @var string + */ + protected $save_log; + + protected function __construct(array $entities) + { + $this->entities = $entities; + } + + /** + * Create a repo with the provided entities. Pass entities, or arrays of properties to stub + * + * @param array|object $entity,... + * + * @return static + */ + public static function with($entity) + { + return static::withList(func_get_args()); + } + + /** + * Same as ::with, but takes an array rather than a list of method parameters + * + * @param array[] $entity_data + * + * @return static + */ + public static function withList(array $entity_data) + { + $entity_class = static::getEntityBaseClass(); + $entities = []; + foreach ($entity_data as $entity) { + if ( ! $entity instanceof $entity_class) { + $entity = static::stubEntity($entity); + } + $entities[] = $entity; + } + + return new static($entities); + } + + /** + * @return string + */ + protected static function getEntityBaseClass() + { + throw new \BadMethodCallException('Implement your own '.__METHOD__.'!'); + } + + /** + * @param array $data + * + * @return object + */ + protected static function stubEntity(array $data) + { + $class = static::getEntityBaseClass(); + $e = new $class; + ObjectPropertyPopulator::assignHash($e, $data); + return $e; + } + + /** + * @return static + */ + public static function withNothing() + { + return new static([]); + } + + /** + * Ronseal + * + * (Does what it says on the tin) + */ + protected function assertNothingSaved() + { + Assert::assertEquals( + '', + $this->save_log, + 'Expected no saved entities' + ); + } + + /** + * This, and only this, entity should have been saved + * + * @param object $entity + */ + protected function assertSavedOnly($entity) + { + Assert::assertEquals( + $this->save_log, + $this->formatSaveLog($entity), + 'Expected entity to be saved exactly once with matching data' + ); + } + + /** + * Build a save-log record to allow the class to identify what's been saved for assertions + * + * @param object $entity + * + * @return string + */ + protected function formatSaveLog($entity) + { + return sprintf( + "%s (object %s) with data:\n%s\n", + get_class($entity), + spl_object_hash($entity), + json_encode($this->entityToArray($entity), JSON_PRETTY_PRINT) + ); + } + + /** + * Creates a simple array representation of a set of entities that can be formatted as JSON + * + * Used to capture a snapshot of entity state at the time it's saved to allow later comparison + * + * @param object $entity + * @param array $seen_objects + * + * @return array + */ + protected function entityToArray($entity, & $seen_objects = []) + { + $entity_hash = spl_object_hash($entity); + if (isset($seen_objects[$entity_hash])) { + return '**RECURSION**'; + } else { + $seen_objects[$entity_hash] = TRUE; + } + + $all_props = \Closure::bind( + function ($e) { + return get_object_vars($e); + }, + NULL, + $entity + ); + $obj_identity = function ($a) { + return get_class($a).'#'.spl_object_hash($a); + }; + $result = []; + foreach ($all_props($entity) as $key => $var) { + if ( ! is_object($var)) { + $result[$key] = $var; + } elseif ($var instanceof Collection) { + $result[$key] = []; + foreach ($var as $collection_item) { + $result[$key][] = [ + $obj_identity($var) => $this->entityToArray($collection_item, $seen_objects) + ]; + } + } elseif ($var instanceof \DateTimeInterface) { + $result[$key][get_class($var)] = $var->format(\DateTime::ISO8601); + } else { + $result[$key] = [ + $obj_identity($var) => $this->entityToArray($var, $seen_objects) + ]; + } + } + + return $result; + } + + /** + * Count entities by a group value returned by the callback + * + * public function countByColour() { + * return $this->countWith( + * function (MyEntity $e) { return $e->getColour(); } + * ); + * } + * + * @param callable $callable + * + * @return int[] + */ + protected function countWith($callable) + { + $counts = []; + foreach ($this->entities as $entity) { + $group = call_user_func($callable, $entity); + $counts[$group] = isset($counts[$group]) ? ++$counts[$group] : 1; + } + return $counts; + } + + /** + * Find a single entity matching the callback (throws if non-unique or nothing matching) + * + * public function load($id) { + * return $this->loadWith(function (MyEntity $e) use ($id) { return $e->getId() === $id; }); + * } + * + * @param callable $callable + * + * @return object + */ + protected function loadWith($callable) + { + if ( ! $entity = $this->findWith($callable)) { + throw new \InvalidArgumentException('No entity matching criteria'); + } + + return $entity; + } + + /** + * Find a single entity matching the callback, or null (throws if non-unique) + * + * @param $callable + * + * @return object + */ + protected function findWith($callable) + { + $entities = $this->listWith($callable); + if (count($entities) > 1) { + throw new \UnexpectedValueException( + 'Found multiple entities : expected unique condition.' + ); + } + + return array_pop($entities); + } + + /** + * Find all entities that the callable matches (like array_filter) + * + * @param callable $callable + * + * @return object[] + */ + protected function listWith($callable) + { + $entities = []; + foreach ($this->entities as $entity) { + if (call_user_func($callable, $entity)) { + $entities[] = $entity; + } + } + + return $entities; + } + + /** + * Use this to implement repository methods that save entities. + * + * The state of all entity properties will be captured at time of save to allow verifying that + * it hasn't been subsequently modified. + * + * @param object $entity + */ + protected function saveEntity($entity) + { + $this->save_log .= $this->formatSaveLog($entity); + if ( ! in_array($entity, $this->entities, TRUE)) { + $this->entities[] = $entity; + } + } + +} diff --git a/test/unit/Repository/AbstractArrayRepositoryTest.php b/test/unit/Repository/AbstractArrayRepositoryTest.php new file mode 100644 index 0000000..e5b5d56 --- /dev/null +++ b/test/unit/Repository/AbstractArrayRepositoryTest.php @@ -0,0 +1,328 @@ + + * @licence proprietary + */ + +namespace test\unit\Ingenerator\PHPUtils\Repository; + + +use Ingenerator\PHPUtils\Object\ObjectPropertyPopulator; +use Ingenerator\PHPUtils\Repository\AbstractArrayRepository; +use PHPUnit\Framework\TestCase; + +class AbstractArrayRepositoryTest extends TestCase +{ + public function test_it_is_initialisable_with_no_entities() + { + $subject = AnyArrayRepository::withNothing(); + $this->assertInstanceOf(AnyArrayRepository::class, $subject); + $this->assertSame([], $subject->getEntities()); + } + + public function test_it_is_initialisable_with_single_entity() + { + $e = new AnyEntity; + $subject = AnyArrayRepository::with($e); + $this->assertSame([$e], $subject->getEntities()); + } + + public function test_it_is_initialisable_with_array_of_props_to_stub() + { + $subject = AnyArrayRepository::with(['prop1' => 'foo']); + $e = $subject->getEntities()[0]; + $this->assertInstanceOf(AnyEntity::class, $e); + $this->assertSame('foo', $e->getProp1()); + } + + public function test_it_is_initialisable_with_multiple_entities_and_arrays() + { + $e1 = new AnyEntity(); + $subject = AnyArrayRepository::with($e1, ['prop1' => 'e2']); + $entities = $subject->getEntities(); + $this->assertCount(2, $entities); + $this->assertSame($e1, $entities[0]); + $this->assertInstanceOf(AnyEntity::class, $entities[1]); + } + + public function test_it_is_initialisable_with_list_of_multiple_entities() + { + $e1 = new AnyEntity; + $e2 = new AnyEntity; + $subject = AnyArrayRepository::withList([$e1, $e2]); + $this->assertSame([$e1, $e2], $subject->getEntities()); + } + + /** + * @testWith [[], []] + * [[{"prop1": "foo"}, {"prop1":"bar"}, {"prop1":"foo"}], {"foo": 2, "bar": 1}] + */ + public function test_it_provides_base_layer_for_counting_entities_by_group($entities, $expect) + { + $subject = AnyArrayRepository::withList($entities); + $this->assertSame( + $expect, + $subject->countWith(function (AnyEntity $e) { return $e->getProp1(); }) + ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function test_its_load_with_throws_if_no_entity() + { + $subject = AnyArrayRepository::with(['prop1' => 'boo']); + $subject->loadWith(function (AnyEntity $e) { return $e->getProp1() === 'baz'; }); + } + + public function test_its_load_with_returns_entity() + { + $subject = AnyArrayRepository::with(['prop1' => 'bat'], ['prop1' => 'boo']); + $e = $subject->loadWith(function (AnyEntity $e) { return $e->getProp1() === 'boo'; }); + $this->assertSame($subject->getEntities()[1], $e); + } + + /** + * @expectedException \UnexpectedValueException + */ + public function test_its_load_with_throws_if_entity_not_unique() + { + $subject = AnyArrayRepository::with(['prop1' => 'bat'], ['prop1' => 'boo']); + $subject->loadWith(function (AnyEntity $e) { return TRUE; }); + } + + public function test_its_find_with_returns_null_if_no_entity() + { + $subject = AnyArrayRepository::with(['prop1' => 'boo']); + $this->assertNull( + $subject->findWith(function (AnyEntity $e) { return $e->getProp1() === 'baz'; }) + ); + } + + public function test_its_find_with_returns_entity() + { + $subject = AnyArrayRepository::with(['prop1' => 'bat'], ['prop1' => 'boo']); + $e = $subject->findWith(function (AnyEntity $e) { return $e->getProp1() === 'boo'; }); + $this->assertSame($subject->getEntities()[1], $e); + } + + /** + * @expectedException \UnexpectedValueException + */ + public function test_its_find_with_throws_if_entity_not_unique() + { + $subject = AnyArrayRepository::with(['prop1' => 'bat'], ['prop1' => 'boo']); + $subject->findWith(function (AnyEntity $e) { return TRUE; }); + } + + /** + * @testWith [[], []] + * [[{"prop1": "ok", "prop2": 0}, {"prop1": "nah-ah", "prop2": 1}, {"prop1": "ok", "prop2": 2}], [0,2]] + */ + public function test_its_list_with_returns_all_matched_entities($entities, $expect) + { + $subject = AnyArrayRepository::withList($entities); + $this->assertSame( + $expect, + array_map( + function (AnyEntity $e) { return $e->getProp2(); }, + $subject->listWith(function (AnyEntity $e) { return $e->getProp1() === 'ok'; }) + ) + ); + } + + public function test_its_save_only_adds_new_entities() + { + $e1 = new AnyEntity; + $subject = AnyArrayRepository::with($e1); + $e2 = new AnyEntity; + $subject->saveEntity($e2); + $subject->saveEntity($e1); + $this->assertSame([$e1, $e2], $subject->getEntities()); + } + + public function test_its_nothing_saved_assertion_passes_when_nothing_saved() + { + $subject = AnyArrayRepository::with(['prop1' => 'f']); + $subject->assertNothingSaved(); + } + + public function test_its_nothing_saved_assertion_fails_if_anything_saved() + { + $subject = AnyArrayRepository::withNothing(); + $subject->saveEntity(new AnyEntity); + $this->assertAssertionFails( + function () use ($subject) { + $subject->assertNothingSaved(); + } + ); + } + + protected function assertAssertionFails($callable) + { + try { + $callable(); + } catch (\RuntimeException $e) { + // Ignore it this is correct + return; + } + $this->fail('Should fail with an assertion failure'); + } + + public function test_its_saved_only_assertion_passes_when_only_one_object_saved() + { + $e1 = new AnyEntity; + $subject = AnyArrayRepository::withNothing(); + $subject->saveEntity($e1); + $subject->assertSavedOnly($e1); + } + + public function test_its_saved_only_fails_if_not_saved() + { + $e1 = new AnyEntity; + $subject = AnyArrayRepository::with($e1); + + $this->assertAssertionFails( + function () use ($subject, $e1) { + $subject->assertSavedOnly($e1); + } + ); + } + + public function test_its_saved_only_fails_if_different_object_with_same_props_saved() + { + $subject = AnyArrayRepository::with(['prop1' => 'foo'], ['prop1' => 'foo']); + $e1 = $subject->getEntities()[0]; + $e2 = $subject->getEntities()[1]; + $subject->saveEntity($e2); + + $this->assertAssertionFails( + function () use ($subject, $e1) { + $subject->assertSavedOnly($e1); + } + ); + } + + public function test_its_saved_only_fails_if_object_modified_since_save() + { + $e1 = new AnyEntity; + $subject = AnyArrayRepository::with($e1); + $subject->saveEntity($e1); + + $e1->setProp2('Bar'); + + $this->assertAssertionFails( + function () use ($subject, $e1) { + $subject->assertSavedOnly($e1); + } + ); + } + +} + + +class AnyArrayRepository extends AbstractArrayRepository +{ + /** + * @return string + */ + protected static function getEntityBaseClass() + { + return AnyEntity::class; + } + + /** + * @return array + */ + public function getEntities() + { + return $this->entities; + } + + public function assertNothingSaved() + { + parent::assertNothingSaved(); + } + + /** + * @param object $entity + */ + public function assertSavedOnly($entity) + { + parent::assertSavedOnly($entity); + } + + /** + * @param callable $callable + * + * @return int[] + */ + public function countWith($callable) + { + return parent::countWith($callable); + } + + /** + * @param callable $callable + * + * @return object + */ + public function loadWith($callable) + { + return parent::loadWith($callable); + } + + /** + * @param $callable + * + * @return object + */ + public function findWith($callable) + { + return parent::findWith($callable); + } + + /** + * @param callable $callable + * + * @return object[] + */ + public function listWith($callable) + { + return parent::listWith($callable); + } + + public function saveEntity($entity) + { + parent::saveEntity($entity); + } + + +} + +class AnyEntity +{ + protected $prop1; + protected $prop2; + + /** + * @return mixed + */ + public function getProp1() + { + return $this->prop1; + } + + /** + * @return mixed + */ + public function getProp2() + { + return $this->prop2; + } + + public function setProp2($string) + { + $this->prop2 = $string; + } +}