Implement advanced search

Up to 5 individual tokens (separated by spaces) can be given as search string.
Each token is individually searched for in all selected fields.

Examples (assuming the relevant fields are selected for search):
- a part named `foo` with a tag `bar` will be found with the search string "foo bar".
- a part named `bar baz` will be found with the search string "baz bar".
- a part with the ID 123 and in storage location `a_qux_b` will be found with the search string "qux 123".
This commit is contained in:
buchmann 2026-04-24 15:07:23 +02:00
parent 57a0dfdbdb
commit 950cf6de26
4 changed files with 161 additions and 36 deletions

View file

@ -37,6 +37,7 @@ use App\Form\Filters\PartFilterType;
use App\Services\Parts\PartsTableActionHandler;
use App\Services\Trees\NodesListBuilder;
use App\Settings\BehaviorSettings\SidebarSettings;
use App\Settings\BehaviorSettings\SearchSettings;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
@ -59,6 +60,7 @@ class PartListsController extends AbstractController
private readonly TranslatorInterface $translator,
private readonly TableSettings $tableSettings,
private readonly SidebarSettings $sidebarSettings,
private readonly SearchSettings $searchSettings,
)
{
}
@ -315,7 +317,7 @@ class PartListsController extends AbstractController
private function searchRequestToFilter(Request $request): PartSearchFilter
{
$filter = new PartSearchFilter($request->query->get('keyword', ''));
$filter = new PartSearchFilter($request->query->get('keyword', ''), $this->searchSettings);
//As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)!
$filter->setName($request->query->getBoolean('name'));

View file

@ -22,8 +22,11 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Query\Parameter;
use Doctrine\DBAL\ParameterType;
use App\Settings\BehaviorSettings\SearchSettings;
class PartSearchFilter implements FilterInterface
{
@ -70,11 +73,16 @@ class PartSearchFilter implements FilterInterface
/** @var bool Use Internal Part number for searching */
protected bool $ipn = true;
/** @var int array_map iteration helper variable */
protected int $it = 0;
public function __construct(
/** @var string The string to query for */
protected string $keyword
)
{
protected string $keyword,
/** @var SearchSettings The settings that control how the search operates */
private readonly SearchSettings $searchSettings,
) {
}
protected function getFieldsToSearch(): array
@ -123,53 +131,91 @@ class PartSearchFilter implements FilterInterface
public function apply(QueryBuilder $queryBuilder): void
{
//Early return if there is no keyword
if ($this->keyword === '')
return;
$fields_to_search = $this->getFieldsToSearch();
$tokens = [];
// Detect if the keyword is purely numeric
$is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
// Add exact ID match only when the keyword is numeric
$search_dbId = $is_numeric && (bool)$this->dbId;
//If we have nothing to search for, do nothing
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
return;
if ($this->searchSettings->enableAdvancedSearch) {
//Transform keyword and trim excess spaces
$this->keyword = trim(str_replace('+', ' ', $this->keyword));
//Split keyword on spaces, but limit token count (default is 3)
$tokens = explode(' ', $this->keyword, $this->searchSettings->searchTokenLimit);
//Throw away array elements which are null or have zero length
$tokens = array_filter($tokens, fn($x) => (strlen($x) > 0));
}
else {
//Pass the whole keyword into the (empty) tokens array as is,
//retaining the original search behavior
$tokens[] = $this->keyword;
}
$params = [];
$expressions = [];
if($fields_to_search !== []) {
//Convert the fields to search to a list of expressions
$expressions = array_map(function (string $field): string {
if ($this->regex) {
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
//If we have nothing to search for, do nothing
if ($fields_to_search === [] && !$search_dbId) {
return;
} else {
//For regex, we pass the query as is
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
//Convert the fields to search to a list of expressions
$expressions = array_map(function (string $field): string {
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
$params[] = new Parameter('search_query', $this->keyword);
//Guard condition
if (!empty($expressions)) {
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
}
} else {
//Escape % and _ characters in the keyword
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
//Add a new expression and parameter set to the query for each token
foreach ($tokens as $i => $token) {
//Conditionally escape % and _ characters
if ($this->searchSettings->escapeSQLWildcards)
$token = str_replace(['%', '_'], ['\%', '\_'], $token);
//Convert the fields to search to a list of expressions
$tmp = array_fill_keys($fields_to_search, $i);
$expressions = array_map(function (string $field, int $idx): string {
return sprintf("ILIKE(%s, :search_query%u) = TRUE", $field, $idx);
}, array_keys($tmp), array_values($tmp));
//Aggregate the parameters for consolidated commission
//For like, we add % to the start and end as wildcards
$params[] = new Parameter('search_query' . $i, '%' . $token . '%');
//Use equal expression to search for exact numeric matches
if ($search_dbId && preg_match('/^\d+$/', $token) === 1) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact' . $i);
$params[] = new Parameter('id_exact' . $i,
(int) $token, ParameterType::INTEGER);
}
//Guard condition
if (!empty($expressions)) {
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
}
}
}
}
//Use equal expression to just search for exact numeric matches
if ($search_dbId) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
ParameterType::INTEGER);
}
//Guard condition
if (!empty($expressions)) {
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
}
$queryBuilder->setParameters(
new ArrayCollection($params)
);
}
public function getKeyword(): string

View file

@ -44,4 +44,7 @@ class BehaviorSettings
#[EmbeddedSettings]
public ?KeybindingsSettings $keybindings = null;
#[EmbeddedSettings]
public ?SearchSettings $search = null;
}

View file

@ -0,0 +1,74 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: "search", label: new TM("settings.behavior.search"))]
#[SettingsIcon('fa-magnifying-glass')]
class SearchSettings
{
/**
* Whether to enable advanced search
* @var bool
*/
#[SettingsParameter(
label: new TM("settings.behavior.search.enable_advanced_search"),
description: new TM("settings.behavior.search.enable_advanced_search.help"),
envVar: "bool:ENABLE_ADVANCED_SEARCH",
envVarMode: EnvVarMode::OVERWRITE
)]
public bool $enableAdvancedSearch = false;
/**
* Defines the maximum number of tokens the keyword can be split into
* @var int
*/
#[SettingsParameter(
label: new TM("settings.behavior.search.token_limit"),
description: new TM("settings.behavior.search.token_limit.help"),
envVar: "int:SEARCH_TOKEN_LIMIT",
envVarMode: EnvVarMode::OVERWRITE,
formOptions: ['attr' => ['min' => 2, 'max' => 5]],
)]
#[Assert\Range(min: 2, max: 5)]
public int $searchTokenLimit = 3;
/**
* Whether to escape sql wildcards
* @var bool
*/
#[SettingsParameter(
label: new TM("settings.behavior.search.escape_sql_wildcards"),
description: new TM("settings.behavior.search.escape_sql_wildcards.help"),
envVar: "bool:ESCAPE_SQL_WILDCARDS",
envVarMode: EnvVarMode::OVERWRITE
)]
public bool $escapeSQLWildcards = true;
}