diff --git a/src/Doctrine/DoctrineExpressionApplier.php b/src/Doctrine/DoctrineExpressionApplier.php new file mode 100644 index 0000000..35b38a1 --- /dev/null +++ b/src/Doctrine/DoctrineExpressionApplier.php @@ -0,0 +1,234 @@ +parameterIndex = 0; + + $doctrineExpression = $this->applyExpression( + queryBuilder: $queryBuilder, + expression: $expression->root, + definition: $definition, + entityManager: $entityManager, + entityClass: $entityClass, + ); + + if (null !== $doctrineExpression) { + $queryBuilder->andWhere($doctrineExpression); + } + } + + /** + * @param class-string $entityClass + */ + private function applyExpression( + QueryBuilder $queryBuilder, + ExpressionInterface $expression, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + if ($expression instanceof Group) { + return $this->applyGroup($queryBuilder, $expression, $definition, $entityManager, $entityClass); + } + + if ($expression instanceof Condition) { + return $this->applyCondition($queryBuilder, $expression, $definition, $entityManager, $entityClass); + } + + return null; + } + + /** + * @param class-string $entityClass + */ + private function applyGroup( + QueryBuilder $queryBuilder, + Group $group, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + $expressions = []; + + foreach ($group->children as $child) { + $doctrineExpression = $this->applyExpression($queryBuilder, $child, $definition, $entityManager, $entityClass); + + if (null !== $doctrineExpression) { + $expressions[] = $doctrineExpression; + } + } + + if ([] === $expressions) { + return null; + } + + if (1 === count($expressions)) { + return $expressions[0]; + } + + /** @var array $expressions */ + return match ($group->logic) { + LogicOperator::And => $queryBuilder->expr()->andX(...$expressions), + LogicOperator::Or => $queryBuilder->expr()->orX(...$expressions), + }; + } + + /** + * @param class-string $entityClass + */ + private function applyCondition( + QueryBuilder $queryBuilder, + Condition $condition, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + try { + $reference = $this->fieldReferenceResolver->normalize($condition->field, $definition); + } catch (\InvalidArgumentException) { + return null; + } + + if (!$this->fieldMetadataResolver->hasField($entityManager, $entityClass, $definition, $reference)) { + return null; + } + + $field = $reference->toString(); + $parameterName = sprintf('advanced_filter_%d', $this->parameterIndex++); + + return match ($condition->operator) { + ComparisonOperator::Equals => $this->createEqExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::NotEquals => $this->createNeqExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::GreaterThan => $this->createGtExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::GreaterThanOrEquals => $this->createGteExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::LessThan => $this->createLtExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::LessThanOrEquals => $this->createLteExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::In => $this->createInExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::NotIn => $this->createNotInExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::IsNull => $queryBuilder->expr()->isNull($field), + ComparisonOperator::IsNotNull => $queryBuilder->expr()->isNotNull($field), + ComparisonOperator::Between => $this->createBetweenExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::StartsWith => $this->createLikeExpression($queryBuilder, $field, $parameterName, (is_scalar($condition->value) ? (string) $condition->value : '').'%'), + ComparisonOperator::EndsWith => $this->createLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '')), + ComparisonOperator::Contains => $this->createLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '').'%'), + ComparisonOperator::NotContains => $this->createNotLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '').'%'), + }; + } + + private function createEqExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->eq($field, ':'.$parameterName); + } + + private function createNeqExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->neq($field, ':'.$parameterName); + } + + private function createGtExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->gt($field, ':'.$parameterName); + } + + private function createGteExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->gte($field, ':'.$parameterName); + } + + private function createLtExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->lt($field, ':'.$parameterName); + } + + private function createLteExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->lte($field, ':'.$parameterName); + } + + private function createInExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->in($field, ':'.$parameterName); + } + + private function createNotInExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->notIn($field, ':'.$parameterName); + } + + private function createBetweenExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): ?string + { + if (!is_array($value) || 2 !== count($value)) { + return null; + } + + $startParam = $parameterName.'_start'; + $endParam = $parameterName.'_end'; + + $queryBuilder->setParameter($startParam, $value[0]); + $queryBuilder->setParameter($endParam, $value[1]); + + return $queryBuilder->expr()->between($field, ':'.$startParam, ':'.$endParam); + } + + private function createLikeExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, string $pattern): object + { + $queryBuilder->setParameter($parameterName, $pattern); + + return $queryBuilder->expr()->like(sprintf('LOWER(%s)', $field), sprintf('LOWER(:%s)', $parameterName)); + } + + private function createNotLikeExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, string $pattern): object + { + $queryBuilder->setParameter($parameterName, $pattern); + + return $queryBuilder->expr()->notLike(sprintf('LOWER(%s)', $field), sprintf('LOWER(:%s)', $parameterName)); + } +} diff --git a/src/Provider/DoctrineOrmDataProvider.php b/src/Provider/DoctrineOrmDataProvider.php index 4228242..c8a2378 100644 --- a/src/Provider/DoctrineOrmDataProvider.php +++ b/src/Provider/DoctrineOrmDataProvider.php @@ -15,6 +15,7 @@ use Zhortein\DatatableBundle\Definition\FilterDefinition; use Zhortein\DatatableBundle\Definition\UserFilterDefinition; use Zhortein\DatatableBundle\Doctrine\DoctrineCountExpressionFactory; +use Zhortein\DatatableBundle\Doctrine\DoctrineExpressionApplier; use Zhortein\DatatableBundle\Doctrine\DoctrineFieldMetadataResolver; use Zhortein\DatatableBundle\Doctrine\DoctrineFieldReferenceResolver; use Zhortein\DatatableBundle\Doctrine\DoctrineJoinApplier; @@ -39,6 +40,8 @@ private DoctrineCountExpressionFactory $countExpressionFactory; + private DoctrineExpressionApplier $expressionApplier; + public function __construct( private ManagerRegistry $managerRegistry, ?DoctrineFieldReferenceResolver $fieldReferenceResolver = null, @@ -46,12 +49,17 @@ public function __construct( ?DoctrineJoinApplier $joinApplier = null, ?DoctrinePaginationApplier $paginationApplier = null, ?DoctrineCountExpressionFactory $countExpressionFactory = null, + ?DoctrineExpressionApplier $expressionApplier = null, ) { $this->fieldReferenceResolver = $fieldReferenceResolver ?? new DoctrineFieldReferenceResolver(); $this->fieldMetadataResolver = $fieldMetadataResolver ?? new DoctrineFieldMetadataResolver(); $this->joinApplier = $joinApplier ?? new DoctrineJoinApplier(); $this->paginationApplier = $paginationApplier ?? new DoctrinePaginationApplier(); $this->countExpressionFactory = $countExpressionFactory ?? new DoctrineCountExpressionFactory(); + $this->expressionApplier = $expressionApplier ?? new DoctrineExpressionApplier( + fieldReferenceResolver: $this->fieldReferenceResolver, + fieldMetadataResolver: $this->fieldMetadataResolver, + ); } public function supports(DatatableDefinition $definition): bool @@ -72,7 +80,7 @@ public function getData(DatatableDefinition $definition, DatatableRequest $reque $rows = $this->loadRows($entityManager, $entityClass, $selectedColumns, $definition, $request); $totalItems = $this->countRows($entityManager, $entityClass, $definition); - $filteredItems = $request->hasSearchQuery() || $request->hasFilters() + $filteredItems = $request->hasSearchQuery() || $request->hasFilters() || $request->hasAdvancedFilters() ? $this->countRows($entityManager, $entityClass, $definition, $request) : $totalItems; @@ -163,6 +171,7 @@ private function loadRows( $this->applyPermanentFilters($queryBuilder, $definition); $this->applyUserFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); + $this->applyAdvancedFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySearch($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySorting($queryBuilder, $entityManager, $entityClass, $definition, $request); @@ -199,6 +208,7 @@ private function countRows( if (null !== $request) { $this->applyUserFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); + $this->applyAdvancedFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySearch($queryBuilder, $entityManager, $entityClass, $definition, $request); } @@ -318,6 +328,35 @@ private function applyUserFilters( } } + /** + * @param class-string $entityClass + */ + private function applyAdvancedFilters( + QueryBuilder $queryBuilder, + EntityManagerInterface $entityManager, + string $entityClass, + DatatableDefinition $definition, + DatatableRequest $request, + ): void { + if (!$request->hasAdvancedFilters()) { + return; + } + + $expression = $request->getAdvancedFilterExpression(); + + if (null === $expression) { + return; + } + + $this->expressionApplier->apply( + queryBuilder: $queryBuilder, + expression: $expression, + definition: $definition, + entityManager: $entityManager, + entityClass: $entityClass, + ); + } + /** * @param class-string $entityClass */ diff --git a/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php b/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php new file mode 100644 index 0000000..cfc485a --- /dev/null +++ b/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php @@ -0,0 +1,303 @@ +bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('email', ComparisonOperator::Equals, 'alice@example.test'), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(3, $result->getTotalItems()); + self::assertSame(1, $result->getFilteredItems()); + self::assertSame(['alice@example.test'], array_column($result->getRows(), 'e_email')); + } + + public function test_it_applies_and_condition(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('enabled', ComparisonOperator::Equals, true), + new Condition('displayName', ComparisonOperator::Contains, 'Charlie'), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(1, $result->getFilteredItems()); + self::assertSame(['charlie@example.test'], array_column($result->getRows(), 'e_email')); + } + + public function test_it_applies_or_condition(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::Or, [ + new Condition('email', ComparisonOperator::Equals, 'alice@example.test'), + new Condition('email', ComparisonOperator::Equals, 'bob@example.test'), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(2, $result->getFilteredItems()); + $emails = array_column($result->getRows(), 'e_email'); + sort($emails); + self::assertSame(['alice@example.test', 'bob@example.test'], $emails); + } + + public function test_it_applies_nested_conditions(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('enabled', ComparisonOperator::Equals, true), + new Group(LogicOperator::Or, [ + new Condition('displayName', ComparisonOperator::Equals, 'Alice'), + new Condition('displayName', ComparisonOperator::Equals, 'Charlie'), + ]), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(2, $result->getFilteredItems()); + } + + public function test_it_applies_various_operators(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $cases = [ + [ComparisonOperator::NotEquals, 'alice@example.test', 2], + [ComparisonOperator::StartsWith, 'ali', 1], + [ComparisonOperator::EndsWith, '.test', 3], + [ComparisonOperator::Contains, 'example', 3], + [ComparisonOperator::NotContains, 'alice', 2], + [ComparisonOperator::In, ['alice@example.test', 'bob@example.test'], 2], + [ComparisonOperator::NotIn, ['alice@example.test'], 2], + [ComparisonOperator::IsNull, null, 0], + [ComparisonOperator::IsNotNull, null, 3], + ]; + + foreach ($cases as [$operator, $value, $expectedCount]) { + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('email', $operator, $value), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame($expectedCount, $result->getFilteredItems(), sprintf('Failed for operator %s', $operator->value)); + } + } + + public function test_it_applies_between_operator(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('id', ComparisonOperator::Between, [1, 2]), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(2, $result->getFilteredItems()); + } + + public function test_it_applies_joined_field_condition(): void + { + $this->bootDoctrineAndLoadFixtures(); + + $expression = new AdvancedFilterExpression( + new Group(LogicOperator::And, [ + new Condition('organization.name', ComparisonOperator::Equals, 'Acme Corp'), + ]) + ); + + $result = $this->createProvider()->getData( + $this->createDefinition(), + DatatableRequest::create(advancedFilterExpression: $expression), + ); + + self::assertSame(3, $result->getFilteredItems()); + } + + #[After] + protected function cleanupDoctrine(): void + { + if (!$this->entityManager instanceof EntityManagerInterface) { + return; + } + + $entityManager = $this->entityManager; + + $this->dropSchema(); + $entityManager->close(); + $this->entityManager = null; + } + + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + private function createProvider(): DoctrineOrmDataProvider + { + $managerRegistry = self::getContainer()->get('doctrine'); + + self::assertInstanceOf(\Doctrine\Persistence\ManagerRegistry::class, $managerRegistry); + + return new DoctrineOrmDataProvider($managerRegistry); + } + + private function createDefinition(): DatatableDefinition + { + $definition = new DatatableDefinition('doctrine-users'); + + $definition + ->setEntityClass(DoctrineUser::class) + ->addColumn('e.id', label: 'ID') + ->addColumn('e.email', label: 'Email') + ->addColumn('e.enabled', label: 'Enabled') + ->addColumn('e.displayName', label: 'Display Name') + ->addJoin('organization', 'e.organization') + ->addColumn('organization.name', label: 'Organization') + ; + + return $definition; + } + + private function bootDoctrineAndLoadFixtures(): void + { + self::bootKernel(); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $this->entityManager = $entityManager; + $this->recreateSchema(); + + $organization = new DoctrineOrganization('Acme Corp', true); + $entityManager->persist($organization); + + $entityManager->persist(new DoctrineUser( + email: 'alice@example.test', + displayName: 'Alice', + enabled: true, + organization: $organization, + )); + $entityManager->persist(new DoctrineUser( + email: 'bob@example.test', + displayName: 'Bob', + enabled: false, + organization: $organization, + )); + $entityManager->persist(new DoctrineUser( + email: 'charlie@example.test', + displayName: 'Charlie', + enabled: true, + organization: $organization, + )); + + $entityManager->flush(); + $entityManager->clear(); + } + + private function recreateSchema(): void + { + $this->dropSchema(); + + $schemaTool = $this->createSchemaTool(); + $schemaTool->createSchema($this->getMetadata()); + } + + private function dropSchema(): void + { + if (!$this->entityManager instanceof EntityManagerInterface) { + return; + } + + $schemaTool = $this->createSchemaTool(); + + try { + $schemaTool->dropSchema($this->getMetadata()); + } catch (\Throwable) { + // The schema may not exist yet. + } + } + + private function createSchemaTool(): SchemaTool + { + return new SchemaTool($this->getStoredEntityManager()); + } + + private function getStoredEntityManager(): EntityManagerInterface + { + if (!$this->entityManager instanceof EntityManagerInterface) { + throw new \LogicException('The entity manager is not initialized.'); + } + + return $this->entityManager; + } +}