mirror of
https://github.com/Icinga/icingadb-web.git
synced 2026-06-09 08:42:14 -04:00
Introduce QueryColumnsProvider
This commit is contained in:
parent
5eb041940e
commit
31a24f3e8c
4 changed files with 478 additions and 338 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace Icinga\Module\Icingadb\Common;
|
||||
|
||||
use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
|
||||
use Icinga\Module\Icingadb\Data\QueryColumnsProvider;
|
||||
use ipl\Html\Html;
|
||||
use ipl\Orm\Query;
|
||||
use ipl\Web\Control\SearchBar;
|
||||
|
|
@ -19,7 +19,7 @@ trait SearchControls
|
|||
|
||||
public function fetchFilterColumns(Query $query): array
|
||||
{
|
||||
return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
|
||||
return iterator_to_array(QueryColumnsProvider::collectFilterColumns($query->getModel(), $query->getResolver()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
447
library/Icingadb/Data/QueryColumnsProvider.php
Normal file
447
library/Icingadb/Data/QueryColumnsProvider.php
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
namespace Icinga\Module\Icingadb\Data;
|
||||
|
||||
use Generator;
|
||||
use Icinga\Module\Icingadb\Common\Auth;
|
||||
use Icinga\Module\Icingadb\Common\Database;
|
||||
use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
|
||||
use Icinga\Module\Icingadb\Model\CustomvarFlat;
|
||||
use Icinga\Module\Icingadb\Model\Host;
|
||||
use Icinga\Module\Icingadb\Model\Service;
|
||||
use ipl\Orm\Model;
|
||||
use ipl\Orm\Query;
|
||||
use ipl\Orm\Relation;
|
||||
use ipl\Orm\Relation\BelongsToMany;
|
||||
use ipl\Orm\Relation\HasOne;
|
||||
use ipl\Orm\Resolver;
|
||||
use ipl\Orm\UnionModel;
|
||||
use ipl\Sql\Expression;
|
||||
use ipl\Sql\Select;
|
||||
use ipl\Stdlib\BaseFilter;
|
||||
use ipl\Stdlib\Filter;
|
||||
use ipl\Web\Control\SearchBar\Suggestions;
|
||||
use IteratorAggregate;
|
||||
|
||||
/**
|
||||
* Provide column and custom variable suggestions for a given query
|
||||
*/
|
||||
class QueryColumnsProvider implements IteratorAggregate
|
||||
{
|
||||
use Auth;
|
||||
use BaseFilter;
|
||||
use Database;
|
||||
|
||||
/** @var Query The query to collect columns from */
|
||||
protected Query $query;
|
||||
|
||||
/** @var string The search to suggest columns for */
|
||||
protected string $searchTerm;
|
||||
|
||||
/** @var ?array<string, string> Fixed columns as column => label, if null columns are selected from $query */
|
||||
protected ?array $fixedColumns = null;
|
||||
|
||||
/** @var array<string, string> Relations to suggest customvars from */
|
||||
protected array $customVarSources;
|
||||
|
||||
/**
|
||||
* Create a new QueryColumnsProvider
|
||||
*
|
||||
* @param Query $query
|
||||
* @param string $searchTerm
|
||||
*/
|
||||
public function __construct(Query $query, string $searchTerm = '*')
|
||||
{
|
||||
$this->query = $query;
|
||||
$this->searchTerm = $searchTerm;
|
||||
$this->customVarSources = static::getDefaultCustomVarSources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a fixed set of columns to suggest columns from
|
||||
*
|
||||
* @param array<string, string> $columns Columns as keys and labels as values
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setFixedColumns(array $columns): static
|
||||
{
|
||||
$this->fixedColumns = $columns;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the custom variable sources to use
|
||||
*
|
||||
* @param array<string, string> $customVarSources
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setCustomVarSources(array $customVarSources): static
|
||||
{
|
||||
$this->customVarSources = $customVarSources;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the search term to suggest columns for
|
||||
*
|
||||
* @param string $searchTerm
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setSearchTerm(string $searchTerm): static
|
||||
{
|
||||
$this->searchTerm = $searchTerm;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIterator(): Generator
|
||||
{
|
||||
$exactVarSearches = [];
|
||||
$parsedArrayVars = [];
|
||||
|
||||
yield from $this->fetchExactCustomVars($exactVarSearches, $parsedArrayVars);
|
||||
yield from $this->fetchColumns();
|
||||
yield from $this->fetchRemainingCustomVars($exactVarSearches, $parsedArrayVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch custom variables that exactly match the search term
|
||||
*
|
||||
* @param string[] $exactVarSearches Collects matched flatnames to exclude from remaining results
|
||||
* @param array<string, true> $parsedArrayVars Already yielded array variable base names
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
protected function fetchExactCustomVars(array &$exactVarSearches, array &$parsedArrayVars): Generator
|
||||
{
|
||||
$exactSearchTerm = trim($this->searchTerm, ' *');
|
||||
if ($exactSearchTerm === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (
|
||||
$this->getDb()->select($this->queryCustomvarConfig(
|
||||
Filter::any(
|
||||
Filter::equal('flatname', $exactSearchTerm),
|
||||
Filter::like('flatname', $exactSearchTerm . '[*]')
|
||||
)
|
||||
)) as $customVar
|
||||
) {
|
||||
$search = $name = $customVar->flatname;
|
||||
$exactVarSearches[] = $search;
|
||||
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
|
||||
$name = substr($search, 0, -(strlen($matches[1]) + 2));
|
||||
if (isset($parsedArrayVars[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedArrayVars[$name] = true;
|
||||
$search = $name . '[*]';
|
||||
}
|
||||
|
||||
foreach ($this->customVarSources as $relation => $label) {
|
||||
if (isset($customVar->$relation)) {
|
||||
yield [
|
||||
'search' => $relation . '.vars.' . $search,
|
||||
'label' => sprintf($label, $name),
|
||||
'group' => t('Best Suggestions')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch model column suggestions matching the search term
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
protected function fetchColumns(): Generator
|
||||
{
|
||||
$columns = $this->fixedColumns ?? self::collectFilterColumns(
|
||||
$this->query->getModel(),
|
||||
$this->query->getResolver()
|
||||
);
|
||||
foreach ($columns as $columnName => $columnMeta) {
|
||||
if ($this->matchSuggestion($columnName, $columnMeta, $this->searchTerm)) {
|
||||
yield [
|
||||
'search' => $columnName,
|
||||
'label' => $columnMeta,
|
||||
'group' => t('Columns')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch custom variable suggestions that were not already yielded by {@see self::fetchExactCustomVars}
|
||||
*
|
||||
* @param string[] $exactVarSearches Flatnames to exclude
|
||||
* @param array<string, true> $parsedArrayVars Already yielded array variables
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
protected function fetchRemainingCustomVars(array $exactVarSearches, array &$parsedArrayVars): Generator
|
||||
{
|
||||
if (! empty($exactVarSearches)) {
|
||||
$varFilter = Filter::all(
|
||||
Filter::like('flatname', $this->searchTerm),
|
||||
Filter::unequal('flatname', $exactVarSearches)
|
||||
);
|
||||
} else {
|
||||
$varFilter = Filter::like('flatname', $this->searchTerm);
|
||||
}
|
||||
|
||||
foreach (
|
||||
$this->getDb()->select($this->queryCustomvarConfig($varFilter)) as $customVar
|
||||
) {
|
||||
$search = $name = $customVar->flatname;
|
||||
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
|
||||
$name = substr($search, 0, -(strlen($matches[1]) + 2));
|
||||
if (isset($parsedArrayVars[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedArrayVars[$name] = true;
|
||||
$search = $name . '[*]';
|
||||
}
|
||||
|
||||
foreach ($this->customVarSources as $relation => $label) {
|
||||
if (isset($customVar->$relation)) {
|
||||
yield [
|
||||
'search' => $relation . '.vars.' . $search,
|
||||
'label' => sprintf($label, $name),
|
||||
'group' => t('Custom Variables')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all columns of this model and its relations that can be used for filtering
|
||||
*
|
||||
* @param Model $model
|
||||
* @param Resolver $resolver
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
|
||||
{
|
||||
if ($model instanceof UnionModel) {
|
||||
$models = [];
|
||||
foreach ($model->getUnions() as $union) {
|
||||
/** @var Model $unionModel */
|
||||
$unionModel = new $union[0]();
|
||||
$models[$unionModel->getTableName()] = $unionModel;
|
||||
self::collectRelations($resolver, $unionModel, $models, []);
|
||||
}
|
||||
} else {
|
||||
$models = [$model->getTableName() => $model];
|
||||
self::collectRelations($resolver, $model, $models, []);
|
||||
}
|
||||
|
||||
/** @var Model $targetModel */
|
||||
foreach ($models as $path => $targetModel) {
|
||||
foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
|
||||
yield $path . '.' . $columnName => $definition->getLabel();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($resolver->getBehaviors($model) as $behavior) {
|
||||
if ($behavior instanceof ReRoute) {
|
||||
foreach ($behavior->getRoutes() as $name => $route) {
|
||||
$relation = $resolver->resolveRelation(
|
||||
$resolver->qualifyPath($route, $model->getTableName()),
|
||||
$model
|
||||
);
|
||||
foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
|
||||
yield $name . '.' . $columnName => $definition->getLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($model instanceof UnionModel) {
|
||||
$queries = $model->getUnions();
|
||||
$baseModelClass = end($queries)[0];
|
||||
$model = new $baseModelClass();
|
||||
}
|
||||
|
||||
$foreignMetaDataSources = [];
|
||||
if (! $model instanceof Host) {
|
||||
$foreignMetaDataSources[] = 'host.user';
|
||||
$foreignMetaDataSources[] = 'host.usergroup';
|
||||
}
|
||||
|
||||
if (! $model instanceof Service) {
|
||||
$foreignMetaDataSources[] = 'service.user';
|
||||
$foreignMetaDataSources[] = 'service.usergroup';
|
||||
}
|
||||
|
||||
foreach ($foreignMetaDataSources as $path) {
|
||||
$foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
|
||||
$resolver->qualifyPath($path, $model->getTableName()),
|
||||
$model
|
||||
)->getTarget());
|
||||
foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
|
||||
yield "$path.$columnName" => $columnDefinition->getLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all direct relations of the given model
|
||||
*
|
||||
* A direct relation is either a direct descendant of the model
|
||||
* or a descendant of such related in a to-one cardinality.
|
||||
*
|
||||
* @param Resolver $resolver
|
||||
* @param Model $subject
|
||||
* @param array $models
|
||||
* @param array $path
|
||||
*/
|
||||
protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
|
||||
{
|
||||
foreach ($resolver->getRelations($subject) as $name => $relation) {
|
||||
/** @var Relation $relation */
|
||||
if (
|
||||
empty($path) || (
|
||||
($name === 'state' && $path[count($path) - 1] !== 'last_comment')
|
||||
|| $name === 'last_comment'
|
||||
|| $name === 'notificationcommand' && $path[0] === 'notification'
|
||||
)
|
||||
) {
|
||||
$relationPath = [$name];
|
||||
if ($relation instanceof HasOne && empty($path)) {
|
||||
array_unshift($relationPath, $subject->getTableName());
|
||||
}
|
||||
|
||||
$relationPath = array_merge($path, $relationPath);
|
||||
$models[join('.', $relationPath)] = $relation->getTarget();
|
||||
self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a query to fetch all available custom variables matching the given filter
|
||||
*
|
||||
* @param Filter\Rule $filter
|
||||
*
|
||||
* @return Select
|
||||
*/
|
||||
public function queryCustomvarConfig(Filter\Rule $filter): Select
|
||||
{
|
||||
$customVars = CustomvarFlat::on($this->getDb());
|
||||
$tableName = $customVars->getModel()->getTableName();
|
||||
$resolver = $customVars->getResolver();
|
||||
|
||||
$scalarQueries = [];
|
||||
$aggregates = ['flatname'];
|
||||
foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
|
||||
if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
|
||||
$query = $customVars->createSubQuery(
|
||||
$relation->getTarget(),
|
||||
$resolver->qualifyPath($name, $tableName)
|
||||
);
|
||||
|
||||
$this->applyBaseFilter($query);
|
||||
|
||||
$aggregates[$name] = new Expression("MAX($name)");
|
||||
$scalarQueries[$name] = $query->assembleSelect()
|
||||
->resetColumns()->columns(new Expression('1'))
|
||||
->limit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$customVars->columns('flatname');
|
||||
$this->applyRestrictions($customVars);
|
||||
$customVars->filter($filter);
|
||||
|
||||
// applyRestrictions() does not hide protected vars, but since querying them is not possible anymore,
|
||||
// we have to. Otherwise, the user can choose a protected var and get an error.
|
||||
$protectedVarFilter = Filter::any();
|
||||
foreach ($this->getAuth()->getRestrictions('icingadb/protect/variables') as $restriction) {
|
||||
$protectedVarFilter->add($this->parseDenylist($restriction, 'flatname'));
|
||||
}
|
||||
|
||||
$customVars->filter($protectedVarFilter);
|
||||
|
||||
$idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
|
||||
$customVars = $customVars->assembleSelect();
|
||||
|
||||
$customVars->columns($scalarQueries);
|
||||
$customVars->groupBy($idColumn);
|
||||
$customVars->limit(Suggestions::DEFAULT_LIMIT);
|
||||
|
||||
// This outer query exists only because there's no way to combine aggregates and sub queries (yet)
|
||||
return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default customvar sources
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getDefaultCustomVarSources()
|
||||
{
|
||||
return [
|
||||
'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
|
||||
'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
|
||||
'host' => t('Host %s', '..<customvar-name>'),
|
||||
'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
|
||||
'notification' => t('Notification %s', '..<customvar-name>'),
|
||||
'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
|
||||
'service' => t('Service %s', '..<customvar-name>'),
|
||||
'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
|
||||
'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
|
||||
'user' => t('Contact %s', '..<customvar-name>'),
|
||||
'usergroup' => t('Contactgroup %s', '..<customvar-name>')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given column path and label match the search term
|
||||
*
|
||||
* Exotic columns (id, bin, checksum) are only matched if the user typed the exact suffix.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $label
|
||||
* @param string $searchTerm
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function matchSuggestion($path, $label, $searchTerm)
|
||||
{
|
||||
if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
|
||||
// Only suggest exotic columns if the user knows about them
|
||||
$trimmedSearch = trim($searchTerm, ' *');
|
||||
return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
|
||||
}
|
||||
|
||||
return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply restrictions and the base filter to the given query
|
||||
*
|
||||
* @param Query $query
|
||||
*/
|
||||
private function applyBaseFilter(Query $query): void
|
||||
{
|
||||
$this->applyRestrictions($query);
|
||||
|
||||
if ($this->hasBaseFilter()) {
|
||||
$query->filter($this->getBaseFilter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,26 +4,15 @@
|
|||
|
||||
namespace Icinga\Module\Icingadb\Web\Control\SearchBar;
|
||||
|
||||
use Generator;
|
||||
use Icinga\Module\Icingadb\Common\Auth;
|
||||
use Icinga\Module\Icingadb\Common\Database;
|
||||
use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
|
||||
use Icinga\Module\Icingadb\Model\CustomvarFlat;
|
||||
use Icinga\Module\Icingadb\Model\Host;
|
||||
use Icinga\Module\Icingadb\Model\Service;
|
||||
use Icinga\Module\Icingadb\Data\QueryColumnsProvider;
|
||||
use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor;
|
||||
use ipl\Html\HtmlElement;
|
||||
use ipl\Orm\Exception\InvalidColumnException;
|
||||
use ipl\Orm\Exception\InvalidRelationException;
|
||||
use ipl\Orm\Model;
|
||||
use ipl\Orm\Query;
|
||||
use ipl\Orm\Relation;
|
||||
use ipl\Orm\Relation\BelongsToMany;
|
||||
use ipl\Orm\Relation\HasOne;
|
||||
use ipl\Orm\Resolver;
|
||||
use ipl\Orm\UnionModel;
|
||||
use ipl\Sql\Expression;
|
||||
use ipl\Sql\Select;
|
||||
use ipl\Stdlib\BaseFilter;
|
||||
use ipl\Stdlib\Filter;
|
||||
use ipl\Stdlib\Seq;
|
||||
|
|
@ -48,19 +37,7 @@ class ObjectSuggestions extends Suggestions
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->customVarSources = [
|
||||
'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
|
||||
'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
|
||||
'host' => t('Host %s', '..<customvar-name>'),
|
||||
'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
|
||||
'notification' => t('Notification %s', '..<customvar-name>'),
|
||||
'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
|
||||
'service' => t('Service %s', '..<customvar-name>'),
|
||||
'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
|
||||
'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
|
||||
'user' => t('Contact %s', '..<customvar-name>'),
|
||||
'usergroup' => t('Contactgroup %s', '..<customvar-name>')
|
||||
];
|
||||
$this->customVarSources = QueryColumnsProvider::getDefaultCustomVarSources();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,7 +134,8 @@ class ObjectSuggestions extends Suggestions
|
|||
if (strpos($column, ' ') !== false) {
|
||||
// $column may be a label
|
||||
list($path, $_) = Seq::find(
|
||||
$this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver()),
|
||||
$this->fixedColumns
|
||||
?? QueryColumnsProvider::collectFilterColumns($query->getModel(), $query->getResolver()),
|
||||
$column,
|
||||
false
|
||||
);
|
||||
|
|
@ -224,291 +202,38 @@ class ObjectSuggestions extends Suggestions
|
|||
$model = $this->getModel();
|
||||
$query = $model::on($this->getDb());
|
||||
|
||||
$parsedArrayVars = [];
|
||||
$exactSearchTerm = trim($searchTerm, ' *');
|
||||
$exactVarSearches = [];
|
||||
$titleAdded = false;
|
||||
$provider = (new QueryColumnsProvider($query, $searchTerm))
|
||||
->setCustomVarSources($this->customVarSources);
|
||||
|
||||
// Suggest exact custom variable matches first
|
||||
if ($exactSearchTerm !== '') {
|
||||
foreach (
|
||||
$this->getDb()->select($this->queryCustomvarConfig(
|
||||
Filter::any(
|
||||
Filter::equal('flatname', $exactSearchTerm),
|
||||
Filter::like('flatname', $exactSearchTerm . '[*]') // Filter for array type custom variables
|
||||
)
|
||||
)) as $customVar
|
||||
) {
|
||||
$search = $name = $customVar->flatname;
|
||||
$exactVarSearches[] = $search;
|
||||
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
|
||||
$name = substr($search, 0, -(strlen($matches[1]) + 2));
|
||||
if (isset($parsedArrayVars[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedArrayVars[$name] = true;
|
||||
$search = $name . '[*]';
|
||||
}
|
||||
|
||||
foreach ($this->customVarSources as $relation => $label) {
|
||||
if (isset($customVar->$relation)) {
|
||||
if ($titleAdded === false) {
|
||||
$this->addHtml(HtmlElement::create(
|
||||
'li',
|
||||
['class' => static::SUGGESTION_TITLE_CLASS],
|
||||
t('Best Suggestions')
|
||||
));
|
||||
|
||||
$titleAdded = true;
|
||||
}
|
||||
|
||||
yield $relation . '.vars.' . $search => sprintf($label, $name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($this->fixedColumns !== null) {
|
||||
$provider->setFixedColumns($this->fixedColumns);
|
||||
}
|
||||
|
||||
// Ordinary columns comes after exact matches,
|
||||
// or if there ar no exact matches they come first
|
||||
$titleAdded = false;
|
||||
$columns = $this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver());
|
||||
foreach ($columns as $columnName => $columnMeta) {
|
||||
if ($this->matchSuggestion($columnName, $columnMeta, $searchTerm)) {
|
||||
if ($titleAdded === false) {
|
||||
$this->addHtml(HtmlElement::create(
|
||||
'li',
|
||||
['class' => static::SUGGESTION_TITLE_CLASS],
|
||||
t('Columns')
|
||||
));
|
||||
|
||||
$titleAdded = true;
|
||||
}
|
||||
|
||||
yield $columnName => $columnMeta;
|
||||
}
|
||||
if ($this->hasBaseFilter()) {
|
||||
$provider->setBaseFilter($this->getBaseFilter());
|
||||
}
|
||||
|
||||
// Finally, the other custom variable suggestions
|
||||
$titleAdded = false;
|
||||
if (! empty($exactVarSearches)) {
|
||||
$varFilter = Filter::all(
|
||||
Filter::like('flatname', $searchTerm),
|
||||
Filter::unequal('flatname', $exactVarSearches)
|
||||
);
|
||||
} else {
|
||||
$varFilter = Filter::like('flatname', $searchTerm);
|
||||
}
|
||||
|
||||
foreach ($this->getDb()->select($this->queryCustomvarConfig($varFilter)) as $customVar) {
|
||||
$search = $name = $customVar->flatname;
|
||||
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
|
||||
$name = substr($search, 0, -(strlen($matches[1]) + 2));
|
||||
if (isset($parsedArrayVars[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedArrayVars[$name] = true;
|
||||
$search = $name . '[*]';
|
||||
$currentGroup = null;
|
||||
foreach ($provider as $item) {
|
||||
if (isset($item['group']) && $item['group'] !== $currentGroup) {
|
||||
$currentGroup = $item['group'];
|
||||
$this->addHtml(HtmlElement::create(
|
||||
'li',
|
||||
['class' => static::SUGGESTION_TITLE_CLASS],
|
||||
$currentGroup
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($this->customVarSources as $relation => $label) {
|
||||
if (isset($customVar->$relation)) {
|
||||
// Suggest exact custom variable matches first
|
||||
if ($titleAdded === false) {
|
||||
$this->addHtml(HtmlElement::create(
|
||||
'li',
|
||||
['class' => static::SUGGESTION_TITLE_CLASS],
|
||||
t('Custom Variables')
|
||||
));
|
||||
|
||||
$titleAdded = true;
|
||||
}
|
||||
|
||||
yield $relation . '.vars.' . $search => sprintf($label, $name);
|
||||
}
|
||||
}
|
||||
yield $item['search'] => $item['label'];
|
||||
}
|
||||
}
|
||||
|
||||
protected function matchSuggestion($path, $label, $searchTerm)
|
||||
{
|
||||
if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
|
||||
// Only suggest exotic columns if the user knows about them
|
||||
$trimmedSearch = trim($searchTerm, ' *');
|
||||
return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
|
||||
}
|
||||
|
||||
return parent::matchSuggestion($path, $label, $searchTerm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a query to fetch all available custom variables matching the given filter
|
||||
*
|
||||
* @param Filter\Rule $filter
|
||||
*
|
||||
* @return Select
|
||||
*/
|
||||
public function queryCustomvarConfig(Filter\Rule $filter): Select
|
||||
{
|
||||
$customVars = CustomvarFlat::on($this->getDb());
|
||||
$tableName = $customVars->getModel()->getTableName();
|
||||
$resolver = $customVars->getResolver();
|
||||
|
||||
$scalarQueries = [];
|
||||
$aggregates = ['flatname'];
|
||||
foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
|
||||
if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
|
||||
$query = $customVars->createSubQuery(
|
||||
$relation->getTarget(),
|
||||
$resolver->qualifyPath($name, $tableName)
|
||||
);
|
||||
|
||||
$this->applyBaseFilter($query);
|
||||
|
||||
$aggregates[$name] = new Expression("MAX($name)");
|
||||
$scalarQueries[$name] = $query->assembleSelect()
|
||||
->resetColumns()->columns(new Expression('1'))
|
||||
->limit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$customVars->columns('flatname');
|
||||
$this->applyRestrictions($customVars);
|
||||
$customVars->filter($filter);
|
||||
|
||||
// applyRestrictions() does not hide protected vars, but since querying them is not possible anymore,
|
||||
// we have to. Otherwise, the user can choose a protected var and get an error.
|
||||
$protectedVarFilter = Filter::any();
|
||||
foreach ($this->getAuth()->getRestrictions('icingadb/protect/variables') as $restriction) {
|
||||
$protectedVarFilter->add($this->parseDenylist($restriction, 'flatname'));
|
||||
}
|
||||
|
||||
$customVars->filter($protectedVarFilter);
|
||||
|
||||
$idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
|
||||
$customVars = $customVars->assembleSelect();
|
||||
|
||||
$customVars->columns($scalarQueries);
|
||||
$customVars->groupBy($idColumn);
|
||||
$customVars->limit(static::DEFAULT_LIMIT);
|
||||
|
||||
// This outer query exists only because there's no way to combine aggregates and sub queries (yet)
|
||||
return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
|
||||
}
|
||||
|
||||
protected function filterColumnSuggestions($data, $searchTerm)
|
||||
{
|
||||
// Remove filtering here, as fetchColumnSuggestions already performs it
|
||||
yield from $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all columns of this model and its relations that can be used for filtering
|
||||
*
|
||||
* @param Model $model
|
||||
* @param Resolver $resolver
|
||||
*
|
||||
* @return Generator
|
||||
*/
|
||||
public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
|
||||
{
|
||||
if ($model instanceof UnionModel) {
|
||||
$models = [];
|
||||
foreach ($model->getUnions() as $union) {
|
||||
/** @var Model $unionModel */
|
||||
$unionModel = new $union[0]();
|
||||
$models[$unionModel->getTableName()] = $unionModel;
|
||||
self::collectRelations($resolver, $unionModel, $models, []);
|
||||
}
|
||||
} else {
|
||||
$models = [$model->getTableName() => $model];
|
||||
self::collectRelations($resolver, $model, $models, []);
|
||||
}
|
||||
|
||||
/** @var Model $targetModel */
|
||||
foreach ($models as $path => $targetModel) {
|
||||
foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
|
||||
yield $path . '.' . $columnName => $definition->getLabel();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($resolver->getBehaviors($model) as $behavior) {
|
||||
if ($behavior instanceof ReRoute) {
|
||||
foreach ($behavior->getRoutes() as $name => $route) {
|
||||
$relation = $resolver->resolveRelation(
|
||||
$resolver->qualifyPath($route, $model->getTableName()),
|
||||
$model
|
||||
);
|
||||
foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
|
||||
yield $name . '.' . $columnName => $definition->getLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($model instanceof UnionModel) {
|
||||
$queries = $model->getUnions();
|
||||
$baseModelClass = end($queries)[0];
|
||||
$model = new $baseModelClass();
|
||||
}
|
||||
|
||||
$foreignMetaDataSources = [];
|
||||
if (! $model instanceof Host) {
|
||||
$foreignMetaDataSources[] = 'host.user';
|
||||
$foreignMetaDataSources[] = 'host.usergroup';
|
||||
}
|
||||
|
||||
if (! $model instanceof Service) {
|
||||
$foreignMetaDataSources[] = 'service.user';
|
||||
$foreignMetaDataSources[] = 'service.usergroup';
|
||||
}
|
||||
|
||||
foreach ($foreignMetaDataSources as $path) {
|
||||
$foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
|
||||
$resolver->qualifyPath($path, $model->getTableName()),
|
||||
$model
|
||||
)->getTarget());
|
||||
foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
|
||||
yield "$path.$columnName" => $columnDefinition->getLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all direct relations of the given model
|
||||
*
|
||||
* A direct relation is either a direct descendant of the model
|
||||
* or a descendant of such related in a to-one cardinality.
|
||||
*
|
||||
* @param Resolver $resolver
|
||||
* @param Model $subject
|
||||
* @param array $models
|
||||
* @param array $path
|
||||
*/
|
||||
protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
|
||||
{
|
||||
foreach ($resolver->getRelations($subject) as $name => $relation) {
|
||||
/** @var Relation $relation */
|
||||
if (
|
||||
empty($path) || (
|
||||
($name === 'state' && $path[count($path) - 1] !== 'last_comment')
|
||||
|| $name === 'last_comment'
|
||||
|| $name === 'notificationcommand' && $path[0] === 'notification'
|
||||
)
|
||||
) {
|
||||
$relationPath = [$name];
|
||||
if ($relation instanceof HasOne && empty($path)) {
|
||||
array_unshift($relationPath, $subject->getTableName());
|
||||
}
|
||||
|
||||
$relationPath = array_merge($path, $relationPath);
|
||||
$models[join('.', $relationPath)] = $relation->getTarget();
|
||||
self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce {@see $customVarSources} to only given relations to fetch variables from
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ use Icinga\Module\Icingadb\Common\Model;
|
|||
use Icinga\Module\Icingadb\Common\SearchControls;
|
||||
use Icinga\Module\Icingadb\Data\CsvResultSet;
|
||||
use Icinga\Module\Icingadb\Data\JsonResultSet;
|
||||
use Icinga\Module\Icingadb\Data\QueryColumnsProvider;
|
||||
use Icinga\Module\Icingadb\Web\Control\ColumnChooser;
|
||||
use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher;
|
||||
use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
|
||||
use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
|
||||
use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;
|
||||
use Icinga\Module\Pdfexport\PrintableHtmlDocument;
|
||||
|
|
@ -38,7 +38,6 @@ use Icinga\Util\Json;
|
|||
use ipl\Html\Html;
|
||||
use ipl\Html\ValidHtml;
|
||||
use ipl\Orm\Query;
|
||||
use ipl\Orm\Resolver;
|
||||
use ipl\Orm\UnionQuery;
|
||||
use ipl\Stdlib\Filter;
|
||||
use ipl\Web\Compat\CompatController;
|
||||
|
|
@ -517,51 +516,20 @@ class Controller extends CompatController
|
|||
*/
|
||||
protected function suggestColumns(Model $model): void
|
||||
{
|
||||
$resolver = new Resolver($model::on($this->getDb()));
|
||||
|
||||
$select = (new ObjectSuggestions())->queryCustomvarConfig(Filter::Any());
|
||||
|
||||
$customVars = [];
|
||||
$parsedArrayVars = [];
|
||||
foreach ($this->getDb()->select($select) as $customVar) {
|
||||
$search = $customVar->flatname;
|
||||
if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
|
||||
$name = substr($search, 0, -(strlen($matches[1]) + 2));
|
||||
if (isset($parsedArrayVars[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedArrayVars[$name] = true;
|
||||
$search = $name . '[*]';
|
||||
}
|
||||
|
||||
foreach ($customVar as $key => $value) {
|
||||
if ($key !== 'flatname' && $value === 1) {
|
||||
$var = $key . '.vars.' . $search;
|
||||
$customVars[$var] = $resolver->getColumnDefinition($var)->getLabel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_merge(
|
||||
$customVars,
|
||||
array_unique(iterator_to_array(ObjectSuggestions::collectFilterColumns($model, $resolver)))
|
||||
);
|
||||
|
||||
$query = $model::on($this->getDb());
|
||||
$provider = new QueryColumnsProvider($query);
|
||||
$suggestions = new SearchSuggestions(
|
||||
(function () use (&$suggestions, $columns) {
|
||||
foreach ($columns as $column => $label) {
|
||||
if (
|
||||
! in_array($column, $suggestions->getExcludeTerms())
|
||||
&& $suggestions->matchSearch($label)
|
||||
) {
|
||||
yield ['search' => $column, 'label' => $label, 'title' => $label];
|
||||
(function () use (&$suggestions, $provider) {
|
||||
$provider->setSearchTerm($suggestions->getSearchTerm());
|
||||
foreach ($provider as $suggestion) {
|
||||
if (! in_array($suggestion['search'], $suggestions->getExcludeTerms())) {
|
||||
yield $suggestion;
|
||||
}
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
$suggestions->forRequest(ServerRequest::fromGlobals());
|
||||
$suggestions->setGroupingCallback(fn($x) => $x['group']);
|
||||
$this->getDocument()->addHtml($suggestions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue