diff --git a/CHANGELOG b/CHANGELOG index 7bb473f3..586dbc4e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -21,6 +21,7 @@ v2.3.0 * Feat: Add `Bdf\Prime\Mapper\Attribute\DiscriminatorMap` attribute to define the discriminator map on single table inheritance in replacement of overriding properties `discriminatorColumn` and `discriminatorMap` of the mapper * Feat: Add `Bdf\Prime\Mapper\Mapper::setDiscriminatorMap()` * Change: Mapper's configurators attributes now handle inheritance (i.e. attributes declared on parent class are inherited by child classes) +* Change: Allow iterable criteria and closure filters on `EntityRepository::count()` and `EntityRelation::count()` BC Breaks * Deprecated: Returning `false` on callback of `EntityRepository::transaction()` to rollback the transaction is deprecated. Use exception instead. diff --git a/src/Entity/Model.php b/src/Entity/Model.php index d7356dcc..fbbf90d7 100755 --- a/src/Entity/Model.php +++ b/src/Entity/Model.php @@ -53,7 +53,7 @@ * @psalm-method static EntityQuery filter(\Closure $filter) * * @method static int updateBy(array $attributes, array $criteria = []) - * @method static int count(array $criteria = [], $attributes = null) + * @method static int count(iterable|callable $criteria = [], $attributes = null) * @method static bool exists(self $entity) * @method static static|null refresh(self $entity) */ diff --git a/src/Relations/EntityRelation.php b/src/Relations/EntityRelation.php index a21a8478..a40545b9 100755 --- a/src/Relations/EntityRelation.php +++ b/src/Relations/EntityRelation.php @@ -4,6 +4,7 @@ use Bdf\Prime\Connection\ConnectionInterface; use Bdf\Prime\Exception\PrimeException; +use Bdf\Prime\Query\Contract\Aggregatable; use Bdf\Prime\Query\Contract\ReadOperation; use Bdf\Prime\Query\Contract\WriteOperation; use Bdf\Prime\Query\Custom\KeyValue\KeyValueQuery; @@ -16,6 +17,7 @@ use ReflectionClass; use ReflectionException; +use function assert; use function sprintf; /** @@ -39,7 +41,6 @@ * @psalm-method R findByIdOrNew(mixed|array $pk) Get one entity by its primary key or instantiate a new one, using where clause criteria if not found in repository * @psalm-method R firstOrFail() Get the first result of the query, or throws an exception if no result * @psalm-method R firstOrNew(bool $useCriteriaAsDefault = true) Get the first result of the query, or create a new instance if no result. If $useCriteriaAsDefault is true, the where criteria will be used as default values for the new instance. - * @psalm-method int count() * * @mixin ReadCommandInterface<\Bdf\Prime\Connection\ConnectionInterface, R> * @psalm-no-seal-methods @@ -257,6 +258,31 @@ public function query(?string $queryClass = null): ReadCommandInterface return $this->relation->link($this->owner, $queryClass); } + /** + * Count number of related entities matching the criteria + * + * Usage: + * ```php + * $entity->relation('relation')->count(); // Count all entities + * $entity->relation('relation')->count(['status' => 'active']); // Count with criteria + * $entity->relation('relation')->count(fn ($query) => $query->where('status', 'active')->where('age', '>', 18)); // Count with callback + * ``` + * + * @param iterable|callable(QueryInterface):void $criteria The filtering criteria. If not set, will count all entities of the repository + * @param string|array|null $attributes The attribute(s) to count. If null, will count all (COUNT(*)) + * + * @return int + * @throws PrimeException + */ + #[ReadOperation] + public function count($criteria = [], $attributes = null): int + { + $query = $this->query()->where($criteria); + assert($query instanceof Aggregatable); + + return $query->count($attributes); + } + /** * Save the relation from an entity * diff --git a/src/Repository/EntityRepository.php b/src/Repository/EntityRepository.php index b37adf20..57e571f7 100755 --- a/src/Repository/EntityRepository.php +++ b/src/Repository/EntityRepository.php @@ -16,6 +16,7 @@ use Bdf\Prime\Exception\PrimeException; use Bdf\Prime\Mapper\Mapper; use Bdf\Prime\Mapper\Metadata; +use Bdf\Prime\Query\Contract\Aggregatable; use Bdf\Prime\Query\Contract\ReadOperation; use Bdf\Prime\Query\Contract\WriteOperation; use Bdf\Prime\Query\Custom\KeyValue\KeyValueQuery; @@ -50,6 +51,7 @@ use Doctrine\DBAL\Connection; use Exception; +use function assert; use function method_exists; use function trigger_error; @@ -576,19 +578,28 @@ public function writer(): WriterInterface } /** - * Count entity + * Count number of entities matching the criteria * - * @param array $criteria - * @param string|array|null $attributes + * Usage: + * ```php + * MyEntity::repository()->count(); // Count all entities + * MyEntity::repository()->count(['status' => 'active']); // Count with criteria + * MyEntity::repository()->count(fn ($query) => $query->where('status', 'active')->where('age', '>', 18)); // Count with callback + * ``` + * + * @param iterable|callable(QueryInterface):void $criteria The filtering criteria. If not set, will count all entities of the repository + * @param string|array|null $attributes The attribute(s) to count. If null, will count all (COUNT(*)) * * @return int * @throws PrimeException */ #[ReadOperation] - public function count(array $criteria = [], $attributes = null): int + public function count($criteria = [], $attributes = null): int { - /** @psalm-suppress UndefinedInterfaceMethod */ - return $this->builder()->where($criteria)->count($attributes); + $query = $this->queries->builder()->where($criteria); + assert($query instanceof Aggregatable); + + return $query->count($attributes); } /** diff --git a/src/Repository/RepositoryInterface.php b/src/Repository/RepositoryInterface.php index f3a685b5..20f752fc 100644 --- a/src/Repository/RepositoryInterface.php +++ b/src/Repository/RepositoryInterface.php @@ -189,11 +189,12 @@ public function writer(): WriterInterface; /** * Count entity * - * @param array $criteria + * @param array $criteria * @param string|array|null $attributes * * @return int * @throws PrimeException + * @todo Update signature in prime 3.0 to match EntityRepository::count() */ #[ReadOperation] public function count(array $criteria = [], $attributes = null): int; diff --git a/tests/Relations/FunctionnalTest.php b/tests/Relations/FunctionnalTest.php index b50263b8..09b7b077 100755 --- a/tests/Relations/FunctionnalTest.php +++ b/tests/Relations/FunctionnalTest.php @@ -18,6 +18,7 @@ use Bdf\Prime\Pack; use Bdf\Prime\CustomerPack; use Bdf\Prime\Project; +use Bdf\Prime\Query\Expression\Attribute; use Bdf\Prime\Test\TestPack; use Bdf\Prime\TestFile; use Bdf\Prime\User; @@ -357,6 +358,41 @@ public function test_eager_relation_constraints() ); } + /** + * + */ + public function test_count_on_entity_relation() + { + TestPack::pack()->nonPersist([ + 'project' => $project = new Project([ + 'id' => 1, + 'name' => 'Projet 1' + ]), + 'company' => new Company([ + 'id' => 1, + 'name' => 'Société 1' + ]) + ]) + ->nonPersist([ + 'developer' => new Developer([ + 'id' => 1, + 'name' => 'Dév 1', + 'project' => TestPack::pack()->get('project'), + 'company' => $this->getTestPack()->get('company') + ]), + 'leadDeveloper' => new Developer([ + 'id' => 2, + 'name' => 'Dév 2', + 'lead' => true, + 'project' => TestPack::pack()->get('project'), + 'company' => $this->getTestPack()->get('company') + ]), + ]); + + $this->assertSame(2, $project->relation('developers')->count()); + $this->assertSame(1, $project->relation('developers')->count(['lead' => true])); + } + /** * */ diff --git a/tests/Repository/EntityRepositoryTest.php b/tests/Repository/EntityRepositoryTest.php index 3f81dad4..959cee38 100755 --- a/tests/Repository/EntityRepositoryTest.php +++ b/tests/Repository/EntityRepositoryTest.php @@ -15,6 +15,7 @@ use Bdf\Prime\Prime; use Bdf\Prime\PrimeTestCase; use Bdf\Prime\Query\Custom\KeyValue\KeyValueQuery; +use Bdf\Prime\Query\Expression\Like; use Bdf\Prime\Query\Query; use Bdf\Prime\Relations\Exceptions\RelationNotFoundException; use Bdf\Prime\Repository\Event\AfterLoad; @@ -140,6 +141,7 @@ public function test_count() $this->assertEquals(2, Prime::repository('Bdf\Prime\TestEntity')->count()); $this->assertEquals(1, Prime::repository('Bdf\Prime\TestEntity')->count(['name :like' => '%2'])); + $this->assertEquals(1, Prime::repository('Bdf\Prime\TestEntity')->count(fn (Query $query) => $query->where('name', (new Like(2))->endsWith()))); } /** diff --git a/tests/StaticAnalysis/Query.php b/tests/StaticAnalysis/Query.php index 22457b9e..5a1fe693 100644 --- a/tests/StaticAnalysis/Query.php +++ b/tests/StaticAnalysis/Query.php @@ -138,6 +138,8 @@ public function utilityMethods(): void { $this->checkInt(Person::count()); $this->checkInt(Person::repository()->count()); + $this->checkInt(Person::repository()->count(['firstName' => 'John'])); + $this->checkInt(Person::repository()->count(fn(QueryInterface $query) => $query->where('firstName', 'John'))); $this->checkInt(Person::updateBy(['firstName' => 'XXX'], ['firstName' => 'John'])); $this->checkInt(Person::repository()->updateBy(['firstName' => 'XXX'], ['firstName' => 'John'])); $this->checkBool(Person::exists(new Person())); @@ -167,6 +169,8 @@ public function test_relation(): void $this->checkAddressCollection($relation->by('zipCode')->all()); $this->checkAddress($relation->create()); $this->checkInt($relation->count()); + $this->checkInt($relation->count(['zipCode' => '84660'])); + $this->checkInt($relation->count(fn(QueryInterface $query) => $query->where('zipCode', '84660'))); } public function test_relation_with_class(): void