From 9d1dfd92d307f7a6ce600b5860bb4258537c28c3 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 13 May 2026 11:16:47 +0200 Subject: [PATCH] Introduce `QueryValuesProvider` Decouple value suggestion logic into a standalone `Generator` instance. This allows callers to fetch column values without any `Suggestions` context. A plain `foreach` on the provider is enough. `ObjectSuggestions::fetchValueSuggestions` is reduced to a thin wrapper that resolves label-to-path input before delegating to the new class. --- library/Icingadb/Data/QueryValuesProvider.php | 115 ++++++++++++++++++ .../Control/SearchBar/ObjectSuggestions.php | 74 ++--------- 2 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 library/Icingadb/Data/QueryValuesProvider.php diff --git a/library/Icingadb/Data/QueryValuesProvider.php b/library/Icingadb/Data/QueryValuesProvider.php new file mode 100644 index 00000000..1c2eeaee --- /dev/null +++ b/library/Icingadb/Data/QueryValuesProvider.php @@ -0,0 +1,115 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Icingadb\Data; + +use Icinga\Module\Icingadb\Common\Auth; +use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor; +use ipl\I18n\Translation; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Query; +use ipl\Stdlib\BaseFilter; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchBar\SearchException; +use IteratorAggregate; +use PDO; +use Traversable; + +/** + * Provide value suggestions for a given column and query + */ +class QueryValuesProvider implements IteratorAggregate +{ + use Auth; + use BaseFilter; + use Translation; + + /** @var Query The query to collect values from */ + protected Query $query; + + /** @var string The column to suggest values for */ + protected string $column; + + /** @var string The search term to suggest values for */ + protected string $searchTerm; + + /** @var Filter\Chain|Filter\Rule The search filter to apply */ + protected Filter\Chain|Filter\Rule $searchFilter; + + /** + * Create a new QueryValuesProvider + * + * @param Query $query The query to collect values from + * @param string $column The column to suggest values for + * @param string $searchTerm The search term to suggest values for + * @param Filter\Chain $searchFilter The search filter to apply + */ + public function __construct(Query $query, string $column, string $searchTerm, Filter\Chain $searchFilter) + { + $this->query = $query; + $this->column = $column; + $this->searchTerm = $searchTerm; + $this->searchFilter = $searchFilter; + } + + public function getIterator(): Traversable + { + $columnPath = $this->query->getResolver()->qualifyPath($this->column, $this->query->getModel()->getTableName()); + [$targetPath, $columnName] = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2); + + $isCustomVar = false; + if (str_ends_with($targetPath, '.vars')) { + $isCustomVar = true; + $targetPath = substr($targetPath, 0, -4) . 'customvar_flat'; + } + + if (str_contains($targetPath, '.')) { + try { + $this->query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early + } catch (InvalidRelationException $e) { + throw new SearchException(sprintf($this->translate('"%s" is not a valid relation'), $e->getRelation())); + } + } + + if ($isCustomVar) { + $columnPath = $targetPath . '.flatvalue'; + $this->query->filter(Filter::like($targetPath . '.flatname', $columnName)); + } + + $inputFilter = Filter::like($columnPath, $this->searchTerm); + $this->query->columns($columnPath); + $this->query->orderBy($columnPath); + + if ($this->searchFilter instanceof Filter\None) { + $this->query->filter($inputFilter); + } elseif ($this->searchFilter instanceof Filter\All) { + $this->searchFilter->add($inputFilter); + + // There may be columns part of $searchFilter which target the base table. These must be + // optimized, otherwise they influence what we'll suggest to the user. (i.e. less) + // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since + // we force optimization on its parent chain, we have to negate that. + $this->searchFilter->metaData()->set('forceOptimization', true); + $inputFilter->metaData()->set('forceOptimization', false); + } else { + $this->searchFilter = $inputFilter; + } + + $this->query->filter($this->searchFilter); + + $this->applyRestrictions($this->query); + if ($this->hasBaseFilter()) { + $this->query->filter($this->getBaseFilter()); + } + + try { + return (new ObjectSuggestionsCursor($this->query->getDb(), $this->query->assembleSelect()->distinct())) + ->setFetchMode(PDO::FETCH_COLUMN); + } catch (InvalidColumnException $e) { + throw new SearchException(sprintf($this->translate('"%s" is not a valid column'), $e->getColumn())); + } + } +} \ No newline at end of file diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php index e5967484..2a36e47a 100644 --- a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php +++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php @@ -8,18 +8,13 @@ namespace Icinga\Module\Icingadb\Web\Control\SearchBar; use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\Database; use Icinga\Module\Icingadb\Data\QueryColumnsProvider; -use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor; +use Icinga\Module\Icingadb\Data\QueryValuesProvider; use ipl\Html\HtmlElement; -use ipl\Orm\Exception\InvalidColumnException; -use ipl\Orm\Exception\InvalidRelationException; use ipl\Orm\Model; -use ipl\Orm\Query; use ipl\Stdlib\BaseFilter; use ipl\Stdlib\Filter; use ipl\Stdlib\Seq; -use ipl\Web\Control\SearchBar\SearchException; use ipl\Web\Control\SearchBar\Suggestions; -use PDO; class ObjectSuggestions extends Suggestions { @@ -92,15 +87,6 @@ class ObjectSuggestions extends Suggestions return QueryColumnsProvider::shouldShowRelationFor($column, $this->getModel()); } - private function applyBaseFilter(Query $query): void - { - $this->applyRestrictions($query); - - if ($this->hasBaseFilter()) { - $query->filter($this->getBaseFilter()); - } - } - protected function createQuickSearchFilter($searchTerm) { $model = $this->getModel(); @@ -126,11 +112,11 @@ class ObjectSuggestions extends Suggestions $query = $model::on($this->getDb()); $query->limit(static::DEFAULT_LIMIT); - if (strpos($column, ' ') !== false) { + if (str_contains($column, ' ')) { // $column may be a label - list($path, $_) = Seq::find( + [$path, $_] = Seq::find( $this->fixedColumns - ?? QueryColumnsProvider::collectFilterColumns($query->getModel(), $query->getResolver()), + ?? QueryColumnsProvider::collectFilterColumns($query->getModel(), $query->getResolver()), $column, false ); @@ -139,57 +125,13 @@ class ObjectSuggestions extends Suggestions } } - $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName()); - list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2); + $provider = new QueryValuesProvider($query, $column, $searchTerm, $searchFilter); - $isCustomVar = false; - if (substr($targetPath, -5) === '.vars') { - $isCustomVar = true; - $targetPath = substr($targetPath, 0, -4) . 'customvar_flat'; + if ($this->hasBaseFilter()) { + $provider->setBaseFilter($this->getBaseFilter()); } - if (strpos($targetPath, '.') !== false) { - try { - $query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early - } catch (InvalidRelationException $e) { - throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation())); - } - } - - if ($isCustomVar) { - $columnPath = $targetPath . '.flatvalue'; - $query->filter(Filter::like($targetPath . '.flatname', $columnName)); - } - - $inputFilter = Filter::like($columnPath, $searchTerm); - $query->columns($columnPath); - $query->orderBy($columnPath); - - // This had so many iterations, if it still doesn't work, consider removing it entirely :( - if ($searchFilter instanceof Filter\None) { - $query->filter($inputFilter); - } elseif ($searchFilter instanceof Filter\All) { - $searchFilter->add($inputFilter); - - // There may be columns part of $searchFilter which target the base table. These must be - // optimized, otherwise they influence what we'll suggest to the user. (i.e. less) - // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since - // we force optimization on its parent chain, we have to negate that. - $searchFilter->metaData()->set('forceOptimization', true); - $inputFilter->metaData()->set('forceOptimization', false); - } else { - $searchFilter = $inputFilter; - } - - $query->filter($searchFilter); - $this->applyBaseFilter($query); - - try { - return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct())) - ->setFetchMode(PDO::FETCH_COLUMN); - } catch (InvalidColumnException $e) { - throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn())); - } + return $provider; } protected function fetchColumnSuggestions($searchTerm)