. * * @package LibreNMS * @link http://librenms.org * @copyright 2018 Tony Murray * @author Tony Murray */ namespace LibreNMS\Alerting; use LibreNMS\Config; use LibreNMS\DB\Schema; class QueryBuilderParser implements \JsonSerializable { private static $legacy_operators = [ '=' => 'equal', '!=' => 'not_equal', '~' => 'regex', '!~' => 'not_regex', '<' => 'less', '>' => 'greater', '<=' => 'less_or_equal', '>=' => 'greater_or_equal', ]; private static $operators = [ 'equal' => "=", 'not_equal' => "!=", 'less' => "<", 'less_or_equal' => "<=", 'greater' => ">", 'greater_or_equal' => ">=", 'between' => 'BETWEEN', 'not_between' => 'NOT BETWEEN', 'begins_with' => "LIKE", 'not_begins_with' => "NOT LIKE", 'contains' => "LIKE", 'not_contains' => "NOT LIKE", 'ends_with' => "LIKE", 'not_ends_with' => "NOT LIKE", 'is_empty' => "=", 'is_not_empty' => "!=", 'is_null' => "IS NULL", 'is_not_null' => "IS NOT NULL", 'regex' => 'REGEXP', 'not_regex' => 'NOT REGEXP', ]; private static $values = [ 'between' => "? AND ?", 'not_between' => "? AND ?", 'begins_with' => "'?%'", 'not_begins_with' => "'?%'", 'contains' => "'%?%'", 'not_contains' => "'%?%'", 'ends_with' => "'%?'", 'not_ends_with' => "'%?'", 'is_null' => "", 'is_not_null' => "", 'is_empty' => "''", 'is_not_empty' => "''", ]; private $builder; private $schema; private function __construct(array $builder) { $this->builder = $builder; $this->schema = new Schema(); } /** * Get all tables used by this rule * * @return array */ public function getTables() { if (!isset($this->tables)) { $this->tables = $this->findTablesRecursive($this->builder); } return $this->tables; } /** * Recursively find tables (including expanding macros) in the given rules * * @param array $rules * @return array List of tables found in rules */ private function findTablesRecursive($rules) { $tables = []; foreach ($rules['rules'] as $rule) { if (array_key_exists('rules', $rule)) { $tables = array_merge($this->findTablesRecursive($rule), $tables); } elseif (str_contains($rule['field'], '.')) { list($table, $column) = explode('.', $rule['field']); if ($table == 'macros') { $tables = array_merge($this->expandMacro($rule['field'], true), $tables); } else { $tables[] = $table; } } } // resolve glue tables (remove duplicates) foreach (array_keys(array_flip($tables)) as $table) { $rp = $this->schema->findRelationshipPath($table); if ($rp) { $tables = array_merge($rp, $tables); } } // remove duplicates return array_keys(array_flip($tables)); } /** * Initialize this from json generated by jQuery QueryBuilder * * @param string|array $json * @return static */ public static function fromJson($json) { if (!is_array($json)) { $json = json_decode($json, true) ?: []; } return new static($json); } /** * Initialize this from a legacy LibreNMS rule * * @param string $query * @return static */ public static function fromOld($query) { $condition = null; $rules = []; $filter = new QueryBuilderFilter(); $split = array_chunk(preg_split('/(&&|\|\|)/', $query, -1, PREG_SPLIT_DELIM_CAPTURE), 2); foreach ($split as $chunk) { list($rule_text, $rule_operator) = $chunk; if (!isset($condition)) { // only allow one condition. Since old rules had no grouping, this should hold logically $condition = ($rule_operator == '||' ? 'OR' : 'AND'); } list($field, $op, $value) = preg_split('/ *([!=<>~]{1,2}) */', trim($rule_text), 2, PREG_SPLIT_DELIM_CAPTURE); $field = ltrim($field, '%'); // for rules missing values just use '= 1' $operator = isset(self::$legacy_operators[$op]) ? self::$legacy_operators[$op] : 'equal'; if (is_null($value)) { $value = '1'; } else { $value = trim($value, '"'); // value is a field, mark it with backticks if (starts_with($value, '%')) { $value = '`' . ltrim($value, '%') . '`'; } // replace regex placeholder, don't think we can safely convert to like operators if ($operator == 'regex' || $operator == 'not_regex') { $value = str_replace('@', '.*', $value); } } $filter_item = $filter->getFilter($field); $type = $filter_item['type']; $input = isset($filter_item['input']) ? $filter_item['input'] : 'text'; $rules[] = [ 'id' => $field, 'field' => $field, 'type' => $type, 'input' => $input, 'operator' => $operator, 'value' => $value, ]; } $builder = [ 'condition' => $condition, 'rules' => $rules, 'valid' => true, ]; return new static($builder); } /** * Get the SQL for this rule, ready to execute with device_id supplied as the parameter * If $expand is false, this will return a more readable representation of the rule, but not executable. * * @param bool $expand * @return null|string The rule or null if this is invalid. */ public function toSql($expand = true) { if (empty($this->builder) || !array_key_exists('condition', $this->builder)) { return null; } $sql = ''; $wrap = false; if ($expand) { $sql = 'SELECT * FROM ' .implode(',', $this->getTables()); $sql .= ' WHERE ' . $this->generateGlue() . ' AND '; // only wrap in ( ) if the condition is OR and there is more than one rule $wrap = $this->builder['condition'] == 'OR' && count($this->builder['rules']) > 1; } return $sql . $this->parseGroup($this->builder, $expand, $wrap); } /** * Parse a rule group * * @param $rule * @param bool $expand Expand macros? * @param bool $wrap Wrap in parenthesis * @return string */ private function parseGroup($rule, $expand = false, $wrap = true) { $group_rules = []; foreach ($rule['rules'] as $group_rule) { if (array_key_exists('condition', $group_rule)) { $group_rules[] = $this->parseGroup($group_rule, $expand); } else { $group_rules[] = $this->parseRule($group_rule, $expand); } } $sql = implode(" {$rule['condition']} ", $group_rules); if ($wrap) { return "($sql)"; } else { return "$sql"; } } /** * Parse a rule * * @param $rule * @param bool $expand Expand macros? * @return string */ private function parseRule($rule, $expand = false) { $field = $rule['field']; $builder_op = $rule['operator']; $op = self::$operators[$builder_op]; $value = $rule['value']; if (is_string($value) && starts_with($value, '`') && ends_with($value, '`')) { // pass through value such as field $value = trim($value, '`'); if ($expand) { $value = $this->expandMacro($value); } } elseif (isset(self::$values[$builder_op])) { // wrap values as needed (is null values don't contain ? so '' is returned) $values = (array) $value; $value = preg_replace_callback('/\?/', function ($matches) use (&$values) { return array_shift($values); }, self::$values[$builder_op]); } elseif (!is_numeric($value)) { // wrap quotes around non-numeric values $value = "\"$value\""; } if ($expand) { $field = $this->expandMacro($field); } return trim("$field $op $value"); } /** * Expand macro to sql * * @param $subject * @param bool $tables_only Used when finding tables in query returns an array instead of sql string * @param int $depth_limit * @return string|array */ private function expandMacro($subject, $tables_only = false, $depth_limit = 20) { if (!str_contains($subject, 'macros.')) { return $subject; } $macros = Config::get('alert.macros.rule'); $count = 0; while ($count++ < $depth_limit && str_contains($subject, 'macros.')) { $subject = preg_replace_callback('/%?macros.([^ =()]+)/', function ($matches) use ($macros) { $name = $matches[1]; if (isset($macros[$name])) { return $macros[$name]; } else { return $matches[0]; // this isn't a macro, don't replace } }, $subject); } if ($tables_only) { preg_match_all('/%([^%.]+)\./', $subject, $matches); return array_unique($matches[1]); } // clean leading % $subject = preg_replace('/%([^%.]+)\./', '$1.', $subject); // wrap entire macro result in parenthesis if needed if (!(starts_with($subject, '(') && ends_with($subject, ')'))) { $subject = "($subject)"; } return $subject; } /** * Generate glue and first part of sql query for this rule * * @param string $target the name of the table to target, for alerting, this should be devices * @return string */ private function generateGlue($target = 'devices') { $tables = $this->getTables(); // get all tables in query // always add the anchor to the target table $anchor = $target . '.' . $this->schema->getPrimaryKey($target) . ' = ?'; $glue = [$anchor]; foreach ($tables as $table) { $path = $this->schema->findRelationshipPath($table, $target); if ($path) { foreach (array_pairs($path) as $pair) { list($left, $right) = $pair; $glue[] = $this->getGlue($left, $right); } } } // remove duplicates $glue = array_unique($glue); return '(' . implode(' AND ', $glue) . ')'; } /** * Get glue sql between tables. Resolve fields to use. * * @param string $parent * @param string $child * @return string */ public function getGlue($parent, $child) { // first check to see if there is a single shared column name ending with _id $shared_keys = array_filter(array_intersect( $this->schema->getColumns($parent), $this->schema->getColumns($child) ), function ($table) { return ends_with($table, '_id'); }); if (count($shared_keys) === 1) { $shared_key = reset($shared_keys); return "$parent.$shared_key = $child.$shared_key"; } $parent_key = $this->schema->getPrimaryKey($parent); $flipped = empty($parent_key); if ($flipped) { // if the "parent" table doesn't have a primary key, flip them list($parent, $child) = [$child, $parent]; $parent_key = $this->schema->getPrimaryKey($parent); } $child_key = $parent_key; // assume the column names match if (!$this->schema->columnExists($child, $child_key)) { // if they don't match, guess the column name from the parent if (ends_with($parent, 'xes')) { $child_key = substr($parent, 0, -2) . '_id'; } else { $child_key = preg_replace('/s$/', '_id', $parent); } if (!$this->schema->columnExists($child, $child_key)) { echo"FIXME: Could not make glue from $child to $parent\n"; } } if ($flipped) { return "$child.$child_key = $parent.$parent_key"; } return "$parent.$parent_key = $child.$child_key"; } /** * Get an array of this rule ready for jQuery QueryBuilder * * @return array */ public function toArray() { return $this->builder; } /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php * @return mixed data which can be serialized by json_encode, * which is a value of any type other than a resource. * @since 5.4.0 */ public function jsonSerialize() { return $this->builder; } }