Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/Controller/PartListsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
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;
Expand All @@ -59,6 +60,7 @@ public function __construct(private readonly EntityManagerInterface $entityManag
private readonly TranslatorInterface $translator,
private readonly TableSettings $tableSettings,
private readonly SidebarSettings $sidebarSettings,
private readonly SearchSettings $searchSettings,
)
{
}
Expand Down Expand Up @@ -315,7 +317,7 @@ function (PartFilter $filter) use ($tag) {

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'));
Expand Down
102 changes: 74 additions & 28 deletions src/DataTables/Filters/PartSearchFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@
*/
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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -124,52 +132,90 @@ protected function getFieldsToSearch(): array
public function apply(QueryBuilder $queryBuilder): void
{
$fields_to_search = $this->getFieldsToSearch();
$is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
$is_numeric = preg_match('/^\d+$/', trim($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 === '') {
$tokens = [];
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;
}

//If we have nothing to search for...
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '' || empty($tokens)) {
// ...enforce returning no results
$queryBuilder->add('where','1 = 0');
return;
}

$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);
}
$expressions2 = [];
$params = [];

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
//Search in selected fields, either based on regex or on tokenized keyword
if ($fields_to_search !== []) {
//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_merge($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);
} 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);
$expressions2 = 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 at the end
//For like, we add % to the start and end as wildcards
$params[] = new Parameter('search_query' . $i, '%' . $token . '%');

//Guard condition
if (!empty($expressions2)) {
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions2)
);
}
}
}
}

//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)
);
}
//Use equal expression to search for exact numeric matches
if ($search_dbId) {
$queryBuilder->orWhere($queryBuilder->expr()->eq('part.id', ':id_exact'));
$params[] = new Parameter('id_exact', (int)$this->keyword,
ParameterType::INTEGER);
}
$queryBuilder->setParameters(
new ArrayCollection($params)
);
}

public function getKeyword(): string
Expand Down
3 changes: 3 additions & 0 deletions src/Settings/BehaviorSettings/BehaviorSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,7 @@ class BehaviorSettings

#[EmbeddedSettings]
public ?KeybindingsSettings $keybindings = null;

#[EmbeddedSettings]
public ?SearchSettings $search = null;
}
74 changes: 74 additions & 0 deletions src/Settings/BehaviorSettings/SearchSettings.php
Original file line number Diff line number Diff line change
@@ -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
Loading