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.
This commit is contained in:
Sukhwinder Dhillon 2026-05-13 11:16:47 +02:00
parent 8736be428f
commit 9d1dfd92d3
2 changed files with 123 additions and 66 deletions

View file

@ -0,0 +1,115 @@
<?php
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// 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()));
}
}
}

View file

@ -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)