diff --git a/src/Domain/Model/GetOrCreateResource.php b/src/Domain/Model/GetOrCreateResource.php new file mode 100644 index 0000000..6b91a2c --- /dev/null +++ b/src/Domain/Model/GetOrCreateResource.php @@ -0,0 +1,45 @@ + + */ + public static function created(object $resource): self + { + return new self($resource, created: true); + } + + /** + * @template TInstance of object + * + * @param TInstance $resource + * + * @return self + */ + public static function existing(object $resource): self + { + return new self($resource, existing: true); + } + + /** + * @param T $resource + */ + private function __construct( + public object $resource, + public bool $created = false, + public bool $existing = false, + ) { + } +} diff --git a/src/Domain/Repository/Collection.php b/src/Domain/Repository/Collection.php new file mode 100644 index 0000000..90d0cff --- /dev/null +++ b/src/Domain/Repository/Collection.php @@ -0,0 +1,19 @@ + + * @extends Selectable + */ +interface Collection extends ReadableCollection, Selectable +{ +} diff --git a/src/Domain/Repository/InMemoryCollection.php b/src/Domain/Repository/InMemoryCollection.php new file mode 100644 index 0000000..9f02fd9 --- /dev/null +++ b/src/Domain/Repository/InMemoryCollection.php @@ -0,0 +1,19 @@ + + * + * @implements Collection + */ +class InMemoryCollection extends ArrayCollection implements Collection +{ +} diff --git a/src/Domain/Repository/ReadonlyCollection.php b/src/Domain/Repository/ReadonlyCollection.php new file mode 100644 index 0000000..757a084 --- /dev/null +++ b/src/Domain/Repository/ReadonlyCollection.php @@ -0,0 +1,169 @@ + + */ +final readonly class ReadonlyCollection implements Collection +{ + /** + * @param DoctrineCollection $collection + */ + public function __construct( + private DoctrineCollection $collection, + ) { + } + + public function getIterator(): \Traversable + { + return $this->collection->getIterator(); + } + + public function count(): int + { + return $this->collection->count(); + } + + /** @phpstan-assert-if-true TValue $element */ + public function contains(mixed $element): bool + { + return $this->collection->contains($element); + } + + public function isEmpty(): bool + { + return $this->collection->isEmpty(); + } + + public function containsKey(int|string $key): bool + { + return $this->collection->containsKey($key); + } + + public function get(int|string $key): mixed + { + return $this->collection->get($key); + } + + public function getKeys(): array + { + return $this->collection->getKeys(); + } + + public function getValues(): array + { + return $this->collection->getValues(); + } + + public function toArray(): array + { + return $this->collection->toArray(); + } + + public function first(): mixed + { + return $this->collection->first(); + } + + public function last(): mixed + { + return $this->collection->last(); + } + + public function key(): int|string|null + { + return $this->collection->key(); + } + + public function current(): mixed + { + return $this->collection->current(); + } + + public function next(): mixed + { + return $this->collection->next(); + } + + public function slice(int $offset, ?int $length = null): array + { + return $this->collection->slice($offset, $length); + } + + public function exists(\Closure $p): bool + { + return $this->collection->exists($p); + } + + /** + * @return self + */ + public function filter(\Closure $p): self + { + return new self($this->collection->filter($p)); + } + + /** + * @template U of object + * + * @return self + */ + public function map(\Closure $func): self + { + /** @var DoctrineCollection $collection */ + $collection = $this->collection->map($func); + + return new self($collection); + } + + public function partition(\Closure $p): array + { + return $this->collection->partition($p); + } + + public function forAll(\Closure $p): bool + { + return $this->collection->forAll($p); + } + + /** @phpstan-assert-if-true TValue $element */ + public function indexOf(mixed $element): int|string|false + { + return $this->collection->indexOf($element); + } + + public function findFirst(\Closure $p): mixed + { + return $this->collection->findFirst($p); + } + + public function reduce(\Closure $func, mixed $initial = null): mixed + { + return $this->collection->reduce($func, $initial); + } + + /** + * @return self + */ + public function matching(Criteria $criteria): self + { + if (!$this->collection instanceof Selectable) { + throw new \LogicException(sprintf('Expected a selectable collection, got "%s".', $this->collection::class)); + } + + /** @var DoctrineCollection&Selectable $collection */ + $collection = $this->collection->matching($criteria); + + return new self($collection); + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/ORM/LazyCriteriaCollection.php b/src/Infrastructure/Persistence/Doctrine/ORM/LazyCriteriaCollection.php new file mode 100644 index 0000000..6706a19 --- /dev/null +++ b/src/Infrastructure/Persistence/Doctrine/ORM/LazyCriteriaCollection.php @@ -0,0 +1,34 @@ + + * + * @implements Collection + */ +class LazyCriteriaCollection extends BaseLazyCriteriaCollection implements Collection +{ + /** + * @template T of object + * + * @param class-string $className + * + * @return self + */ + public static function for(EntityManagerInterface $em, string $className, Criteria $criteria): self + { + /** @var self */ + return new self($em->getUnitOfWork()->getEntityPersister($className), $criteria); + } +} diff --git a/tests/Unit/Domain/Repository/ReadonlyCollectionTest.php b/tests/Unit/Domain/Repository/ReadonlyCollectionTest.php new file mode 100644 index 0000000..fa2c2f2 --- /dev/null +++ b/tests/Unit/Domain/Repository/ReadonlyCollectionTest.php @@ -0,0 +1,303 @@ +assertCount(3, $collection); + } + + #[Test] + public function emptyAndNonEmpty(): void + { + $this->assertTrue(new ReadonlyCollection(new ArrayCollection())->isEmpty()); + $this->assertFalse(new ReadonlyCollection(new ArrayCollection([new \stdClass()]))->isEmpty()); + } + + #[Test] + public function contains(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $c = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b])); + + $this->assertTrue($collection->contains($a)); + $this->assertFalse($collection->contains($c)); + } + + #[Test] + public function containsKey(): void + { + $collection = new ReadonlyCollection(new ArrayCollection(['x' => new \stdClass()])); + + $this->assertTrue($collection->containsKey('x')); + $this->assertFalse($collection->containsKey('y')); + } + + #[Test] + public function get(): void + { + $obj = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection(['x' => $obj])); + + $this->assertSame($obj, $collection->get('x')); + $this->assertNull($collection->get('y')); + } + + #[Test] + public function getKeysAndGetValues(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection(['x' => $a, 'y' => $b])); + + $this->assertSame(['x', 'y'], $collection->getKeys()); + $this->assertSame([$a, $b], $collection->getValues()); + } + + #[Test] + public function toArray(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $data = ['x' => $a, 'y' => $b]; + $collection = new ReadonlyCollection(new ArrayCollection($data)); + + $this->assertSame($data, $collection->toArray()); + } + + #[Test] + public function firstAndLast(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $c = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + $this->assertSame($a, $collection->first()); + $this->assertSame($c, $collection->last()); + } + + #[Test] + public function firstAndLastOnEmptyCollection(): void + { + $collection = new ReadonlyCollection(new ArrayCollection()); + + // @phpstan-ignore method.impossibleType + $this->assertFalse($collection->first()); + // @phpstan-ignore method.impossibleType + $this->assertFalse($collection->last()); + } + + #[Test] + public function keyAndCurrentAndNext(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b])); + + $collection->first(); + + $this->assertSame(0, $collection->key()); + $this->assertSame($a, $collection->current()); + $this->assertSame($b, $collection->next()); + $this->assertSame(1, $collection->key()); + } + + #[Test] + public function slice(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $c = new \stdClass(); + $d = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c, $d])); + + $this->assertSame([1 => $b, 2 => $c], $collection->slice(1, 2)); + } + + #[Test] + public function exists(): void + { + $a = new \stdClass(); + $a->name = 'alice'; + $b = new \stdClass(); + $b->name = 'bob'; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b])); + + $this->assertTrue($collection->exists(fn ($k, $v) => $v->name === 'alice')); + $this->assertFalse($collection->exists(fn ($k, $v) => $v->name === 'nobody')); + } + + #[Test] + public function filter(): void + { + $a = new \stdClass(); + $a->active = true; + $b = new \stdClass(); + $b->active = false; + $c = new \stdClass(); + $c->active = true; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + $filtered = $collection->filter(fn ($v) => $v->active); + + $this->assertInstanceOf(ReadonlyCollection::class, $filtered); + $this->assertSame([$a, $c], $filtered->getValues()); + } + + #[Test] + public function map(): void + { + $a = new \stdClass(); + $a->name = 'alice'; + $b = new \stdClass(); + $b->name = 'bob'; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b])); + + $mapped = $collection->map(function ($v) { + $obj = new \stdClass(); + $obj->upper = strtoupper($v->name); + + return $obj; + }); + + $this->assertInstanceOf(ReadonlyCollection::class, $mapped); + $this->assertSame('ALICE', $mapped->toArray()[0]->upper); + $this->assertSame('BOB', $mapped->toArray()[1]->upper); + } + + #[Test] + public function partition(): void + { + $a = new \stdClass(); + $a->even = false; + $b = new \stdClass(); + $b->even = true; + $c = new \stdClass(); + $c->even = false; + $d = new \stdClass(); + $d->even = true; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c, $d])); + + [$truthy, $falsy] = $collection->partition(fn ($k, $v) => $v->even); + + $this->assertSame([$b, $d], $truthy->getValues()); + $this->assertSame([$a, $c], $falsy->getValues()); + } + + #[Test] + public function forAll(): void + { + $a = new \stdClass(); + $a->valid = true; + $b = new \stdClass(); + $b->valid = true; + $c = new \stdClass(); + $c->valid = false; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + $this->assertFalse($collection->forAll(fn ($k, $v) => $v->valid)); + + $allValid = new ReadonlyCollection(new ArrayCollection([$a, $b])); + $this->assertTrue($allValid->forAll(fn ($k, $v) => $v->valid)); + } + + #[Test] + public function indexOf(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $c = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + $this->assertSame(1, $collection->indexOf($b)); + $this->assertFalse($collection->indexOf(new \stdClass())); + } + + #[Test] + public function findFirst(): void + { + $a = new \stdClass(); + $a->value = 1; + $b = new \stdClass(); + $b->value = 2; + $c = new \stdClass(); + $c->value = 3; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + $this->assertSame($c, $collection->findFirst(fn ($k, $v) => $v->value > 2)); + $this->assertNull($collection->findFirst(fn ($k, $v) => $v->value > 10)); + } + + #[Test] + public function reduce(): void + { + $a = new \stdClass(); + $a->value = 1; + $b = new \stdClass(); + $b->value = 2; + $c = new \stdClass(); + $c->value = 3; + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b, $c])); + + /** @var int $sum */ + $sum = $collection->reduce(fn (int $carry, \stdClass $v) => $carry + $v->value, 0); + + $this->assertSame(6, $sum); + } + + #[Test] + public function getIterator(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + $collection = new ReadonlyCollection(new ArrayCollection([$a, $b])); + + $values = iterator_to_array($collection); + + $this->assertSame([$a, $b], $values); + } + + #[Test] + public function matching(): void + { + $collection = new ReadonlyCollection(new ArrayCollection([ + ['name' => 'Alice'], + ['name' => 'Bob'], + ])); + + $criteria = Criteria::create()->where(Criteria::expr()->eq('name', 'Alice')); + $result = $collection->matching($criteria); + + $this->assertInstanceOf(ReadonlyCollection::class, $result); + $this->assertCount(1, $result); + } + + #[Test] + public function matchingThrowsOnNonSelectableCollection(): void + { + $inner = $this->createStub(DoctrineCollection::class); + $collection = new ReadonlyCollection($inner); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Expected a selectable collection'); + + $collection->matching(Criteria::create()); + } +}