Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature #5948 Add mode to search all/any terms #5949

Merged
merged 1 commit into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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/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)' => [
'"user2@example.com" "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)' => [
'"user2@example.com" "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