Skip to content

Commit

Permalink
feat: Specification system (#38)
Browse files Browse the repository at this point in the history
- `Collection::find()`/`filter()` accept mixed `$specification`
- `DoctrineBridgeCollection::find()`/`filter()` can accept `Criteria` instance as `$specification`
- `DoctrineBridgeCollection::find()`/`filter()` can accept "specification objects"
- `EntityResult::find()`/`filter()` can accept `Criteria` instance as `$specification`
- `EntityResult::find()`/`filter()` can accept "specification objects"
- `Matchable` interface
- doctrine/orm specification interpreter
  • Loading branch information
kbond authored Mar 1, 2024
1 parent c1aab4a commit c745588
Show file tree
Hide file tree
Showing 48 changed files with 2,177 additions and 53 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"doctrine/collections": "^2.1",
"doctrine/dbal": "^2.12|^3.0",
"doctrine/doctrine-bundle": "^2.4",
"doctrine/orm": "^2.11",
"doctrine/orm": "^2.15",
"pagerfanta/pagerfanta": "^1.0|^2.0|^3.0",
"phpstan/phpstan": "^1.4",
"phpunit/phpunit": "^9.5.0",
Expand Down
15 changes: 10 additions & 5 deletions src/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Zenstruck;

use Zenstruck\Collection\ArrayCollection;
use Zenstruck\Collection\Exception\InvalidSpecification;
use Zenstruck\Collection\Page;
use Zenstruck\Collection\Pages;

Expand All @@ -28,11 +29,13 @@
interface Collection extends \IteratorAggregate, \Countable
{
/**
* @param callable(V,K):bool $predicate
* @param mixed|callable(V,K):bool $specification
*
* @return self<K,V>
*
* @throws InvalidSpecification if $specification is not valid
*/
public function filter(callable $predicate): self;
public function filter(mixed $specification): self;

/**
* @template T
Expand Down Expand Up @@ -69,12 +72,14 @@ public function first(mixed $default = null): mixed;
/**
* @template D
*
* @param callable(V,K):bool $predicate
* @param D $default
* @param mixed|callable(V,K):bool $specification
* @param D $default
*
* @return V|D
*
* @throws InvalidSpecification if $specification is not a valid specification
*/
public function find(callable $predicate, mixed $default = null): mixed;
public function find(mixed $specification, mixed $default = null): mixed;

/**
* @template T
Expand Down
11 changes: 8 additions & 3 deletions src/Collection/ArrayCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Zenstruck\Collection;

use Zenstruck\Collection;
use Zenstruck\Collection\Exception\InvalidSpecification;

/**
* @author Kevin Bond <[email protected]>
Expand Down Expand Up @@ -179,13 +180,17 @@ public function merge(iterable ...$with): self
}

/**
* @param null|callable(V,K):bool $predicate
* @param null|callable(V,K):bool $specification
*
* @return self<K,V>
*/
public function filter(?callable $predicate = null): self
public function filter(mixed $specification = null): self
{
return new self(\array_filter($this->source, $predicate, \ARRAY_FILTER_USE_BOTH));
if (null !== $specification && !\is_callable($specification)) {
throw InvalidSpecification::build($specification, self::class, 'filter', 'Only null|callable(V,K):bool is supported.');
}

return new self(\array_filter($this->source, $specification, \ARRAY_FILTER_USE_BOTH));
}

/**
Expand Down
42 changes: 39 additions & 3 deletions src/Collection/Doctrine/DoctrineBridgeCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@
use Doctrine\Common\Collections\AbstractLazyCollection;
use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection;
use Doctrine\Common\Collections\Collection as DoctrineCollection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Selectable;
use Zenstruck\Collection;
use Zenstruck\Collection\ArrayCollection;
use Zenstruck\Collection\Doctrine\Specification\CriteriaInterpreter;
use Zenstruck\Collection\IterableCollection;
use Zenstruck\Collection\Matchable;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -25,13 +29,15 @@
* @template V
* @implements Collection<K,V>
* @implements DoctrineCollection<K,V>
* @implements Matchable<K,V>
*/
final class DoctrineBridgeCollection implements Collection, DoctrineCollection
final class DoctrineBridgeCollection implements Collection, DoctrineCollection, Matchable
{
/** @use IterableCollection<K,V> */
use IterableCollection {
map as private innerMap;
reduce as private innerReduce;
find as private innerFind;
}

/** @var DoctrineCollection<K,V> */
Expand Down Expand Up @@ -72,11 +78,41 @@ public function findFirst(\Closure $p): mixed
}

/**
* @param Criteria|callable(V,K):bool $specification
*/
public function find(mixed $specification, mixed $default = null): mixed
{
if ($specification instanceof Criteria) {
return $this->filter($specification->setMaxResults(1))->first($default);
}

if (!\is_callable($specification)) {
return $this->find(CriteriaInterpreter::interpret($specification, self::class, 'find'), $default);
}

return $this->innerFind($specification, $default);
}

/**
* @param Criteria|callable(V,K):bool $specification
*
* @return self<K,V>
*/
public function filter(\Closure|callable $p): self
public function filter(mixed $specification): self
{
return new self($this->inner->filter($p(...)));
if ($this->inner instanceof Selectable && $specification instanceof Criteria) {
return new self($this->inner->matching($specification));
}

if ($this->inner instanceof Criteria) {
throw new \LogicException(\sprintf('"%s" is not an instance of "%s". Cannot use Criteria as a specification.', $this->inner::class, Selectable::class));
}

if (!\is_callable($specification)) {
return $this->filter(CriteriaInterpreter::interpret($specification, self::class, 'filter'));
}

return new self($this->inner->filter($specification(...)));
}

public function reduce(\Closure|callable $function, mixed $initial = null): mixed
Expand Down
64 changes: 64 additions & 0 deletions src/Collection/Doctrine/DoctrineSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the zenstruck/collection package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Collection\Doctrine;

use Zenstruck\Collection\Doctrine\ORM\Specification\AntiJoin;
use Zenstruck\Collection\Doctrine\ORM\Specification\Join;
use Zenstruck\Collection\Doctrine\Specification\Cache;
use Zenstruck\Collection\Doctrine\Specification\Delete;
use Zenstruck\Collection\Doctrine\Specification\Instance;
use Zenstruck\Collection\Doctrine\Specification\Unwritable;
use Zenstruck\Collection\Spec;

/**
* @author Kevin Bond <[email protected]>
*/
final class DoctrineSpec extends Spec
{
public static function readonly(): Unwritable
{
return new Unwritable();
}

public static function delete(): Delete
{
return new Delete();
}

public static function cache(?int $lifetime = null, ?string $key = null): Cache
{
return new Cache($lifetime, $key);
}

/**
* @param class-string $class
*/
public static function instanceOf(string $class): Instance
{
return new Instance($class);
}

public static function innerJoin(string $field, ?string $alias = null): Join
{
return Join::inner($field, $alias);
}

public static function leftJoin(string $field, ?string $alias = null): Join
{
return Join::left($field, $alias);
}

public static function antiJoin(string $field): AntiJoin
{
return new AntiJoin($field);
}
}
85 changes: 62 additions & 23 deletions src/Collection/Doctrine/ORM/EntityRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
use Doctrine\ORM\QueryBuilder;
use Zenstruck\Collection\Doctrine\ObjectRepository;
use Zenstruck\Collection\Doctrine\ORM\Specification\QueryBuilderInterpreter;
use Zenstruck\Collection\Exception\InvalidSpecification;

/**
Expand All @@ -34,7 +36,7 @@ public function __construct(private EntityManagerInterface $em, private string $
}

/**
* @param mixed|Criteria|array<string,mixed>|(object&callable(QueryBuilder):void) $specification
* @param mixed|Criteria|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*/
public function find(mixed $specification): ?object
{
Expand All @@ -53,41 +55,43 @@ public function find(mixed $specification): ?object
return $qb->getQuery()->getSingleResult();
}

if (\is_object($specification)) {
try {
return QueryBuilderInterpreter::interpret($specification, static::class, __FUNCTION__, $this->qb(), 'e') // @phpstan-ignore-line
->result()
->first()
;
} catch (InvalidSpecification $e) {
if (!$this->em->getMetadataFactory()->hasMetadataFor(DefaultProxyClassNameResolver::getClass($specification))) {
throw $e;
}
}
}

return $this->em()->find($this->class, $specification);
} catch (NoResultException) {
return null;
}
}

/**
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder):void) $specification
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*
* @return EntityResult<V>
*/
public function query(mixed $specification): EntityResult
{
$specification ??= [];
$qb = $this->qb();

if ($specification instanceof Criteria) {
return $qb->addCriteria($specification)->result();
}

if (\is_callable($specification) && \is_object($specification)) {
$specification($qb, 'e');

return $qb->result();
}

if (!\is_array($specification)) {
throw InvalidSpecification::build($specification, static::class, __FUNCTION__, 'Only array|Criteria|callable(QueryBuilder) supported.');
}

foreach ($specification as $field => $value) {
$qb->andWhere("e.{$field} = :{$field}")->setParameter($field, $value);
}
return $this->resultFor($specification, __FUNCTION__);
}

return $qb->result();
/**
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*
* @return EntityResult<V>
*/
public function filter(mixed $specification): EntityResult
{
return $this->resultFor($specification, __FUNCTION__);
}

public function count(): int
Expand All @@ -112,4 +116,39 @@ final protected function em(): EntityManagerInterface
{
return $this->em;
}

/**
* @return EntityResult<V>
*/
private function resultFor(mixed $specification, string $method): EntityResult
{
$specification ??= [];
$qb = $this->qb();

if ($specification instanceof Criteria) {
return $qb->addCriteria($specification)->result();
}

if (\is_callable($specification) && \is_object($specification)) {
$specification($qb, 'e');

return $qb->result();
}

if (\is_object($specification)) {
return QueryBuilderInterpreter::interpret($specification, static::class, $method, $qb, 'e') // @phpstan-ignore-line
->result()
;
}

if (!\is_array($specification)) {
throw InvalidSpecification::build($specification, static::class, $method, 'Only array|Criteria|callable(QueryBuilder) supported.');
}

foreach ($specification as $field => $value) {
$qb->andWhere("e.{$field} = :{$field}")->setParameter($field, $value);
}

return $qb->result();
}
}
14 changes: 12 additions & 2 deletions src/Collection/Doctrine/ORM/EntityRepositoryBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ trait EntityRepositoryBridge
private EntityRepository $collectionRepo;

/**
* @param mixed|Criteria|array<string,mixed>|(object&callable(QueryBuilder):void) $specification
* @param mixed|Criteria|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*/
public function find($specification, $lockMode = null, $lockVersion = null): ?object
{
Expand All @@ -38,7 +38,7 @@ public function find($specification, $lockMode = null, $lockVersion = null): ?ob
}

/**
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder):void) $specification
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*
* @return EntityResult<V>
*/
Expand All @@ -47,6 +47,16 @@ public function query(mixed $specification): EntityResult
return $this->collectionRepo()->query($specification);
}

/**
* @param Criteria|null|array<string,mixed>|(object&callable(QueryBuilder,string):void)|object $specification
*
* @return EntityResult<V>
*/
public function filter(mixed $specification): EntityResult
{
return $this->query($specification);
}

public function getIterator(): \Traversable
{
return $this->collectionRepo()->getIterator();
Expand Down
Loading

0 comments on commit c745588

Please sign in to comment.