diff --git a/library/Icingadb/Common/SearchControls.php b/library/Icingadb/Common/SearchControls.php index 4fe700a7..e90700ee 100644 --- a/library/Icingadb/Common/SearchControls.php +++ b/library/Icingadb/Common/SearchControls.php @@ -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())); } /** diff --git a/library/Icingadb/Data/QueryColumnsProvider.php b/library/Icingadb/Data/QueryColumnsProvider.php new file mode 100644 index 00000000..c2e333b7 --- /dev/null +++ b/library/Icingadb/Data/QueryColumnsProvider.php @@ -0,0 +1,447 @@ + +// 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 Fixed columns as column => label, if null columns are selected from $query */ + protected ?array $fixedColumns = null; + + /** @var array 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 $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 $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 $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 $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 + */ + public static function getDefaultCustomVarSources() + { + return [ + 'checkcommand' => t('Checkcommand %s', '..'), + 'eventcommand' => t('Eventcommand %s', '..'), + 'host' => t('Host %s', '..'), + 'hostgroup' => t('Hostgroup %s', '..'), + 'notification' => t('Notification %s', '..'), + 'notificationcommand' => t('Notificationcommand %s', '..'), + 'service' => t('Service %s', '..'), + 'servicegroup' => t('Servicegroup %s', '..'), + 'timeperiod' => t('Timeperiod %s', '..'), + 'user' => t('Contact %s', '..'), + 'usergroup' => t('Contactgroup %s', '..') + ]; + } + + /** + * 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()); + } + } +} diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php index 9543b745..1535a2cc 100644 --- a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php +++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php @@ -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', '..'), - 'eventcommand' => t('Eventcommand %s', '..'), - 'host' => t('Host %s', '..'), - 'hostgroup' => t('Hostgroup %s', '..'), - 'notification' => t('Notification %s', '..'), - 'notificationcommand' => t('Notificationcommand %s', '..'), - 'service' => t('Service %s', '..'), - 'servicegroup' => t('Servicegroup %s', '..'), - 'timeperiod' => t('Timeperiod %s', '..'), - 'user' => t('Contact %s', '..'), - 'usergroup' => t('Contactgroup %s', '..') - ]; + $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 * diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index fbb4f061..d4a673f8 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -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); } }