diff --git a/doc/crud.rst b/doc/crud.rst index b470807a0a..360c460386 100644 --- a/doc/crud.rst +++ b/doc/crud.rst @@ -240,6 +240,12 @@ Search, Order, and Pagination Options ->setSearchFields(null) // call this method to focus the search input automatically when loading the 'index' page ->setAutofocusSearch() + // match any terms (default mode) + // term1 in (field1 or field2) or term2 in (field1 or field2) + ->setSearchMode(SearchMode::ANY_TERMS) + // force to match all the terms + // term1 in (field1 or field2) and term2 in (field1 or field2) + ->setSearchMode(SearchMode::ALL_TERMS) ; } diff --git a/src/Config/Crud.php b/src/Config/Crud.php index f52f08b309..c57046c86b 100644 --- a/src/Config/Crud.php +++ b/src/Config/Crud.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Config; +use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SortOrder; use EasyCorp\Bundle\EasyAdminBundle\Dto\CrudDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterConfigDto; @@ -274,6 +275,17 @@ public function setSearchFields(?array $fieldNames): self return $this; } + public function setSearchMode(string $searchMode): self + { + if (!\in_array($searchMode, [SearchMode::ANY_TERMS, SearchMode::ALL_TERMS], true)) { + throw new \InvalidArgumentException(sprintf('The search mode can be only "%s" or "%s", "%s" given.', SearchMode::ANY_TERMS, SearchMode::ALL_TERMS, $searchMode)); + } + + $this->dto->setSearchMode($searchMode); + + return $this; + } + public function setAutofocusSearch(bool $autofocusSearch = true): self { $this->dto->setAutofocusSearch($autofocusSearch); diff --git a/src/Config/Option/SearchMode.php b/src/Config/Option/SearchMode.php new file mode 100644 index 0000000000..83660d0023 --- /dev/null +++ b/src/Config/Option/SearchMode.php @@ -0,0 +1,9 @@ +defaultSort = $defaultSort; } + public function getSearchMode(): string + { + return $this->searchMode; + } + + public function setSearchMode(string $searchMode): void + { + $this->searchMode = $searchMode; + } + public function getSearchFields(): ?array { return $this->searchFields; diff --git a/src/Dto/SearchDto.php b/src/Dto/SearchDto.php index 2bb0f51c2a..d0cbc2dd50 100644 --- a/src/Dto/SearchDto.php +++ b/src/Dto/SearchDto.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Dto; +use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use Symfony\Component\HttpFoundation\Request; /** @@ -19,8 +20,9 @@ final class SearchDto private ?array $searchableProperties; /** @var string[]|null */ private ?array $appliedFilters; + private string $searchMode; - public function __construct(Request $request, ?array $searchableProperties, ?string $query, array $defaultSort, array $customSort, ?array $appliedFilters) + public function __construct(Request $request, ?array $searchableProperties, ?string $query, array $defaultSort, array $customSort, ?array $appliedFilters, string $searchMode = SearchMode::ANY_TERMS) { $this->request = $request; $this->searchableProperties = $searchableProperties; @@ -28,6 +30,7 @@ public function __construct(Request $request, ?array $searchableProperties, ?str $this->defaultSort = $defaultSort; $this->customSort = $customSort; $this->appliedFilters = $appliedFilters; + $this->searchMode = $searchMode; } public function getRequest(): Request @@ -102,4 +105,9 @@ public function getAppliedFilters(): ?array { return $this->appliedFilters; } + + public function getSearchMode(): string + { + return $this->searchMode; + } } diff --git a/src/Factory/AdminContextFactory.php b/src/Factory/AdminContextFactory.php index c242e3e1c9..e0b69ba76b 100644 --- a/src/Factory/AdminContextFactory.php +++ b/src/Factory/AdminContextFactory.php @@ -215,8 +215,9 @@ public function getSearchDto(Request $request, ?CrudDto $crudDto): ?SearchDto $defaultSort = $crudDto->getDefaultSort(); $customSort = $queryParams[EA::SORT] ?? []; $appliedFilters = $queryParams[EA::FILTERS] ?? []; + $searchMode = $crudDto->getSearchMode(); - return new SearchDto($request, $searchableProperties, $query, $defaultSort, $customSort, $appliedFilters); + return new SearchDto($request, $searchableProperties, $query, $defaultSort, $customSort, $appliedFilters, $searchMode); } // Copied from https://github.com/symfony/twig-bridge/blob/master/AppVariable.php diff --git a/src/Orm/EntityRepository.php b/src/Orm/EntityRepository.php index 8bdb6fef55..4dc6ae3c47 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -5,10 +5,12 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Query\Expr\Orx; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; +use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityRepositoryInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto; @@ -96,6 +98,7 @@ private function addSearchClause(QueryBuilder $queryBuilder, SearchDto $searchDt 'text_query' => '%'.$lowercaseQueryTerm.'%', ]; + $queryTermConditions = new Orx(); foreach ($searchablePropertiesConfig as $propertyConfig) { $entityName = $propertyConfig['entity_name']; @@ -106,27 +109,32 @@ private function addSearchClause(QueryBuilder $queryBuilder, SearchDto $searchDt || ($propertyConfig['is_numeric'] && $isNumericQueryTerm) ) { $parameterName = sprintf('query_for_numbers_%d', $queryTermIndex); - $queryBuilder->orWhere(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)) - ->setParameter($parameterName, $dqlParameters['numeric_query']); + $queryTermConditions->add(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)); + $queryBuilder->setParameter($parameterName, $dqlParameters['numeric_query']); } elseif ($propertyConfig['is_guid'] && $isUuidQueryTerm) { $parameterName = sprintf('query_for_uuids_%d', $queryTermIndex); - $queryBuilder->orWhere(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)) - ->setParameter($parameterName, $dqlParameters['uuid_query'], 'uuid' === $propertyConfig['property_data_type'] ? 'uuid' : null); + $queryTermConditions->add(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)); + $queryBuilder->setParameter($parameterName, $dqlParameters['uuid_query'], 'uuid' === $propertyConfig['property_data_type'] ? 'uuid' : null); } elseif ($propertyConfig['is_ulid'] && $isUlidQueryTerm) { $parameterName = sprintf('query_for_ulids_%d', $queryTermIndex); - $queryBuilder->orWhere(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)) - ->setParameter($parameterName, $dqlParameters['uuid_query'], 'ulid'); + $queryTermConditions->add(sprintf('%s.%s = :%s', $entityName, $propertyConfig['property_name'], $parameterName)); + $queryBuilder->setParameter($parameterName, $dqlParameters['uuid_query'], 'ulid'); } elseif ($propertyConfig['is_text']) { $parameterName = sprintf('query_for_text_%d', $queryTermIndex); - $queryBuilder->orWhere(sprintf('LOWER(%s.%s) LIKE :%s', $entityName, $propertyConfig['property_name'], $parameterName)) - ->setParameter($parameterName, $dqlParameters['text_query']); + $queryTermConditions->add(sprintf('LOWER(%s.%s) LIKE :%s', $entityName, $propertyConfig['property_name'], $parameterName)); + $queryBuilder->setParameter($parameterName, $dqlParameters['text_query']); } elseif ($propertyConfig['is_json'] && !$isPostgreSql) { // neither LOWER() nor LIKE() are supported for JSON columns by all PostgreSQL installations $parameterName = sprintf('query_for_text_%d', $queryTermIndex); - $queryBuilder->orWhere(sprintf('LOWER(%s.%s) LIKE :%s', $entityName, $propertyConfig['property_name'], $parameterName)) - ->setParameter($parameterName, $dqlParameters['text_query']); + $queryTermConditions->add(sprintf('LOWER(%s.%s) LIKE :%s', $entityName, $propertyConfig['property_name'], $parameterName)); + $queryBuilder->setParameter($parameterName, $dqlParameters['text_query']); } } + if (SearchMode::ALL_TERMS === $searchDto->getSearchMode()) { + $queryBuilder->andWhere($queryTermConditions); + } else { + $queryBuilder->orWhere($queryTermConditions); + } } $this->eventDispatcher->dispatch(new AfterEntitySearchEvent($queryBuilder, $searchDto, $entityDto)); diff --git a/tests/Controller/Search/AllTermsCrudSearchControllerTest.php b/tests/Controller/Search/AllTermsCrudSearchControllerTest.php new file mode 100644 index 0000000000..99bc8c940b --- /dev/null +++ b/tests/Controller/Search/AllTermsCrudSearchControllerTest.php @@ -0,0 +1,69 @@ +client->followRedirects(); + } + + /** + * @dataProvider provideSearchTests + */ + public function testSearch(string $query, int $expectedResultCount) + { + $this->client->request('GET', $this->generateIndexUrl($query)); + static::assertIndexFullEntityCount($expectedResultCount); + } + + public static function provideSearchTests(): iterable + { + // the CRUD Controller associated to this test has configured the search + // properties used by the search engine. That's why results are not the default ones + $totalNumberOfPosts = 20; + $numOfPostsWrittenByEachAuthor = 4; + $numOfPostsPublishedByEachUser = 2; + + yield 'search by blog post title and author or publisher email no results' => [ + '"Blog Post 10" "user4@"', + 0, + ]; + + yield 'search by blog post title and author or publisher email' => [ + 'Blog Post "user4@"', + $numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser, + ]; + + yield 'search by author and publisher email' => [ + 'user1 user2@', + $numOfPostsPublishedByEachUser, + ]; + + yield 'search by author and publisher email no results' => [ + 'user1 user3@', + 0, + ]; + + yield 'search by author or publisher email' => [ + 'user4', + $numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser, + ]; + } +} diff --git a/tests/Dto/SearchDtoTest.php b/tests/Dto/SearchDtoTest.php index 1af4fc2a21..59a387536b 100644 --- a/tests/Dto/SearchDtoTest.php +++ b/tests/Dto/SearchDtoTest.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Dto; +use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -10,13 +11,13 @@ class SearchDtoTest extends TestCase { public function testQueryStringIsTrimmedAutomatically() { - $dto = new SearchDto(new Request(), null, ' foo ', [], [], null); + $dto = new SearchDto(new Request(), null, ' foo ', [], [], null, SearchMode::ANY_TERMS); $this->assertSame('foo', $dto->getQuery()); } public function testDefaultSort() { - $dto = new SearchDto(new Request(), null, null, ['foo' => 'ASC'], [], null); + $dto = new SearchDto(new Request(), null, null, ['foo' => 'ASC'], [], null, SearchMode::ANY_TERMS); $this->assertSame(['foo' => 'ASC'], $dto->getSort()); } @@ -25,7 +26,7 @@ public function testDefaultSort() */ public function testSortConfigMerging(array $defaultSort, array $customSort, array $expectedSortConfig) { - $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null); + $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null, SearchMode::ANY_TERMS); $this->assertSame($expectedSortConfig, $dto->getSort()); } @@ -34,7 +35,7 @@ public function testSortConfigMerging(array $defaultSort, array $customSort, arr */ public function testIsSortingField(array $defaultSort, array $customSort, string $fieldName, bool $expectedResult) { - $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null); + $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null, SearchMode::ANY_TERMS); $this->assertSame($expectedResult, $dto->isSortingField($fieldName)); } @@ -43,7 +44,7 @@ public function testIsSortingField(array $defaultSort, array $customSort, string */ public function testGetSortDirection(array $defaultSort, array $customSort, string $fieldName, string $expectedDirection) { - $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null); + $dto = new SearchDto(new Request(), null, null, $defaultSort, $customSort, null, SearchMode::ANY_TERMS); $this->assertSame($expectedDirection, $dto->getSortDirection($fieldName)); } @@ -52,10 +53,18 @@ public function testGetSortDirection(array $defaultSort, array $customSort, stri */ public function testGetQueryTerms(string $query, array $expectedQueryTerms) { - $dto = new SearchDto(new Request(), null, $query, [], [], null); + $dto = new SearchDto(new Request(), null, $query, [], [], null, SearchMode::ANY_TERMS); $this->assertSame($expectedQueryTerms, $dto->getQueryTerms()); } + public function testSearchMode() + { + foreach ([SearchMode::ANY_TERMS, SearchMode::ALL_TERMS] as $searchMode) { + $dto = new SearchDto(new Request(), null, null, ['foo' => 'ASC'], [], null, $searchMode); + $this->assertSame($searchMode, $dto->getSearchMode()); + } + } + public function provideSortDirectionTests(): iterable { yield 'no default sort, no custom sort' => [ diff --git a/tests/TestApplication/src/Controller/Search/AllTermsCrudSearchController.php b/tests/TestApplication/src/Controller/Search/AllTermsCrudSearchController.php new file mode 100644 index 0000000000..6e8fa98516 --- /dev/null +++ b/tests/TestApplication/src/Controller/Search/AllTermsCrudSearchController.php @@ -0,0 +1,23 @@ +setSearchFields(['title', 'author.email', 'publisher.email']) + ->setSearchMode(SearchMode::ALL_TERMS); + } +}