Skip to content

Commit

Permalink
feature #5948 Add mode to search all/any terms
Browse files Browse the repository at this point in the history
  • Loading branch information
tasiot committed Oct 8, 2023
1 parent 6b1722a commit 55b6417
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 18 deletions.
6 changes: 6 additions & 0 deletions doc/crud.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
;
}

Expand Down
12 changes: 12 additions & 0 deletions src/Config/Crud.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/Config/Option/SearchMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Config\Option;

final class SearchMode
{
public const ANY_TERMS = 'any_terms';
public const ALL_TERMS = 'all_terms';
}
12 changes: 12 additions & 0 deletions src/Dto/CrudDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Translation\TranslatableMessageBuilder;
use function Symfony\Component\Translation\t;
Expand Down Expand Up @@ -51,6 +52,7 @@ final class CrudDto
private ?string $decimalSeparator = null;
private array $defaultSort = [];
private ?array $searchFields = [];
private string $searchMode = SearchMode::ANY_TERMS;
private bool $autofocusSearch = false;
private bool $showEntityActionsAsDropdown = true;
private ?PaginatorDto $paginatorDto = null;
Expand Down Expand Up @@ -334,6 +336,16 @@ public function setDefaultSort(array $defaultSort): void
$this->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;
Expand Down
9 changes: 8 additions & 1 deletion src/Dto/SearchDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ 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)
{
$this->request = $request;
$this->searchableProperties = $searchableProperties;
$this->query = trim((string) $query);
$this->defaultSort = $defaultSort;
$this->customSort = $customSort;
$this->appliedFilters = $appliedFilters;
$this->searchMode = $searchMode;
}

public function getRequest(): Request
Expand Down Expand Up @@ -102,4 +104,9 @@ public function getAppliedFilters(): ?array
{
return $this->appliedFilters;
}

public function getSearchMode(): string
{
return $this->searchMode;
}
}
3 changes: 2 additions & 1 deletion src/Factory/AdminContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions src/Orm/EntityRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'];

Expand All @@ -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));
Expand Down
69 changes: 69 additions & 0 deletions tests/Controller/Search/AllTermsCrudSearchControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Controller\Search;

use EasyCorp\Bundle\EasyAdminBundle\Test\AbstractCrudTestCase;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\DashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\Search\AllTermsCrudSearchController;

class AllTermsCrudSearchControllerTest extends AbstractCrudTestCase
{
protected function getControllerFqcn(): string
{
return AllTermsCrudSearchController::class;
}

protected function getDashboardFqcn(): string
{
return DashboardController::class;
}

protected function setUp(): void
{
parent::setUp();
$this->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,
];
}
}
21 changes: 15 additions & 6 deletions tests/Dto/SearchDtoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}

Expand All @@ -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());
}

Expand All @@ -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));
}

Expand All @@ -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));
}

Expand All @@ -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' => [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\Search;

use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\BlogPost;

class AllTermsCrudSearchController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return BlogPost::class;
}

public function configureCrud(Crud $crud): Crud
{
return parent::configureCrud($crud)
->setSearchFields(['title', 'author.email', 'publisher.email'])
->setSearchMode(SearchMode::ALL_TERMS);
}
}

0 comments on commit 55b6417

Please sign in to comment.