Skip to content

Commit

Permalink
feature #5949 feature #5948 Add mode to search all/any terms (tasiot)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 4.x branch.

Discussion
----------

feature #5948 Add mode to search all/any terms

Follow up to #5948

This PR add a searchMode option in Crud to set if the search is an AND or OR operator between terms.

Commits
-------

636329b feature #5948 Add mode to search all/any terms
  • Loading branch information
javiereguiluz committed Nov 21, 2023
2 parents 7003ee7 + 636329b commit dc6395c
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 41 deletions.
14 changes: 10 additions & 4 deletions doc/crud.rst
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,21 @@ Search, Order, and Pagination Options
->setSearchFields(null)
// call this method to focus the search input automatically when loading the 'index' page
->setAutofocusSearch()
// force to match all the terms (default mode)
// term1 in (field1 or field2) and term2 in (field1 or field2)
->setSearchMode(SearchMode::ALL_TERMS)
// match any terms
// term1 in (field1 or field2) or term2 in (field1 or field2)
->setSearchMode(SearchMode::ANY_TERMS)
;
}

.. tip::

The search engine makes an OR query by default (searching for ``foo bar``
returns items with ``foo`` OR ``bar`` OR ``foo bar``). You can wrap all or
part of your query with quotes to make an exact search: ``"foo bar"`` only
returns items with that exact content, including the middle white space.
The search engine splits all terms by default (searching for ``foo bar``
returns items with ``foo`` and ``bar``). You can wrap all or part of your
query with quotes to make an exact search: ``"foo bar"`` only returns
items with that exact content, including the middle white space.

::

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::ALL_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
10 changes: 9 additions & 1 deletion src/Dto/SearchDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace EasyCorp\Bundle\EasyAdminBundle\Dto;

use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode;
use Symfony\Component\HttpFoundation\Request;

/**
Expand All @@ -19,15 +20,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 = SearchMode::ALL_TERMS)
{
$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 +105,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 @@ -216,8 +216,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/AnyTermsCrudSearchControllerTest.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\AnyTermsCrudSearchController;

class AnyTermsCrudSearchControllerTest extends AbstractCrudTestCase
{
protected function getControllerFqcn(): string
{
return AnyTermsCrudSearchController::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 yields no results' => [
'blog post',
0,
];

yield 'search by blog post slug yields no results' => [
'blog-post',
0,
];

yield 'search by author or publisher email' => [
'@example.com',
$totalNumberOfPosts,
];

yield 'quoted search by author or published email' => [
'"user4@"',
$numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser,
];

yield 'multiple search by author or publisher email (partial or complete)' => [
'"[email protected]" "user4@"',
2 * $numOfPostsWrittenByEachAuthor + 2 * $numOfPostsPublishedByEachUser,
];
}
}
29 changes: 14 additions & 15 deletions tests/Controller/Search/CustomCrudSearchControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,32 @@ 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 yields no results' => [
'blog post',
yield 'search by blog post title and author or publisher email no results' => [
'"Blog Post 10" "user4@"',
0,
];

yield 'search by blog post slug yields no results' => [
'blog-post',
0,
yield 'search by blog post title and author or publisher email' => [
'Blog Post "user4@"',
$numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser,
];

yield 'search by author or publisher email' => [
'@example.com',
$totalNumberOfPosts,
yield 'search by author and publisher email' => [
'user1 user2@',
$numOfPostsPublishedByEachUser,
];

yield 'quoted search by author or published email' => [
'"user4@"',
$numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser,
yield 'search by author and publisher email no results' => [
'user1 user3@',
0,
];

yield 'multiple search by author or publisher email (partial or complete)' => [
'"[email protected]" "user4@"',
2 * $numOfPostsWrittenByEachAuthor + 2 * $numOfPostsPublishedByEachUser,
yield 'search by author or publisher email' => [
'user4',
$numOfPostsWrittenByEachAuthor + $numOfPostsPublishedByEachUser,
];
}
}
36 changes: 27 additions & 9 deletions tests/Controller/Search/DefaultCrudSearchControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,36 @@ public static function provideSearchTests(): iterable
$totalNumberOfPosts,
];

yield 'default search is OR search' => [
yield 'search all blog posts (use quotes)' => [
[],
'post 17',
'"blog post"',
$totalNumberOfPosts,
];

yield 'use quotes to make an AND search' => [
yield 'search all terms' => [
[],
'blog post-17',
1,
];

yield 'search all terms (inversed terms)' => [
[],
'post-17 blog',
1,
];

yield 'search all terms with quotes' => [
[],
'"post 17"',
1,
];

yield 'search all terms with quotes (inversed terms)' => [
[],
'"17 post"',
0,
];

yield 'quoted terms with inside quotes' => [
[
['title' => 'Foo "Bar Baz', 'slug' => 'foo-bar-baz'],
Expand All @@ -143,16 +161,16 @@ public static function provideSearchTests(): iterable
1,
];

yield "multiple quoted terms (it's an OR of two AND terms)" => [
yield 'multiple quoted terms' => [
[],
'"post 17" "post 18"',
2,
'"Blog post" "post 17"',
1,
];

yield "multiple quoted terms and unquoted terms (it's an OR search again)" => [
yield 'multiple quoted terms and unquoted terms' => [
[],
'"post 17" "post 18" post 5',
$totalNumberOfPosts,
'"Blog post" "post 17" post ipsum',
1,
];
}

Expand Down
Loading

0 comments on commit dc6395c

Please sign in to comment.