mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-28 20:21:41 +00:00
Implement extensive search (#1406)
* 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". * 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. * Restructure query buildup * Update tests * Move options from Settings to localStorage * Consider mutual exclusivity of search options If regex search is enabled, the other two options are disabled (only visually). This should give the user a fair idea of what's happening while keeping things as simple as possible. * Added translations for the checkboxes * Fix stimulus controller to allow handling multiple instances of the dropdown menu * Added tooltips for the different search mode options --------- Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
parent
49ffd3c938
commit
1686df968f
8 changed files with 434 additions and 34 deletions
54
assets/controllers/elements/search_config_controller.js
Normal file
54
assets/controllers/elements/search_config_controller.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2026 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Controller} from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to disable the extensive and wildcard search options, when Regex search is enabled.
|
||||||
|
*/
|
||||||
|
export default class extends Controller
|
||||||
|
{
|
||||||
|
static targets = ["regex", "extensive", "wildcard"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
|
||||||
|
this.regexTarget.addEventListener('change', () => {
|
||||||
|
this.updateOthers()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOthers() {
|
||||||
|
let value = this.regexTarget.checked;
|
||||||
|
let ext = this.extensiveTarget;
|
||||||
|
let wild = this.wildcardTarget;
|
||||||
|
if (value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
ext.disabled = 'disabled'
|
||||||
|
wild.disabled = 'disabled'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ext.removeAttribute('disabled')
|
||||||
|
wild.removeAttribute('disabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ class RegisterEventHelper {
|
||||||
document.querySelectorAll('.tooltip').forEach(el => el.remove());
|
document.querySelectorAll('.tooltip').forEach(el => el.remove());
|
||||||
|
|
||||||
//Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.)
|
//Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.)
|
||||||
const tooltipSelector = 'a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title]';
|
const tooltipSelector = 'a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title], div[title]';
|
||||||
document.querySelectorAll(tooltipSelector).forEach(el => {
|
document.querySelectorAll(tooltipSelector).forEach(el => {
|
||||||
const existing = Tooltip.getInstance(el);
|
const existing = Tooltip.getInstance(el);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,8 @@ class PartListsController extends AbstractController
|
||||||
|
|
||||||
|
|
||||||
$filter->setRegex($request->query->getBoolean('regex'));
|
$filter->setRegex($request->query->getBoolean('regex'));
|
||||||
|
$filter->setExtensive($request->query->getBoolean('extensive'));
|
||||||
|
$filter->setWildcard($request->query->getBoolean('wildcard'));
|
||||||
|
|
||||||
return $filter;
|
return $filter;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ declare(strict_types=1);
|
||||||
*/
|
*/
|
||||||
namespace App\DataTables\Filters;
|
namespace App\DataTables\Filters;
|
||||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\ORM\Query\Parameter;
|
||||||
use Doctrine\DBAL\ParameterType;
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
|
||||||
class PartSearchFilter implements FilterInterface
|
class PartSearchFilter implements FilterInterface
|
||||||
|
|
@ -30,6 +32,12 @@ class PartSearchFilter implements FilterInterface
|
||||||
|
|
||||||
/** @var boolean Whether to use regex for searching */
|
/** @var boolean Whether to use regex for searching */
|
||||||
protected bool $regex = false;
|
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 */
|
/** @var bool Use name field for searching */
|
||||||
protected bool $name = true;
|
protected bool $name = true;
|
||||||
|
|
@ -70,11 +78,14 @@ class PartSearchFilter implements FilterInterface
|
||||||
/** @var bool Use Internal Part number for searching */
|
/** @var bool Use Internal Part number for searching */
|
||||||
protected bool $ipn = true;
|
protected bool $ipn = true;
|
||||||
|
|
||||||
|
/** @var int array_map iteration helper variable */
|
||||||
|
protected int $it = 0;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
/** @var string The string to query for */
|
/** @var string The string to query for */
|
||||||
protected string $keyword
|
protected string $keyword
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getFieldsToSearch(): array
|
protected function getFieldsToSearch(): array
|
||||||
|
|
@ -124,43 +135,72 @@ class PartSearchFilter implements FilterInterface
|
||||||
public function apply(QueryBuilder $queryBuilder): void
|
public function apply(QueryBuilder $queryBuilder): void
|
||||||
{
|
{
|
||||||
$fields_to_search = $this->getFieldsToSearch();
|
$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
|
// Add exact ID match only when the keyword is numeric
|
||||||
$search_dbId = $is_numeric && (bool)$this->dbId;
|
$search_dbId = $is_numeric && (bool)$this->dbId;
|
||||||
|
|
||||||
//If we have nothing to search for, do nothing
|
$tokens = [];
|
||||||
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
|
if ($this->extensive) {
|
||||||
|
//Transform keyword and trim excess spaces
|
||||||
|
$this->keyword = trim(str_replace('+', ' ', $this->keyword));
|
||||||
|
//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));
|
||||||
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expressions = [];
|
$expressions = [];
|
||||||
|
$expressions2 = [];
|
||||||
if($fields_to_search !== []) {
|
$params = [];
|
||||||
//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);
|
//Search in selected fields, either based on regex or on tokenized keyword
|
||||||
}, $fields_to_search);
|
if ($fields_to_search !== []) {
|
||||||
|
//For regex, we pass the query as is
|
||||||
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
|
|
||||||
if ($this->regex) {
|
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 {
|
} else {
|
||||||
//Escape % and _ characters in the keyword
|
//Add a new expression and parameter set to the query for each token
|
||||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
foreach ($tokens as $i => $token) {
|
||||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
//Conditionally escape % and _ characters
|
||||||
}
|
if (!$this->wildcard)
|
||||||
}
|
$token = str_replace(['%', '_'], ['\%', '\_'], $token);
|
||||||
|
|
||||||
//Use equal expression to just search for exact numeric matches
|
//Convert the fields to search to a list of expressions
|
||||||
if ($search_dbId) {
|
$tmp = array_fill_keys($fields_to_search, $i);
|
||||||
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
|
$expressions2 = array_map(function (string $field, int $idx): string {
|
||||||
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
|
return sprintf("ILIKE(%s, :search_query%u) = TRUE", $field, $idx);
|
||||||
ParameterType::INTEGER);
|
}, 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Guard condition
|
//Guard condition
|
||||||
|
|
@ -169,7 +209,16 @@ class PartSearchFilter implements FilterInterface
|
||||||
$queryBuilder->andWhere(
|
$queryBuilder->andWhere(
|
||||||
$queryBuilder->expr()->orX(...$expressions)
|
$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
|
public function getKeyword(): string
|
||||||
|
|
@ -194,6 +243,30 @@ class PartSearchFilter implements FilterInterface
|
||||||
return $this;
|
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
|
public function isName(): bool
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->name;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{% macro settings_drodown(show_label_instead_icon = true) %}
|
{% macro settings_dropdown(show_label_instead_icon = true) %}
|
||||||
|
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn dropdown-toggle my-2" type="button" id="navbar-search-options" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-auto-close="true">
|
<button class="btn dropdown-toggle my-2" type="button" id="navbar-search-options" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-auto-close="true">
|
||||||
{% if show_label_instead_icon %}{% trans %}search.options.label{% endtrans %}{% else %}<i class="fa-solid fa-gear"></i>{% endif %}
|
{% if show_label_instead_icon %}{% trans %}search.options.label{% endtrans %}{% else %}<i class="fa-solid fa-gear"></i>{% endif %}
|
||||||
<span class="caret"></span>
|
<span class="caret"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" aria-labelledby="navbar-search-options">
|
<div class="dropdown-menu" aria-labelledby="navbar-search-options" {{ stimulus_controller('elements/search_config') }}>
|
||||||
<div class="px-2" style="width: max-content;">
|
<div class="px-2" style="width: max-content;">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||||
|
|
@ -66,10 +66,18 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="form-check">
|
<div class="form-check" title="{% trans %}search.regexmatching.help{% endtrans %}">
|
||||||
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }} {{ stimulus_target('elements/search_config', 'regex') }}>
|
||||||
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
|
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check" title="{% trans %}search.extensive_matching.help{% endtrans %}">
|
||||||
|
<input type="checkbox" class="form-check-input" id="extensive" name="extensive" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }} {{ stimulus_target('elements/search_config', 'extensive') }}>
|
||||||
|
<label for="extensive" class="form-check-label justify-content-start">{% trans %}search.extensive_matching{% endtrans %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check" title="{% trans %}search.permit_wildcards.help{% endtrans %}">
|
||||||
|
<input type="checkbox" class="form-check-input" id="wildcard" name="wildcard" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }} {{ stimulus_target('elements/search_config', 'wildcard') }}>
|
||||||
|
<label for="wildcard" class="form-check-label justify-content-start">{% trans %}search.permit_wildcards{% endtrans %}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -85,7 +93,7 @@
|
||||||
|
|
||||||
{# Show the options left in navbar #}
|
{# Show the options left in navbar #}
|
||||||
{% if is_navbar %}
|
{% if is_navbar %}
|
||||||
{{ _self.settings_drodown(is_navbar) }}
|
{{ _self.settings_dropdown(is_navbar) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div {{ stimulus_controller('elements/part_search') }}
|
<div {{ stimulus_controller('elements/part_search') }}
|
||||||
|
|
@ -103,7 +111,7 @@
|
||||||
|
|
||||||
{# And right in the standalone mode #}
|
{# And right in the standalone mode #}
|
||||||
{% if not is_navbar %}
|
{% if not is_navbar %}
|
||||||
{{ _self.settings_drodown(is_navbar) }}
|
{{ _self.settings_dropdown(is_navbar) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@
|
||||||
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.regex %}checked{% endif %}>
|
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.regex %}checked{% endif %}>
|
||||||
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
|
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.extensive %}checked{% endif %}>
|
||||||
|
<label for="extensive" class="form-check-label justify-content-start">{% trans %}search.extensive_matching{% endtrans %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" disabled {% if searchFilter.extensive %}checked{% endif %}>
|
||||||
|
<label for="wildcard" class="form-check-label justify-content-start">{% trans %}search.permit_wildcards{% endtrans %}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
227
tests/DataTables/Filters/PartSearchFilterTest.php
Normal file
227
tests/DataTables/Filters/PartSearchFilterTest.php
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
<?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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\DataTables\Filters;
|
||||||
|
|
||||||
|
use App\DataTables\Filters\PartSearchFilter;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
|
||||||
|
public function testApplyEnforcesNoResultsWhenKeywordEmpty(): void
|
||||||
|
{
|
||||||
|
$filter = new PartSearchFilter('');
|
||||||
|
|
||||||
|
$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('setParameters');
|
||||||
|
|
||||||
|
$filter->apply($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyEnforcesNoResultsWhenNothingToSearchForAndNoExactIdSearch(): void
|
||||||
|
{
|
||||||
|
$filter = (new PartSearchFilter('foo'))
|
||||||
|
->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->once())
|
||||||
|
->method('add')
|
||||||
|
->with('where', '1 = 0');
|
||||||
|
$qb->expects($this->never())->method('andWhere');
|
||||||
|
$qb->expects($this->never())->method('setParameters');
|
||||||
|
|
||||||
|
$filter->apply($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyUsesRegexExpressionAndRawParameterWhenRegexEnabled(): void
|
||||||
|
{
|
||||||
|
$filter = (new PartSearchFilter('foo.*bar'))
|
||||||
|
->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<Parameter>) 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(0);
|
||||||
|
$this->assertNotNull($p);
|
||||||
|
$this->assertSame('search_query', $p->getName());
|
||||||
|
$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'));
|
||||||
|
$expr = $this->createMock(Expr::class);
|
||||||
|
$expr->method('orX')->willReturn(new Orx());
|
||||||
|
|
||||||
|
$qb = $this->createMock(QueryBuilder::class);
|
||||||
|
$qb->method('expr')->willReturn($expr);
|
||||||
|
|
||||||
|
$qb->expects($this->never())->method('setParameter');
|
||||||
|
|
||||||
|
$qb->expects($this->once())
|
||||||
|
->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')
|
||||||
|
->with($this->isInstanceOf(Orx::class));
|
||||||
|
|
||||||
|
$filter->apply($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyAddsExactIdExpressionWhenDbIdSearchEnabledAndKeywordNumeric(): void
|
||||||
|
{
|
||||||
|
$filter = (new PartSearchFilter('123'))
|
||||||
|
->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->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('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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyDoesNotAddExactIdExpressionWhenKeywordNotNumeric(): void
|
||||||
|
{
|
||||||
|
$filter = (new PartSearchFilter('123abc'))
|
||||||
|
->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);
|
||||||
|
|
||||||
|
$qb->expects($this->never())->method('setParameter');
|
||||||
|
|
||||||
|
$qb->expects($this->once())
|
||||||
|
->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');
|
||||||
|
|
||||||
|
$filter->apply($qb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13715,6 +13715,34 @@ Buerklin-API Authentication server:
|
||||||
<target>You can use this randomly generated value (share it with nobody):</target>
|
<target>You can use this randomly generated value (share it with nobody):</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="pJfdMQU" name="search.extensive_matching">
|
||||||
|
<segment>
|
||||||
|
<source>search.extensive_matching</source>
|
||||||
|
<target>Extensive Matching</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="YO2ZB66" name="search.permit_wildcards">
|
||||||
|
<segment>
|
||||||
|
<source>search.permit_wildcards</source>
|
||||||
|
<target>Treat <code>%</code> and <code>_</code> as wildcards</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="_nxvnMn" name="search.regexmatching.help">
|
||||||
|
<segment>
|
||||||
|
<source>search.regexmatching.help</source>
|
||||||
|
<target>Use regular expressions like "^\w{3,5}" to search for parts. Does not require slashes padding.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="mry8ytG" name="search.extensive_matching.help">
|
||||||
|
<segment>
|
||||||
|
<source>search.extensive_matching.help</source>
|
||||||
|
<target>The search term is split up into parts, and a result is found, when each part is contained in any field. Without this mode the full search term has to be contained in a single field.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="zLnKtwP" name="search.permit_wildcards.help">
|
||||||
|
<segment>
|
||||||
|
<source>search.permit_wildcards.help</source>
|
||||||
|
<target>If enabled, _ is treated as a wildcard for one character and % is treated as a wildcard for zero or more characters.</target>
|
||||||
<unit id="OP7MacF" name="system.trusted_hosts.unconfigured.title">
|
<unit id="OP7MacF" name="system.trusted_hosts.unconfigured.title">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>system.trusted_hosts.unconfigured.title</source>
|
<source>system.trusted_hosts.unconfigured.title</source>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue