Skip to content

Commit

Permalink
feat: add ArrayCollection that does matching() calls against the …
Browse files Browse the repository at this point in the history
…private properties, not indirectly through the getters. (#105)

* feat: add `ArrayCollection` that does `matching()` calls against the private properties, not indirectly through the getters.

* traverse parents to find private property
  • Loading branch information
priyadi authored Jul 7, 2024
1 parent aade8a0 commit f8e00af
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 7 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 66 additions & 0 deletions packages/collections-domain/src/ArrayCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/collections package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* 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<TKey,T>
*/
class ArrayCollection extends DoctrineArrayCollection
{
/**
* @psalm-return Collection<TKey,T>&Selectable<TKey,T>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/collections package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* 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);
}
}
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ parameters:
excludePaths:
- tests/doctrine/*
- tests/var/*
- packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php
ignoreErrors:

includes:
Expand Down
35 changes: 35 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,41 @@
<file name="packages/collections-common/src/Trait/PageableTrait.php" />
</errorLevel>
</MethodSignatureMismatch>
<PossiblyUndefinedArrayOffset>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</PossiblyUndefinedArrayOffset>
<MixedArgument>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MixedArgument>
<MissingClosureParamType>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MissingClosureParamType>
<MixedAssignment>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MixedAssignment>
<MixedInferredReturnType>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MixedInferredReturnType>
<MixedReturnStatement>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MixedReturnStatement>
<MixedArgumentTypeCoercion>
<errorLevel type="suppress">
<file name="packages/collections-domain/src/Internal/DirectClosureExpressionVisitor.php" />
</errorLevel>
</MixedArgumentTypeCoercion>
</issueHandlers>

<plugins>
Expand Down
Loading

0 comments on commit f8e00af

Please sign in to comment.