mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-06 16:41:38 +00:00
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:
parent
57a0dfdbdb
commit
950cf6de26
4 changed files with 161 additions and 36 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,4 +44,7 @@ class BehaviorSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?KeybindingsSettings $keybindings = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?SearchSettings $search = null;
|
||||
}
|
||||
|
|
|
|||
74
src/Settings/BehaviorSettings/SearchSettings.php
Normal file
74
src/Settings/BehaviorSettings/SearchSettings.php
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue