diff --git a/CHANGELOG.md b/CHANGELOG.md index bc49c48..0eefe49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.8.1 + +* feat: add `ArrayCollection` that does `matching()` calls against the private + properties, not indirectly through the getters. + ## 0.8.0 * feat: `$key` parameter type widening, to accommodate object primary keys, like diff --git a/packages/collections-domain/src/ArrayCollection.php b/packages/collections-domain/src/ArrayCollection.php new file mode 100644 index 0000000..18ae4d7 --- /dev/null +++ b/packages/collections-domain/src/ArrayCollection.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Domain\Collections; + +use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Order; +use Doctrine\Common\Collections\Selectable; +use Rekalogika\Domain\Collections\Internal\DirectClosureExpressionVisitor; + +/** + * @template TKey of array-key + * @template T + * @extends DoctrineArrayCollection + */ +class ArrayCollection extends DoctrineArrayCollection +{ + /** + * @psalm-return Collection&Selectable + */ + public function matching(Criteria $criteria): Collection&Selectable + { + $expr = $criteria->getWhereExpression(); + $filtered = $this->toArray(); + + if ($expr) { + $visitor = new DirectClosureExpressionVisitor(); + /** @var \Closure(T):bool */ + $filter = $visitor->dispatch($expr); + $filtered = array_filter($filtered, $filter); + } + + $orderings = $criteria->orderings(); + + if ($orderings) { + $next = null; + foreach (array_reverse($orderings) as $field => $ordering) { + /** @var \Closure(mixed,mixed):int */ + $next = DirectClosureExpressionVisitor::sortByField($field, $ordering === Order::Descending ? -1 : 1, $next); + } + + uasort($filtered, $next); + } + + $offset = $criteria->getFirstResult(); + $length = $criteria->getMaxResults(); + + if ($offset !== null && $offset > 0 || $length !== null && $length > 0) { + $filtered = array_slice($filtered, (int) $offset, $length, true); + } + + return $this->createFrom($filtered); + } +} diff --git a/packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php b/packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php new file mode 100644 index 0000000..ce952fa --- /dev/null +++ b/packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Domain\Collections\Internal; + +use Doctrine\Common\Collections\Expr\Comparison; +use Rekalogika\Contracts\Collections\Exception\UnexpectedValueException; + +use Closure; +use Doctrine\Common\Collections\Expr\CompositeExpression; +use Doctrine\Common\Collections\Expr\ExpressionVisitor; +use Doctrine\Common\Collections\Expr\Value; +use RuntimeException; + +use function explode; +use function in_array; +use function is_array; +use function is_scalar; +use function iterator_to_array; +use function str_contains; +use function str_ends_with; +use function str_starts_with; + +/** + * Copied from ClosureExpressionVisitor from doctrine/collections package. + */ +class DirectClosureExpressionVisitor extends ExpressionVisitor +{ + /** + * Accesses the field of a given object. This field has to be public + * directly or indirectly (through an accessor get*, is*, or a magic + * method, __get, __call). + * + * @param object|mixed[] $object + * + * @return mixed + */ + public static function getObjectFieldValue(object|array $object, string $field) + { + if (str_contains($field, '.')) { + [$field, $subField] = explode('.', $field, 2); + $object = self::getObjectFieldValue($object, $field); + + return self::getObjectFieldValue($object, $subField); + } + + if (is_array($object)) { + return $object[$field]; + } + + $reflection = new \ReflectionObject($object); + + do { + if ($reflection->hasProperty($field)) { + $property = $reflection->getProperty($field); + $property->setAccessible(true); + + return $property->getValue($object); + } + } while ($reflection = $reflection->getParentClass()); + + throw new UnexpectedValueException('Unknown field ' . $field); + } + + /** + * Helper for sorting arrays of objects based on multiple fields + orientations. + * + * @return Closure + */ + public static function sortByField(string $name, int $orientation = 1, Closure|null $next = null) + { + if (!$next) { + $next = static fn (): int => 0; + } + + return static function ($a, $b) use ($name, $next, $orientation): int { + $aValue = static::getObjectFieldValue($a, $name); + + $bValue = static::getObjectFieldValue($b, $name); + + if ($aValue === $bValue) { + return $next($a, $b); + } + + return ($aValue > $bValue ? 1 : -1) * $orientation; + }; + } + + /** + * {@inheritDoc} + */ + public function walkComparison(Comparison $comparison) + { + $field = $comparison->getField(); + $value = $comparison->getValue()->getValue(); + + return match ($comparison->getOperator()) { + Comparison::EQ => static fn ($object): bool => static::getObjectFieldValue($object, $field) === $value, + Comparison::NEQ => static fn ($object): bool => static::getObjectFieldValue($object, $field) !== $value, + Comparison::LT => static fn ($object): bool => static::getObjectFieldValue($object, $field) < $value, + Comparison::LTE => static fn ($object): bool => static::getObjectFieldValue($object, $field) <= $value, + Comparison::GT => static fn ($object): bool => static::getObjectFieldValue($object, $field) > $value, + Comparison::GTE => static fn ($object): bool => static::getObjectFieldValue($object, $field) >= $value, + Comparison::IN => static function ($object) use ($field, $value): bool { + $fieldValue = static::getObjectFieldValue($object, $field); + + return in_array($fieldValue, $value, is_scalar($fieldValue)); + }, + Comparison::NIN => static function ($object) use ($field, $value): bool { + $fieldValue = static::getObjectFieldValue($object, $field); + + return !in_array($fieldValue, $value, is_scalar($fieldValue)); + }, + Comparison::CONTAINS => static fn ($object): bool => str_contains((string) static::getObjectFieldValue($object, $field), (string) $value), + Comparison::MEMBER_OF => static function ($object) use ($field, $value): bool { + $fieldValues = static::getObjectFieldValue($object, $field); + + if (!is_array($fieldValues)) { + $fieldValues = iterator_to_array($fieldValues); + } + + return in_array($value, $fieldValues, true); + }, + Comparison::STARTS_WITH => static fn ($object): bool => str_starts_with((string) static::getObjectFieldValue($object, $field), (string) $value), + Comparison::ENDS_WITH => static fn ($object): bool => str_ends_with((string) static::getObjectFieldValue($object, $field), (string) $value), + default => throw new RuntimeException('Unknown comparison operator: ' . $comparison->getOperator()), + }; + } + + /** + * {@inheritDoc} + */ + public function walkValue(Value $value) + { + return $value->getValue(); + } + + /** + * {@inheritDoc} + */ + public function walkCompositeExpression(CompositeExpression $expr) + { + $expressionList = []; + + foreach ($expr->getExpressionList() as $child) { + $expressionList[] = $this->dispatch($child); + } + + return match ($expr->getType()) { + CompositeExpression::TYPE_AND => $this->andExpressions($expressionList), + CompositeExpression::TYPE_OR => $this->orExpressions($expressionList), + CompositeExpression::TYPE_NOT => $this->notExpression($expressionList), + default => throw new RuntimeException('Unknown composite ' . $expr->getType()), + }; + } + + /** @param callable[] $expressions */ + private function andExpressions(array $expressions): Closure + { + return static function ($object) use ($expressions): bool { + foreach ($expressions as $expression) { + if (!$expression($object)) { + return false; + } + } + + return true; + }; + } + + /** @param callable[] $expressions */ + private function orExpressions(array $expressions): Closure + { + return static function ($object) use ($expressions): bool { + foreach ($expressions as $expression) { + if ($expression($object)) { + return true; + } + } + + return false; + }; + } + + /** @param callable[] $expressions */ + private function notExpression(array $expressions): Closure + { + return static fn ($object) => !$expressions[0]($object); + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b09e96c..25df295 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -13,6 +13,7 @@ parameters: excludePaths: - tests/doctrine/* - tests/var/* + - packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php ignoreErrors: includes: diff --git a/psalm.xml b/psalm.xml index f173828..900f479 100644 --- a/psalm.xml +++ b/psalm.xml @@ -80,6 +80,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/src/UnitTests/Collections/ArrayCollectionTest.php b/tests/src/UnitTests/Collections/ArrayCollectionTest.php new file mode 100644 index 0000000..8d200a1 --- /dev/null +++ b/tests/src/UnitTests/Collections/ArrayCollectionTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Collections\Tests\UnitTests\Collections; + +use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection; +use Doctrine\Common\Collections\Criteria; +use PHPUnit\Framework\TestCase; +use Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures\Citizen; +use Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures\Country; +use Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures\NullCountry; +use Rekalogika\Domain\Collections\ArrayCollection; + +class ArrayCollectionTest extends TestCase +{ + /** + * @return array + */ + private static function createArray(): array + { + $country = new Country('Khemed'); + + return [ + new Citizen(3, 'John Doe', $country), + new Citizen(2, 'Jane Doe', $country), + new Citizen(1, 'John Smith'), + ]; + } + + public function testDoctrineArrayCollection(): void + { + $citizens = new DoctrineArrayCollection(self::createArray()); + $statelessCriteria = Criteria::create() + ->where(Criteria::expr()->isNull('country')); + + $statelessCitizens = $citizens->matching($statelessCriteria); + static::assertCount(0, $statelessCitizens); // wrong count + } + + public function testOurArrayCollection(): void + { + $citizens = new ArrayCollection(self::createArray()); + $statelessCriteria = Criteria::create() + ->where(Criteria::expr()->isNull('country')); + + $statelessCitizens = $citizens->matching($statelessCriteria); + static::assertCount(1, $statelessCitizens); + } + + public function testParentPrivateProperty(): void + { + $citizens = new ArrayCollection([ + new Country('Khemed'), + new Country('San Theodoros'), + new Country('Borduria'), + new Country('Syldavia'), + new NullCountry() + ]); + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('foo', 'bar')); + + $result = $citizens->matching($criteria); + static::assertCount(5, $result); + } +} diff --git a/tests/src/UnitTests/Collections/CollectionTest.php b/tests/src/UnitTests/Collections/CollectionTest.php index f967271..784a7cd 100644 --- a/tests/src/UnitTests/Collections/CollectionTest.php +++ b/tests/src/UnitTests/Collections/CollectionTest.php @@ -16,6 +16,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Tests\Common\Collections\ArrayCollectionTestCase; +use Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures\Citizen; use Rekalogika\Domain\Collections\Common\Count\DelegatedCountStrategy; use Rekalogika\Domain\Collections\RecollectionDecorator; diff --git a/tests/src/UnitTests/Collections/CountTest.php b/tests/src/UnitTests/Collections/CountTest.php index 8fd2c14..58a697e 100644 --- a/tests/src/UnitTests/Collections/CountTest.php +++ b/tests/src/UnitTests/Collections/CountTest.php @@ -15,6 +15,7 @@ use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; +use Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures\Citizen; use Rekalogika\Domain\Collections\Common\Count\DelegatedCountStrategy; use Rekalogika\Domain\Collections\Common\Count\DisabledCountStrategy; use Rekalogika\Domain\Collections\Common\Count\PrecountingStrategy; diff --git a/tests/src/UnitTests/Collections/Fixtures/Citizen.php b/tests/src/UnitTests/Collections/Fixtures/Citizen.php new file mode 100644 index 0000000..4e2f972 --- /dev/null +++ b/tests/src/UnitTests/Collections/Fixtures/Citizen.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures; + +class Citizen +{ + public function __construct( + private int $id, + private string $name, + private ?Country $country = null, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getCountry(): Country + { + return $this->country ?? new NullCountry(); + } +} diff --git a/tests/src/UnitTests/Collections/Citizen.php b/tests/src/UnitTests/Collections/Fixtures/Country.php similarity index 72% rename from tests/src/UnitTests/Collections/Citizen.php rename to tests/src/UnitTests/Collections/Fixtures/Country.php index a41abaf..3725de2 100644 --- a/tests/src/UnitTests/Collections/Citizen.php +++ b/tests/src/UnitTests/Collections/Fixtures/Country.php @@ -11,23 +11,24 @@ * that was distributed with this source code. */ -namespace Rekalogika\Collections\Tests\UnitTests\Collections; +namespace Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures; -class Citizen +class Country { + private string $foo = 'bar'; + public function __construct( - private int $id, private string $name, ) { } - public function getId(): int + public function getName(): string { - return $this->id; + return $this->name; } - public function getName(): string + public function x(): string { - return $this->name; + return $this->foo; } } diff --git a/tests/src/UnitTests/Collections/Fixtures/NullCountry.php b/tests/src/UnitTests/Collections/Fixtures/NullCountry.php new file mode 100644 index 0000000..f0011a4 --- /dev/null +++ b/tests/src/UnitTests/Collections/Fixtures/NullCountry.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Collections\Tests\UnitTests\Collections\Fixtures; + +class NullCountry extends Country +{ + public function __construct() + { + parent::__construct('None'); + } +}