summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php406
1 files changed, 406 insertions, 0 deletions
diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..b89e729
--- /dev/null
+++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,406 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+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\Util\ObjectSuggestionsCursor;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Exception\InvalidRelationException;
+use ipl\Orm\Model;
+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\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+use PDO;
+
+class ObjectSuggestions extends Suggestions
+{
+ use Auth;
+ use Database;
+
+ /** @var Model */
+ protected $model;
+
+ /** @var array */
+ protected $customVarSources;
+
+ 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('User %s', '..<customvar-name>'),
+ 'usergroup' => t('Usergroup %s', '..<customvar-name>')
+ ];
+ }
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ /**
+ * Get the model to show suggestions for
+ *
+ * @return Model
+ */
+ public function getModel(): Model
+ {
+ if ($this->model === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->model;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ if (strpos($column, '.vars.') !== false) {
+ return false;
+ }
+
+ $tableName = $this->getModel()->getTableName();
+ $columnPath = explode('.', $column);
+
+ switch (count($columnPath)) {
+ case 3:
+ if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) {
+ return true;
+ }
+
+ // For host/service state relation columns apply the same rules
+ case 2:
+ return $columnPath[0] !== $tableName;
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->getModel();
+ $resolver = $model::on($this->getDb())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableName()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // $column may be a label
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName());
+ list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2);
+
+ $isCustomVar = false;
+ if (substr($targetPath, -5) === '.vars') {
+ $isCustomVar = true;
+ $targetPath = substr($targetPath, 0, -4) . 'customvar_flat';
+ }
+
+ 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->applyRestrictions($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()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+
+ // Ordinary columns first
+ foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) {
+ yield $columnName => $columnMeta;
+ }
+
+ // Custom variables only after the columns are exhausted and there's actually a chance the user sees them
+ $titleAdded = false;
+ $parsedArrayVars = [];
+ foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) 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)) {
+ if (! $titleAdded) {
+ $titleAdded = true;
+ $this->addHtml(HtmlElement::create(
+ 'li',
+ ['class' => static::SUGGESTION_TITLE_CLASS],
+ t('Custom Variables')
+ ));
+ }
+
+ yield $relation . '.vars.' . $search => sprintf($label, $name);
+ }
+ }
+ }
+ }
+
+ 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 term
+ *
+ * @param string $searchTerm
+ *
+ * @return Select
+ */
+ protected function queryCustomvarConfig(string $searchTerm): 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->applyRestrictions($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::like('flatname', $searchTerm));
+ $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');
+ }
+
+ /**
+ * 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);
+ }
+ }
+ }
+}