From 950cf6de26419faf53c8d058ced002831cbde643 Mon Sep 17 00:00:00 2001 From: buchmann Date: Fri, 24 Apr 2026 15:07:23 +0200 Subject: [PATCH 1/5] 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". --- src/Controller/PartListsController.php | 4 +- src/DataTables/Filters/PartSearchFilter.php | 116 ++++++++++++------ .../BehaviorSettings/BehaviorSettings.php | 3 + .../BehaviorSettings/SearchSettings.php | 74 +++++++++++ 4 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 src/Settings/BehaviorSettings/SearchSettings.php diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 2210fc186..ce2eb2e94 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -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; @@ -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, ) { } @@ -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')); diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 9f6734e56..53b483e93 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -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 { @@ -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 @@ protected function getFieldsToSearch(): array 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 diff --git a/src/Settings/BehaviorSettings/BehaviorSettings.php b/src/Settings/BehaviorSettings/BehaviorSettings.php index ec849db3c..2740466f8 100644 --- a/src/Settings/BehaviorSettings/BehaviorSettings.php +++ b/src/Settings/BehaviorSettings/BehaviorSettings.php @@ -44,4 +44,7 @@ class BehaviorSettings #[EmbeddedSettings] public ?KeybindingsSettings $keybindings = null; + + #[EmbeddedSettings] + public ?SearchSettings $search = null; } diff --git a/src/Settings/BehaviorSettings/SearchSettings.php b/src/Settings/BehaviorSettings/SearchSettings.php new file mode 100644 index 000000000..dd67d51c1 --- /dev/null +++ b/src/Settings/BehaviorSettings/SearchSettings.php @@ -0,0 +1,74 @@ +. + */ + +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; +} From c21c6fec28528e144e7ad612159c5b22d726ae78 Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 9 Jun 2026 08:14:27 +0200 Subject: [PATCH 2/5] Add tests These were created with the help of GPT-5.2. Disclaimer: I don't have the experience to judge the quality or validity of the results. --- .../Filters/PartSearchFilterTest.php | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/DataTables/Filters/PartSearchFilterTest.php diff --git a/tests/DataTables/Filters/PartSearchFilterTest.php b/tests/DataTables/Filters/PartSearchFilterTest.php new file mode 100644 index 000000000..07b62626d --- /dev/null +++ b/tests/DataTables/Filters/PartSearchFilterTest.php @@ -0,0 +1,187 @@ +. + */ + +namespace App\Tests\DataTables\Filters; + +use App\DataTables\Filters\PartSearchFilter; +use App\Settings\BehaviorSettings\SearchSettings; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\ParameterType; +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\Query\Expr\Comparison; +use Doctrine\ORM\Query\Expr\Orx; +use Doctrine\ORM\Query\Parameter; +use Doctrine\ORM\QueryBuilder; +use PHPUnit\Framework\TestCase; + +final class PartSearchFilterTest extends TestCase +{ + private function makeSearchSettings( + bool $enableAdvancedSearch = false, + int $searchTokenLimit = 3, + bool $escapeSQLWildcards = true, + ): SearchSettings { + $settings = $this->createMock(SearchSettings::class); + $settings->enableAdvancedSearch = $enableAdvancedSearch; + $settings->searchTokenLimit = $searchTokenLimit; + $settings->escapeSQLWildcards = $escapeSQLWildcards; + + return $settings; + } + + public function testApplyReturnsEarlyWhenKeywordEmpty(): void + { + $filter = new PartSearchFilter('', $this->makeSearchSettings()); + + $qb = $this->createMock(QueryBuilder::class); + $qb->expects($this->never())->method('andWhere'); + $qb->expects($this->never())->method('setParameter'); + + $filter->apply($qb); + } + + public function testApplyReturnsEarlyWhenNothingToSearchForAndNoExactIdSearch(): void + { + $filter = (new PartSearchFilter('foo', $this->makeSearchSettings())) + ->setName(false) + ->setCategory(false) + ->setDescription(false) + ->setComment(false) + ->setTags(false) + ->setStorelocation(false) + ->setOrdernr(false) + ->setMpn(false) + ->setSupplier(false) + ->setManufacturer(false) + ->setFootprint(false) + ->setIPN(false) + ->setDbId(false); + + $qb = $this->createMock(QueryBuilder::class); + $qb->expects($this->never())->method('andWhere'); + $qb->expects($this->never())->method('setParameter'); + + $filter->apply($qb); + } + + public function testApplyUsesRegexExpressionAndRawParameterWhenRegexEnabled(): void + { + $filter = (new PartSearchFilter('foo.*bar', $this->makeSearchSettings())) + ->setRegex(true); + + $expr = $this->createStub(Expr::class); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('expr')->willReturn($expr); + + $qb->expects($this->never())->method('setParameter'); + + // In PR #1406 the filter uses setParameters(ArrayCollection) instead of setParameter() in regex mode. + $qb->expects($this->once()) + ->method('setParameters') + ->with($this->callback(function ($params): bool { + $this->assertInstanceOf(\Doctrine\Common\Collections\ArrayCollection::class, $params); + + /** @var \Doctrine\ORM\Query\Parameter|null $p */ + $p = $params->get('search_query'); + $this->assertNotNull($p); + $this->assertSame('foo.*bar', $p->getValue()); + + return true; + })); + + // We don't assert the exact expression object (Doctrine internals), only that a WHERE is added. + $qb->expects($this->once())->method('andWhere'); + + $filter->apply($qb); + } + + public function testApplyEscapesSqlWildcardsAndWrapsLikeParameterWhenRegexDisabled(): void + { + $filter = (new PartSearchFilter('10%_off', $this->makeSearchSettings(escapeSQLWildcards: true))) + ->setRegex(false); + + $expr = $this->createMock(Expr::class); + $expr->method('orX')->willReturn(new Orx()); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('expr')->willReturn($expr); + + $qb->expects($this->once()) + ->method('setParameter') + ->with('search_query', '%10\%\_off%'); + + $qb->expects($this->once()) + ->method('andWhere') + ->with($this->isInstanceOf(Orx::class)); + + $filter->apply($qb); + } + + public function testApplyAddsExactIdExpressionWhenDbIdSearchEnabledAndKeywordNumeric(): void + { + $filter = (new PartSearchFilter('123', $this->makeSearchSettings())) + ->setDbId(true); + + $expr = $this->createMock(Expr::class); + $expr->expects($this->once()) + ->method('eq') + ->with('part.id', ':id_exact') + ->willReturn(new Comparison('part.id', '=', ':id_exact')); + + $expr->method('orX')->willReturn(new Orx()); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('expr')->willReturn($expr); + + $qb->expects($this->once()) + ->method('setParameter') + ->with('id_exact', 123, ParameterType::INTEGER); + + $qb->expects($this->once()) + ->method('andWhere') + ->with($this->isInstanceOf(Orx::class)); + + $filter->apply($qb); + } + + public function testApplyDoesNotAddExactIdExpressionWhenKeywordNotNumeric(): void + { + $filter = (new PartSearchFilter('123abc', $this->makeSearchSettings())) + ->setDbId(true); + + $expr = $this->createMock(Expr::class); + $expr->expects($this->never())->method('eq'); + $expr->method('orX')->willReturn(new Orx()); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('expr')->willReturn($expr); + + // It should still set the search_query parameter for LIKE (default regex=false) + $qb->expects($this->once()) + ->method('setParameter') + ->with('search_query', '%123abc%'); + + $qb->expects($this->once())->method('andWhere'); + + $filter->apply($qb); + } +} From 85998ea1df7c9bc6b6e2df49a203c35656d91b8f Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 9 Jun 2026 15:38:35 +0200 Subject: [PATCH 3/5] Restructure query buildup --- src/DataTables/Filters/PartSearchFilter.php | 64 ++++++++++----------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 53b483e93..9d8ee394c 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -131,19 +131,13 @@ protected function getFieldsToSearch(): array 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; + $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; + $tokens = []; if ($this->searchSettings->enableAdvancedSearch) { //Transform keyword and trim excess spaces $this->keyword = trim(str_replace('+', ' ', $this->keyword)); @@ -158,27 +152,26 @@ public function apply(QueryBuilder $queryBuilder): void $tokens[] = $this->keyword; } - $params = []; + //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 = []; + $expressions2 = []; + $params = []; - //If we have nothing to search for, do nothing - if ($fields_to_search === [] && !$search_dbId) { - return; - } else { + //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) { //Convert the fields to search to a list of expressions - $expressions = array_map(function (string $field): string { + $expressions = array_merge($expressions, array_map(function (string $field): string { return sprintf("REGEXP(%s, :search_query) = TRUE", $field); - }, $fields_to_search); + }, $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 { //Add a new expression and parameter set to the query for each token foreach ($tokens as $i => $token) { @@ -188,31 +181,38 @@ public function apply(QueryBuilder $queryBuilder): void //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 { + $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 + //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 . '%'); - //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)) { + if (!empty($expressions2)) { //Add Or concatenation of the expressions to our query $queryBuilder->andWhere( - $queryBuilder->expr()->orX(...$expressions) + $queryBuilder->expr()->orX(...$expressions2) ); } } } } + //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) ); From 3702c079b2b26d16c0a016d79d5c6ed6fb86c26b Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 9 Jun 2026 15:44:29 +0200 Subject: [PATCH 4/5] Update tests --- .../Filters/PartSearchFilterTest.php | 79 ++++++++++++++++--- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/tests/DataTables/Filters/PartSearchFilterTest.php b/tests/DataTables/Filters/PartSearchFilterTest.php index 07b62626d..91d04c741 100644 --- a/tests/DataTables/Filters/PartSearchFilterTest.php +++ b/tests/DataTables/Filters/PartSearchFilterTest.php @@ -47,18 +47,21 @@ private function makeSearchSettings( return $settings; } - public function testApplyReturnsEarlyWhenKeywordEmpty(): void + public function testApplyEnforcesNoResultsWhenKeywordEmpty(): void { $filter = new PartSearchFilter('', $this->makeSearchSettings()); $qb = $this->createMock(QueryBuilder::class); + $qb->expects($this->once()) + ->method('add') + ->with('where', '1 = 0'); $qb->expects($this->never())->method('andWhere'); - $qb->expects($this->never())->method('setParameter'); + $qb->expects($this->never())->method('setParameters'); $filter->apply($qb); } - public function testApplyReturnsEarlyWhenNothingToSearchForAndNoExactIdSearch(): void + public function testApplyEnforcesNoResultsWhenNothingToSearchForAndNoExactIdSearch(): void { $filter = (new PartSearchFilter('foo', $this->makeSearchSettings())) ->setName(false) @@ -76,8 +79,11 @@ public function testApplyReturnsEarlyWhenNothingToSearchForAndNoExactIdSearch(): ->setDbId(false); $qb = $this->createMock(QueryBuilder::class); + $qb->expects($this->once()) + ->method('add') + ->with('where', '1 = 0'); $qb->expects($this->never())->method('andWhere'); - $qb->expects($this->never())->method('setParameter'); + $qb->expects($this->never())->method('setParameters'); $filter->apply($qb); } @@ -101,8 +107,9 @@ public function testApplyUsesRegexExpressionAndRawParameterWhenRegexEnabled(): v $this->assertInstanceOf(\Doctrine\Common\Collections\ArrayCollection::class, $params); /** @var \Doctrine\ORM\Query\Parameter|null $p */ - $p = $params->get('search_query'); + $p = $params->get(0); $this->assertNotNull($p); + $this->assertSame('search_query', $p->getName()); $this->assertSame('foo.*bar', $p->getValue()); return true; @@ -125,9 +132,21 @@ public function testApplyEscapesSqlWildcardsAndWrapsLikeParameterWhenRegexDisabl $qb = $this->createMock(QueryBuilder::class); $qb->method('expr')->willReturn($expr); + $qb->expects($this->never())->method('setParameter'); + $qb->expects($this->once()) - ->method('setParameter') - ->with('search_query', '%10\%\_off%'); + ->method('setParameters') + ->with($this->callback(function ($params): bool { + $this->assertInstanceOf(ArrayCollection::class, $params); + + /** @var Parameter|null $p */ + $p = $params->get(0); + $this->assertNotNull($p); + $this->assertSame('search_query0', $p->getName()); + $this->assertSame('%10\\%\\_off%', $p->getValue()); + + return true; + })); $qb->expects($this->once()) ->method('andWhere') @@ -152,14 +171,39 @@ public function testApplyAddsExactIdExpressionWhenDbIdSearchEnabledAndKeywordNum $qb = $this->createMock(QueryBuilder::class); $qb->method('expr')->willReturn($expr); + $qb->expects($this->never())->method('setParameter'); + + // New structure: LIKE token search is added via andWhere(...), exact ID match via orWhere(...), + // and all parameters are passed in one consolidated setParameters() call. $qb->expects($this->once()) - ->method('setParameter') - ->with('id_exact', 123, ParameterType::INTEGER); + ->method('setParameters') + ->with($this->callback(function ($params): bool { + $this->assertInstanceOf(ArrayCollection::class, $params); + + /** @var Parameter|null $p0 */ + $p0 = $params->get(0); + $this->assertNotNull($p0); + $this->assertSame('search_query0', $p0->getName()); + $this->assertSame('%123%', $p0->getValue()); + + /** @var Parameter|null $p1 */ + $p1 = $params->get(1); + $this->assertNotNull($p1); + $this->assertSame('id_exact', $p1->getName()); + $this->assertSame(123, $p1->getValue()); + $this->assertSame(ParameterType::INTEGER, $p1->getType()); + + return true; + })); $qb->expects($this->once()) ->method('andWhere') ->with($this->isInstanceOf(Orx::class)); + $qb->expects($this->once()) + ->method('orWhere') + ->with($this->isInstanceOf(Comparison::class)); + $filter->apply($qb); } @@ -175,10 +219,21 @@ public function testApplyDoesNotAddExactIdExpressionWhenKeywordNotNumeric(): voi $qb = $this->createMock(QueryBuilder::class); $qb->method('expr')->willReturn($expr); - // It should still set the search_query parameter for LIKE (default regex=false) + $qb->expects($this->never())->method('setParameter'); + $qb->expects($this->once()) - ->method('setParameter') - ->with('search_query', '%123abc%'); + ->method('setParameters') + ->with($this->callback(function ($params): bool { + $this->assertInstanceOf(ArrayCollection::class, $params); + + /** @var Parameter|null $p */ + $p = $params->get(0); + $this->assertNotNull($p); + $this->assertSame('search_query0', $p->getName()); + $this->assertSame('%123abc%', $p->getValue()); + + return true; + })); $qb->expects($this->once())->method('andWhere'); From 2a1c985715d9b9a510bf575a7514f498e6f5daa2 Mon Sep 17 00:00:00 2001 From: buchmann Date: Fri, 26 Jun 2026 11:54:45 +0200 Subject: [PATCH 5/5] Move options from Settings to localStorage --- src/Controller/PartListsController.php | 6 +- src/DataTables/Filters/PartSearchFilter.php | 43 +++++++++-- .../BehaviorSettings/BehaviorSettings.php | 3 - .../BehaviorSettings/SearchSettings.php | 74 ------------------- templates/components/search.macro.html.twig | 14 +++- templates/parts/lists/search_list.html.twig | 8 ++ .../Filters/PartSearchFilterTest.php | 27 ++----- 7 files changed, 63 insertions(+), 112 deletions(-) delete mode 100644 src/Settings/BehaviorSettings/SearchSettings.php diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index ce2eb2e94..87d48a4a7 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -37,7 +37,6 @@ 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; @@ -60,7 +59,6 @@ public function __construct(private readonly EntityManagerInterface $entityManag private readonly TranslatorInterface $translator, private readonly TableSettings $tableSettings, private readonly SidebarSettings $sidebarSettings, - private readonly SearchSettings $searchSettings, ) { } @@ -317,7 +315,7 @@ function (PartFilter $filter) use ($tag) { private function searchRequestToFilter(Request $request): PartSearchFilter { - $filter = new PartSearchFilter($request->query->get('keyword', ''), $this->searchSettings); + $filter = new PartSearchFilter($request->query->get('keyword', '')); //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')); @@ -336,6 +334,8 @@ private function searchRequestToFilter(Request $request): PartSearchFilter $filter->setRegex($request->query->getBoolean('regex')); + $filter->setExtensive($request->query->getBoolean('extensive')); + $filter->setWildcard($request->query->getBoolean('wildcard')); return $filter; } diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 9d8ee394c..777f9c8ef 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -26,13 +26,18 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Query\Parameter; use Doctrine\DBAL\ParameterType; -use App\Settings\BehaviorSettings\SearchSettings; class PartSearchFilter implements FilterInterface { /** @var boolean Whether to use regex for searching */ protected bool $regex = false; + + /** @var boolean Whether to use extensive matching for searching */ + protected bool $extensive = false; + + /** @var boolean Whether to use wildcards for searching */ + protected bool $wildcard = false; /** @var bool Use name field for searching */ protected bool $name = true; @@ -78,9 +83,7 @@ class PartSearchFilter implements FilterInterface public function __construct( /** @var string The string to query for */ - protected string $keyword, - /** @var SearchSettings The settings that control how the search operates */ - private readonly SearchSettings $searchSettings, + protected string $keyword ) { } @@ -138,11 +141,11 @@ public function apply(QueryBuilder $queryBuilder): void $search_dbId = $is_numeric && (bool)$this->dbId; $tokens = []; - if ($this->searchSettings->enableAdvancedSearch) { + if ($this->extensive) { //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); + //Split keyword on spaces, but limit token count to 5 + $tokens = explode(' ', $this->keyword, 5); //Throw away array elements which are null or have zero length $tokens = array_filter($tokens, fn($x) => (strlen($x) > 0)); } @@ -176,7 +179,7 @@ public function apply(QueryBuilder $queryBuilder): void //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) + if (!$this->wildcard) $token = str_replace(['%', '_'], ['\%', '\_'], $token); //Convert the fields to search to a list of expressions @@ -240,6 +243,30 @@ public function setRegex(bool $regex): PartSearchFilter return $this; } + public function isExtensive(): bool + { + return $this->extensive; + } + + public function setExtensive(bool $extensive): PartSearchFilter + { + $this->extensive = $extensive; + return $this; + } + + + public function isWildcard(): bool + { + return $this->wildcard; + } + + public function setWildcard(bool $wildcard): PartSearchFilter + { + $this->wildcard = $wildcard; + return $this; + } + + public function isName(): bool { return $this->name; diff --git a/src/Settings/BehaviorSettings/BehaviorSettings.php b/src/Settings/BehaviorSettings/BehaviorSettings.php index 2740466f8..ec849db3c 100644 --- a/src/Settings/BehaviorSettings/BehaviorSettings.php +++ b/src/Settings/BehaviorSettings/BehaviorSettings.php @@ -44,7 +44,4 @@ class BehaviorSettings #[EmbeddedSettings] public ?KeybindingsSettings $keybindings = null; - - #[EmbeddedSettings] - public ?SearchSettings $search = null; } diff --git a/src/Settings/BehaviorSettings/SearchSettings.php b/src/Settings/BehaviorSettings/SearchSettings.php deleted file mode 100644 index dd67d51c1..000000000 --- a/src/Settings/BehaviorSettings/SearchSettings.php +++ /dev/null @@ -1,74 +0,0 @@ -. - */ - -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; -} diff --git a/templates/components/search.macro.html.twig b/templates/components/search.macro.html.twig index 90c018767..f826e1a4d 100644 --- a/templates/components/search.macro.html.twig +++ b/templates/components/search.macro.html.twig @@ -1,4 +1,4 @@ -{% macro settings_drodown(show_label_instead_icon = true) %} +{% macro settings_dropdown(show_label_instead_icon = true) %} +
+ + +
+
+ + +
@@ -85,7 +93,7 @@ {# Show the options left in navbar #} {% if is_navbar %} - {{ _self.settings_drodown(is_navbar) }} + {{ _self.settings_dropdown(is_navbar) }} {% endif %}
{% endmacro %} diff --git a/templates/parts/lists/search_list.html.twig b/templates/parts/lists/search_list.html.twig index 49093c4c5..56cf71cd2 100644 --- a/templates/parts/lists/search_list.html.twig +++ b/templates/parts/lists/search_list.html.twig @@ -69,6 +69,14 @@
+
+ + +
+
+ + +
diff --git a/tests/DataTables/Filters/PartSearchFilterTest.php b/tests/DataTables/Filters/PartSearchFilterTest.php index 91d04c741..1a2738a2b 100644 --- a/tests/DataTables/Filters/PartSearchFilterTest.php +++ b/tests/DataTables/Filters/PartSearchFilterTest.php @@ -22,7 +22,6 @@ namespace App\Tests\DataTables\Filters; use App\DataTables\Filters\PartSearchFilter; -use App\Settings\BehaviorSettings\SearchSettings; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\ParameterType; use Doctrine\ORM\Query\Expr; @@ -34,22 +33,10 @@ final class PartSearchFilterTest extends TestCase { - private function makeSearchSettings( - bool $enableAdvancedSearch = false, - int $searchTokenLimit = 3, - bool $escapeSQLWildcards = true, - ): SearchSettings { - $settings = $this->createMock(SearchSettings::class); - $settings->enableAdvancedSearch = $enableAdvancedSearch; - $settings->searchTokenLimit = $searchTokenLimit; - $settings->escapeSQLWildcards = $escapeSQLWildcards; - - return $settings; - } public function testApplyEnforcesNoResultsWhenKeywordEmpty(): void { - $filter = new PartSearchFilter('', $this->makeSearchSettings()); + $filter = new PartSearchFilter(''); $qb = $this->createMock(QueryBuilder::class); $qb->expects($this->once()) @@ -63,7 +50,7 @@ public function testApplyEnforcesNoResultsWhenKeywordEmpty(): void public function testApplyEnforcesNoResultsWhenNothingToSearchForAndNoExactIdSearch(): void { - $filter = (new PartSearchFilter('foo', $this->makeSearchSettings())) + $filter = (new PartSearchFilter('foo')) ->setName(false) ->setCategory(false) ->setDescription(false) @@ -90,7 +77,7 @@ public function testApplyEnforcesNoResultsWhenNothingToSearchForAndNoExactIdSear public function testApplyUsesRegexExpressionAndRawParameterWhenRegexEnabled(): void { - $filter = (new PartSearchFilter('foo.*bar', $this->makeSearchSettings())) + $filter = (new PartSearchFilter('foo.*bar')) ->setRegex(true); $expr = $this->createStub(Expr::class); @@ -123,9 +110,7 @@ public function testApplyUsesRegexExpressionAndRawParameterWhenRegexEnabled(): v public function testApplyEscapesSqlWildcardsAndWrapsLikeParameterWhenRegexDisabled(): void { - $filter = (new PartSearchFilter('10%_off', $this->makeSearchSettings(escapeSQLWildcards: true))) - ->setRegex(false); - + $filter = (new PartSearchFilter('10%_off')); $expr = $this->createMock(Expr::class); $expr->method('orX')->willReturn(new Orx()); @@ -157,7 +142,7 @@ public function testApplyEscapesSqlWildcardsAndWrapsLikeParameterWhenRegexDisabl public function testApplyAddsExactIdExpressionWhenDbIdSearchEnabledAndKeywordNumeric(): void { - $filter = (new PartSearchFilter('123', $this->makeSearchSettings())) + $filter = (new PartSearchFilter('123')) ->setDbId(true); $expr = $this->createMock(Expr::class); @@ -209,7 +194,7 @@ public function testApplyAddsExactIdExpressionWhenDbIdSearchEnabledAndKeywordNum public function testApplyDoesNotAddExactIdExpressionWhenKeywordNotNumeric(): void { - $filter = (new PartSearchFilter('123abc', $this->makeSearchSettings())) + $filter = (new PartSearchFilter('123abc')) ->setDbId(true); $expr = $this->createMock(Expr::class);