diff options
Diffstat (limited to 'library/Icinga/Data')
46 files changed, 5169 insertions, 0 deletions
diff --git a/library/Icinga/Data/ConfigObject.php b/library/Icinga/Data/ConfigObject.php new file mode 100644 index 0000000..c9a3134 --- /dev/null +++ b/library/Icinga/Data/ConfigObject.php @@ -0,0 +1,289 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Iterator; +use ArrayAccess; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Exception\ProgrammingError; + +/** + * Container for configuration values + */ +class ConfigObject extends ArrayDatasource implements Iterator, ArrayAccess +{ + /** + * Create a new config + * + * @param array $data The data to initialize the new config with + */ + public function __construct(array $data = array()) + { + // Convert all embedded arrays to ConfigObjects as well + foreach ($data as & $value) { + if (is_array($value)) { + $value = new static($value); + } + } + + parent::__construct($data); + } + + /** + * Deep clone this config + */ + public function __clone() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = clone $value; + } else { + $array[$key] = $value; + } + } + + $this->data = $array; + } + + /** + * Reset the current position of $this->data + * + * @return void + */ + public function rewind(): void + { + reset($this->data); + } + + /** + * Return the section's or property's value of the current iteration + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + return current($this->data); + } + + /** + * Return whether the position of the current iteration is valid + * + * @return bool + */ + public function valid(): bool + { + return key($this->data) !== null; + } + + /** + * Return the section's or property's name of the current iteration + * + * @return string + */ + public function key(): string + { + return key($this->data); + } + + /** + * Advance the position of the current iteration and return the new section's or property's value + * + * @return void + */ + public function next(): void + { + next($this->data); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function __isset($key) + { + return isset($this->data[$key]); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return mixed|NULL The value or NULL in case $key does not exist + */ + public function __get($key) + { + return $this->get($key); + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + */ + public function __set($key, $value) + { + if (is_array($value)) { + $this->data[$key] = new static($value); + } else { + $this->data[$key] = $value; + } + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function __unset($key) + { + unset($this->data[$key]); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function offsetExists($key): bool + { + return isset($this->$key); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return ?mixed The value or NULL in case $key does not exist + */ + #[\ReturnTypeWillChange] + public function offsetGet($key) + { + return $this->get($key); + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + * + * @throws ProgrammingError If the key is null + */ + public function offsetSet($key, $value): void + { + if ($key === null) { + throw new ProgrammingError('Appending values without an explicit key is not supported'); + } + + $this->$key = $value; + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function offsetUnset($key): void + { + unset($this->$key); + } + + /** + * Return whether this config has any data + * + * @return bool + */ + public function isEmpty() + { + return empty($this->data); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * @param mixed $default The value to return in case the property or section is missing + * + * @return mixed + */ + public function get($key, $default = null) + { + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + + return $default; + } + + /** + * Return all section and property names + * + * @return array + */ + public function keys() + { + return array_keys($this->data); + } + + /** + * Return this config's data as associative array + * + * @return array + */ + public function toArray() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = $value->toArray(); + } else { + $array[$key] = $value; + } + } + + return $array; + } + + /** + * Merge the given data with this config + * + * @param array|ConfigObject $data An array or a config + * + * @return $this + */ + public function merge($data) + { + if ($data instanceof self) { + $data = $data->toArray(); + } + + foreach ($data as $key => $value) { + if (array_key_exists($key, $this->data)) { + if (is_array($value)) { + if ($this->data[$key] instanceof self) { + $this->data[$key]->merge($value); + } else { + $this->data[$key] = new static($value); + } + } else { + $this->data[$key] = $value; + } + } else { + $this->data[$key] = is_array($value) ? new static($value) : $value; + } + } + + return $this; + } +} diff --git a/library/Icinga/Data/ConnectionInterface.php b/library/Icinga/Data/ConnectionInterface.php new file mode 100644 index 0000000..bd7d026 --- /dev/null +++ b/library/Icinga/Data/ConnectionInterface.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface ConnectionInterface extends Selectable, Queryable +{ +} diff --git a/library/Icinga/Data/DataArray/ArrayDatasource.php b/library/Icinga/Data/DataArray/ArrayDatasource.php new file mode 100644 index 0000000..e300616 --- /dev/null +++ b/library/Icinga/Data/DataArray/ArrayDatasource.php @@ -0,0 +1,292 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\DataArray; + +use ArrayIterator; +use Icinga\Data\Selectable; +use Icinga\Data\SimpleQuery; + +class ArrayDatasource implements Selectable +{ + /** + * The array being used as data source + * + * @var array + */ + protected $data; + + /** + * The current result + * + * @var array + */ + protected $result; + + /** + * The result of a counted query + * + * @var int + */ + protected $count; + + /** + * The name of the column to map array keys on + * + * In case the array being used as data source provides keys of type string,this name + * will be used to set such as column on each row, if the column is not set already. + * + * @var string + */ + protected $keyColumn; + + /** + * Create a new data source for the given array + * + * @param array $data The array you're going to use as a data source + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * Set the name of the column to map array keys on + * + * @param string $name + * + * @return $this + */ + public function setKeyColumn($name) + { + $this->keyColumn = $name; + return $this; + } + + /** + * Return the name of the column to map array keys on + * + * @return string + */ + public function getKeyColumn() + { + return $this->keyColumn; + } + + /** + * Provide a query for this data source + * + * @return SimpleQuery + */ + public function select() + { + return new SimpleQuery(clone $this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param SimpleQuery $query + * + * @return ArrayIterator + */ + public function query(SimpleQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Fetch and return a column of all rows of the result set as an array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchColumn(SimpleQuery $query) + { + $result = array(); + foreach ($this->getResult($query) as $row) { + $arr = (array) $row; + $result[] = array_shift($arr); + } + + return $result; + } + + /** + * Fetch and return all rows of the given query's result as a flattened key/value based array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchPairs(SimpleQuery $query) + { + $result = array(); + $keys = null; + foreach ($this->getResult($query) as $row) { + if ($keys === null) { + $keys = array_keys((array) $row); + if (count($keys) < 2) { + $keys[1] = $keys[0]; + } + } + + $result[$row->{$keys[0]}] = $row->{$keys[1]}; + } + + return $result; + } + + /** + * Fetch and return the first row of the given query's result + * + * @param SimpleQuery $query + * + * @return object|false The row or false in case the result is empty + */ + public function fetchRow(SimpleQuery $query) + { + $result = $this->getResult($query); + if (empty($result)) { + return false; + } + + return array_shift($result); + } + + /** + * Fetch and return all rows of the given query's result as an array + * + * @param SimpleQuery $query + * + * @return array + */ + public function fetchAll(SimpleQuery $query) + { + return $this->getResult($query); + } + + /** + * Count all rows of the given query's result + * + * @param SimpleQuery $query + * + * @return int + */ + public function count(SimpleQuery $query) + { + if ($this->count === null) { + $this->count = count($this->createResult($query)); + } + + return $this->count; + } + + /** + * Create and return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ + protected function createResult(SimpleQuery $query) + { + $columns = $query->getColumns(); + $filter = $query->getFilter(); + $offset = $query->hasOffset() ? $query->getOffset() : 0; + $limit = $query->hasLimit() ? $query->getLimit() : 0; + $data = $this->data; + + if ($query->hasOrder()) { + uasort($data, [$query, 'compare']); + } + + $foundStringKey = false; + $result = []; + $skipped = 0; + foreach ($data as $key => $row) { + if ($this->keyColumn !== null && !isset($row->{$this->keyColumn})) { + $row = clone $row; // Make sure that this won't affect the actual data + $row->{$this->keyColumn} = $key; + } + + if (! $filter->matches($row)) { + continue; + } elseif ($skipped < $offset) { + $skipped++; + continue; + } + + // Get only desired columns if asked so + if (! empty($columns)) { + $filteredRow = (object) array(); + foreach ($columns as $alias => $name) { + if (! is_string($alias)) { + $alias = $name; + } + + if (isset($row->$name)) { + $filteredRow->$alias = $row->$name; + } else { + $filteredRow->$alias = null; + } + } + } else { + $filteredRow = $row; + } + + $foundStringKey |= is_string($key); + $result[$key] = $filteredRow; + + if (count($result) === $limit) { + break; + } + } + + if (! $foundStringKey) { + $result = array_values($result); + } + + return $result; + } + + /** + * Return whether a query result exists + * + * @return bool + */ + protected function hasResult() + { + return $this->result !== null; + } + + /** + * Set the current result + * + * @param array $result + * + * @return $this + */ + protected function setResult(array $result) + { + $this->result = $result; + return $this; + } + + /** + * Return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ + protected function getResult(SimpleQuery $query) + { + if (! $this->hasResult()) { + $this->setResult($this->createResult($query)); + } + + return $this->result; + } +} diff --git a/library/Icinga/Data/Db/DbConnection.php b/library/Icinga/Data/Db/DbConnection.php new file mode 100644 index 0000000..fc6814d --- /dev/null +++ b/library/Icinga/Data/Db/DbConnection.php @@ -0,0 +1,655 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Db; + +use DateTime; +use DateTimeZone; +use Exception; +use Icinga\Data\Filter\FilterEqual; +use Icinga\Data\Filter\FilterNotEqual; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use PDO; +use Iterator; +use Zend_Db; +use Zend_Db_Expr; +use Icinga\Data\ConfigObject; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterOr; +use Icinga\Data\Reducible; +use Icinga\Data\ResourceFactory; +use Icinga\Data\Selectable; +use Icinga\Data\Updatable; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; + +/** + * Encapsulate database connections and query creation + */ +class DbConnection implements Selectable, Extensible, Updatable, Reducible, Inspectable +{ + /** + * Connection config + * + * @var ConfigObject + */ + private $config; + + /** + * Database type + * + * @var string + */ + private $dbType; + + /** + * @var \Zend_Db_Adapter_Abstract + */ + private $dbAdapter; + + /** + * Table prefix + * + * @var string + */ + private $tablePrefix = ''; + + private static $genericAdapterOptions = array( + Zend_Db::AUTO_QUOTE_IDENTIFIERS => false, + Zend_Db::CASE_FOLDING => Zend_Db::CASE_LOWER + ); + + private static $driverOptions = array( + PDO::ATTR_TIMEOUT => 10, + PDO::ATTR_CASE => PDO::CASE_LOWER, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ); + + /** + * Create a new connection object + * + * @param ConfigObject $config + */ + public function __construct(ConfigObject $config = null) + { + $this->config = $config; + $this->connect(); + } + + /** + * Provide a query on this connection + * + * @return DbQuery + */ + public function select() + { + return new DbQuery($this); + } + + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param DbQuery $query + * + * @return Iterator + */ + public function query(DbQuery $query) + { + return $query->getSelectQuery()->query(); + } + + /** + * Get the connection configuration + * + * @return ConfigObject + */ + public function getConfig() + { + return $this->config; + } + + /** + * Getter for database type + * + * @return string + */ + public function getDbType() + { + return $this->dbType; + } + + /** + * Getter for the Zend_Db_Adapter + * + * @return \Zend_Db_Adapter_Abstract + */ + public function getDbAdapter() + { + return $this->dbAdapter; + } + + /** + * Create a new connection + */ + private function connect() + { + $genericAdapterOptions = self::$genericAdapterOptions; + $driverOptions = self::$driverOptions; + $adapterParamaters = array( + 'host' => $this->config->host, + 'username' => $this->config->username, + 'password' => $this->config->password, + 'dbname' => $this->config->dbname, + 'charset' => $this->config->charset ?: null, + 'options' => & $genericAdapterOptions, + 'driver_options' => & $driverOptions + ); + $this->dbType = strtolower($this->config->get('db', 'mysql')); + switch ($this->dbType) { + case 'mssql': + $adapter = 'Pdo_Mssql'; + $pdoType = $this->config->get('pdoType'); + if (empty($pdoType)) { + if (extension_loaded('sqlsrv')) { + $adapter = 'Sqlsrv'; + } else { + $pdoType = 'dblib'; + } + } + if ($pdoType === 'dblib') { + // Driver does not support setting attributes + unset($adapterParamaters['options']); + unset($adapterParamaters['driver_options']); + } + if (! empty($pdoType)) { + $adapterParamaters['pdoType'] = $pdoType; + } + $defaultPort = 1433; + break; + case 'mysql': + $adapter = 'Pdo_Mysql'; + if ($this->config->use_ssl) { + # The presence of these keys as empty strings or null cause non-ssl connections to fail + if ($this->config->ssl_key) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_KEY] = $this->config->ssl_key; + } + if ($this->config->ssl_cert) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CERT] = $this->config->ssl_cert; + } + if ($this->config->ssl_ca) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CA] = $this->config->ssl_ca; + } + if ($this->config->ssl_capath) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CAPATH] = $this->config->ssl_capath; + } + if ($this->config->ssl_cipher) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_CIPHER] = $this->config->ssl_cipher; + } + if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && $this->config->ssl_do_not_verify_server_cert + ) { + $adapterParamaters['driver_options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + /* + * Set MySQL server SQL modes to behave as closely as possible to Oracle and PostgreSQL. Note that the + * ONLY_FULL_GROUP_BY mode is left on purpose because MySQL requires you to specify all non-aggregate + * columns in the group by list even if the query is grouped by the master table's primary key which is + * valid ANSI SQL though. Further in that case the query plan would suffer if you add more columns to + * the group by list. + */ + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] = + 'SET SESSION SQL_MODE=\'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,' + . 'ANSI_QUOTES,PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION\''; + if (isset($adapterParamaters['charset'])) { + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ', NAMES ' . $adapterParamaters['charset']; + if (trim($adapterParamaters['charset']) === 'latin1') { + // Required for MySQL 8+ because we need PIPES_AS_CONCAT and + // have several columns with explicit COLLATE instructions + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ' COLLATE latin1_general_ci'; + } + + unset($adapterParamaters['charset']); + } + + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .= ", time_zone='" . $this->defaultTimezoneOffset() . "'"; + $driverOptions[PDO::MYSQL_ATTR_INIT_COMMAND] .=';'; + $defaultPort = 3306; + break; + case 'oci': + $adapter = 'Oracle'; + unset($adapterParamaters['options']); + unset($adapterParamaters['driver_options']); + $adapterParamaters['driver_options'] = array( + 'lob_as_string' => true + ); + $defaultPort = 1521; + break; + case 'oracle': + $adapter = 'Pdo_Oci'; + $defaultPort = 1521; + + // remove host parameter when not configured + if (empty($this->config->host)) { + unset($adapterParamaters['host']); + } + break; + case 'pgsql': + $adapter = 'Pdo_Pgsql'; + $defaultPort = 5432; + break; + case 'ibm': + $adapter = 'Pdo_Ibm'; + $defaultPort = 50000; + break; + case 'sqlite': + $adapter = 'Pdo_Sqlite'; + $defaultPort = 0; // Dummy port because a value is required + break; + default: + throw new ConfigurationError( + 'Backend "%s" is not supported', + $this->dbType + ); + } + $adapterParamaters['port'] = $this->config->get('port', $defaultPort); + $this->dbAdapter = Zend_Db::factory($adapter, $adapterParamaters); + $this->dbAdapter->setFetchMode(Zend_Db::FETCH_OBJ); + // TODO(el/tg): The profiler is disabled per default, why do we disable the profiler explicitly? + $this->dbAdapter->getProfiler()->setEnabled(false); + } + + public static function fromResourceName($name) + { + return new static(ResourceFactory::getResourceConfig($name)); + } + + /** + * Getter for the table prefix + * + * @return string + */ + public function getTablePrefix() + { + return $this->tablePrefix; + } + + /** + * Setter for the table prefix + * + * @param string $prefix + * + * @return $this + */ + public function setTablePrefix($prefix) + { + $this->tablePrefix = $prefix; + return $this; + } + + /** + * Get offset from the current default timezone to GMT + * + * @return string + */ + protected function defaultTimezoneOffset() + { + $tz = new DateTimeZone(date_default_timezone_get()); + $offset = $tz->getOffset(new DateTime()); + $prefix = $offset >= 0 ? '+' : '-'; + $offset = abs($offset); + $hours = (int) floor($offset / 3600); + $minutes = (int) floor(($offset % 3600) / 60); + return sprintf('%s%d:%02d', $prefix, $hours, $minutes); + } + + /** + * Count all rows of the result set + * + * @param DbQuery $query + * + * @return int + */ + public function count(DbQuery $query) + { + return (int) $this->dbAdapter->fetchOne($query->getCountQuery()); + } + + /** + * Retrieve an array containing all rows of the result set + * + * @param DbQuery $query + * + * @return array + */ + public function fetchAll(DbQuery $query) + { + return $this->dbAdapter->fetchAll($query->getSelectQuery()); + } + + /** + * Fetch the first row of the result set + * + * @param DbQuery $query + * + * @return mixed + */ + public function fetchRow(DbQuery $query) + { + return $this->dbAdapter->fetchRow($query->getSelectQuery()); + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @param DbQuery $query + * + * @return array + */ + public function fetchColumn(DbQuery $query) + { + return $this->dbAdapter->fetchCol($query->getSelectQuery()); + } + + /** + * Fetch the first column of the first row of the result set + * + * @param DbQuery $query + * + * @return string + */ + public function fetchOne(DbQuery $query) + { + return $this->dbAdapter->fetchOne($query->getSelectQuery()); + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @param DbQuery $query + * + * @return array + */ + public function fetchPairs(DbQuery $query) + { + return $this->dbAdapter->fetchPairs($query->getSelectQuery()); + } + + /** + * Insert a table row with the given data + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as third parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param array $types + * + * @return int The number of affected rows + */ + public function insert($table, array $bind, array $types = array()) + { + $columns = $values = array(); + foreach ($bind as $column => $value) { + $columns[] = $column; + if ($value instanceof Zend_Db_Expr) { + $values[] = (string) $value; + unset($bind[$column]); + } else { + $values[] = ':' . $column; + } + } + + $sql = 'INSERT INTO ' . $table + . ' (' . join(', ', $columns) . ') ' + . 'VALUES (' . join(', ', $values) . ')'; + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * Note that the base implementation does not perform any quoting on the $table argument. + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as fourth parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param Filter $filter + * @param array $types + * + * @return int The number of affected rows + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $set = array(); + foreach ($bind as $column => $value) { + if ($value instanceof Zend_Db_Expr) { + $set[] = $column . ' = ' . $value; + unset($bind[$column]); + } else { + $set[] = $column . ' = :' . $column; + } + } + + $sql = 'UPDATE ' . $table + . ' SET ' . join(', ', $set) + . ($filter ? ' WHERE ' . $this->renderFilter($filter) : ''); + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + * + * @return int The number of affected rows + */ + public function delete($table, Filter $filter = null) + { + return $this->dbAdapter->delete($table, $filter ? $this->renderFilter($filter) : ''); + } + + /** + * Render and return the given filter as SQL-WHERE clause + * + * @param Filter $filter + * + * @return string + */ + public function renderFilter(Filter $filter, $level = 0) + { + // TODO: This is supposed to supersede DbQuery::renderFilter() + $where = ''; + if ($filter->isChain()) { + if ($filter instanceof FilterAnd) { + $operator = ' AND '; + } elseif ($filter instanceof FilterOr) { + $operator = ' OR '; + } elseif ($filter instanceof FilterNot) { + $operator = ' AND '; + $where .= ' NOT '; + } else { + throw new ProgrammingError('Cannot render filter: %s', get_class($filter)); + } + + if (! $filter->isEmpty()) { + $parts = array(); + foreach ($filter->filters() as $filterPart) { + $part = $this->renderFilter($filterPart, $level + 1); + if ($part) { + $parts[] = $part; + } + } + + if (! empty($parts)) { + if ($level > 0) { + $where .= ' (' . implode($operator, $parts) . ') '; + } else { + $where .= implode($operator, $parts); + } + } + } else { + return ''; // Explicitly return the empty string due to the FilterNot case + } + } else { + $where .= $this->renderFilterExpression($filter); + } + + return $where; + } + + /** + * Render and return the given filter expression + * + * @param Filter $filter + * + * @return string + */ + protected function renderFilterExpression(Filter $filter) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $value = $filter->getExpression(); + + if (is_array($value)) { + $comp = []; + $pattern = []; + foreach ($value as $val) { + if (strpos($val, '*') === false) { + $comp[] = $val; + } else { + $pattern[] = $this->renderFilterExpression(Filter::expression($column, $sign, $val)); + } + } + + $sql = $pattern; + if ($sign === '=') { + if (! empty($comp)) { + $sql[] = $column . ' IN (' . $this->dbAdapter->quote($comp) . ')'; + } + + $operator = 'OR'; + } elseif ($sign === '!=') { + if (! empty($comp)) { + $sql[] = sprintf( + '(%1$s NOT IN (%2$s) OR %1$s IS NULL)', + $column, + $this->dbAdapter->quote($comp) + ); + } + + $operator = 'AND'; + } else { + throw new ProgrammingError( + 'Unable to render array expressions with operators other than equal or not equal' + ); + } + + return count($sql) === 1 ? $sql[0] : '(' . implode(" $operator ", $sql) . ')'; + } elseif ($sign === '=' + && ! $filter instanceof FilterEqual + && $value !== null + && strpos($value, '*') !== false + ) { + if ($value === '*') { + return $column . ' IS NOT NULL'; + } + + return $column . ' LIKE ' . $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)); + } elseif ($sign === '!=' + && ! $filter instanceof FilterNotEqual + && $value !== null + && strpos($value, '*') !== false + ) { + if ($value === '*') { + return $column . ' IS NULL'; + } + + return sprintf( + '(%1$s NOT LIKE %2$s OR %1$s IS NULL)', + $column, + $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)) + ); + } elseif ($sign === '!=') { + return sprintf('(%1$s != %2$s OR %1$s IS NULL)', $column, $this->dbAdapter->quote($value)); + } else { + return sprintf('%s %s %s', $column, $sign, $this->dbAdapter->quote($value)); + } + } + + public function inspect() + { + $insp = new Inspection('Db Connection'); + try { + $this->getDbAdapter()->getConnection(); + $config = $this->dbAdapter->getConfig(); + $insp->write(sprintf( + 'Connection to %s as %s on %s:%s successful', + $config['dbname'], + $config['username'], + array_key_exists('host', $config) ? $config['host'] : '(none)', + $config['port'] + )); + switch ($this->dbType) { + case 'mysql': + $rows = $this->dbAdapter->query( + 'SHOW VARIABLES WHERE variable_name ' . + 'IN (\'version\', \'protocol_version\', \'version_compile_os\', \'have_ssl\');' + )->fetchAll(); + $sqlinsp = new Inspection('MySQL'); + $hasSsl = false; + foreach ($rows as $row) { + $sqlinsp->write($row->variable_name . ': ' . $row->value); + if ($row->variable_name === 'have_ssl' && $row->value === 'YES') { + $hasSsl = true; + } + } + if ($hasSsl) { + $ssl_rows = $this->dbAdapter->query( + 'SHOW STATUS WHERE variable_name ' . + 'IN (\'Ssl_Cipher\');' + )->fetchAll(); + foreach ($ssl_rows as $ssl_row) { + $sqlinsp->write($ssl_row->variable_name . ': ' . $ssl_row->value); + } + } + $insp->write($sqlinsp); + break; + case 'pgsql': + $row = $this->dbAdapter->query('SELECT version();')->fetchAll(); + $sqlinsp = new Inspection('PostgreSQL'); + $sqlinsp->write($row[0]->version); + $insp->write($sqlinsp); + break; + } + } catch (Exception $e) { + return $insp->error(sprintf('Connection failed %s', $e->getMessage())); + } + return $insp; + } +} diff --git a/library/Icinga/Data/Db/DbQuery.php b/library/Icinga/Data/Db/DbQuery.php new file mode 100644 index 0000000..30816a7 --- /dev/null +++ b/library/Icinga/Data/Db/DbQuery.php @@ -0,0 +1,564 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Db; + +use DateInterval; +use DateTime; +use DateTimeZone; +use Exception; +use Icinga\Data\Filter\Filter; +use Zend_Db_Expr; +use Zend_Db_Select; +use Icinga\Application\Logger; +use Icinga\Data\SimpleQuery; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; + +/** + * Database query class + */ +class DbQuery extends SimpleQuery +{ + /** + * @var Zend_Db_Adapter_Abstract + */ + protected $db; + + /** + * Whether or not the query is a sub query + * + * Sub queries are automatically wrapped in parentheses + * + * @var bool + */ + protected $isSubQuery = false; + + /** + * Select query + * + * @var Zend_Db_Select + */ + protected $select; + + /** + * Whether to use a subquery for counting + * + * When the query is distinct or has a HAVING or GROUP BY clause this must be set to true + * + * @var bool + */ + protected $useSubqueryCount = false; + + /** + * Count query result + * + * Count queries are only executed once + * + * @var int + */ + protected $count; + + /** + * GROUP BY clauses + * + * @var string|array + */ + protected $group; + + protected function init() + { + $this->db = $this->ds->getDbAdapter(); + $this->select = $this->db->select(); + parent::init(); + } + + /** + * Get whether or not the query is a sub query + */ + public function getIsSubQuery() + { + return $this->isSubQuery; + } + + /** + * Set whether or not the query is a sub query + * + * @param bool $isSubQuery + * + * @return $this + */ + public function setIsSubQuery($isSubQuery = true) + { + $this->isSubQuery = (bool) $isSubQuery; + return $this; + } + + public function setUseSubqueryCount($useSubqueryCount = true) + { + $this->useSubqueryCount = $useSubqueryCount; + return $this; + } + + public function from($target, array $fields = null) + { + parent::from($target, $fields); + $this->select->from($this->target, array()); + return $this; + } + + public function where($condition, $value = null) + { + // $this->count = $this->select = null; + return parent::where($condition, $value); + } + + public function addFilter(Filter $filter) + { + $this->expressionsToTimestamp($filter); + return parent::addFilter($filter); + } + + private function expressionsToTimestamp(Filter $filter) + { + if ($filter->isChain()) { + foreach ($filter->filters() as $child) { + $this->expressionsToTimestamp($child); + } + } elseif ($this->isTimestamp($filter->getColumn())) { + $filter->setExpression($this->valueToTimestamp($filter->getExpression())); + } + } + + protected function dbSelect() + { + return clone $this->select; + } + + /** + * Return the underlying select + * + * @return Zend_Db_Select + */ + public function select() + { + return $this->select; + } + + /** + * Get the select query + * + * Applies order and limit if any + * + * @return Zend_Db_Select + */ + public function getSelectQuery() + { + $select = $this->dbSelect(); + // Add order fields to select for postgres distinct queries (#6351) + if ($this->hasOrder() + && $this->getDatasource()->getDbType() === 'pgsql' + && $select->getPart(Zend_Db_Select::DISTINCT) === true) { + foreach ($this->getOrder() as $fieldAndDirection) { + if (array_search($fieldAndDirection[0], $this->columns, true) === false) { + $this->columns[] = $fieldAndDirection[0]; + } + } + } + + $group = $this->getGroup(); + if ($group) { + $select->group($group); + } + + if (! empty($this->columns)) { + $select->columns($this->columns); + } + + $this->applyFilterSql($select); + + if ($this->hasLimit() || $this->hasOffset()) { + $select->limit($this->getLimit(), $this->getOffset()); + } + if ($this->hasOrder()) { + foreach ($this->getOrder() as $fieldAndDirection) { + $select->order( + $fieldAndDirection[0] . ' ' . $fieldAndDirection[1] + ); + } + } + + return $select; + } + + protected function applyFilterSql($select) + { + $where = $this->getDatasource()->renderFilter($this->filter); + if ($where !== '') { + $select->where($where); + } + } + + protected function escapeForSql($value) + { + // bindParam? bindValue? + if (is_array($value)) { + $ret = array(); + foreach ($value as $val) { + $ret[] = $this->escapeForSql($val); + } + return implode(', ', $ret); + } else { + //if (preg_match('/^\d+$/', $value)) { + // return $value; + //} else { + return $this->db->quote($value); + //} + } + } + + protected function escapeWildcards($value) + { + return preg_replace('/\*/', '%', $value); + } + + protected function valueToTimestamp($value) + { + if (is_string($value)) { + if (ctype_digit($value)) { + $value = (int) $value; + } else { + $value = strtotime($value); + } + } elseif (! is_int($value)) { + $value = (int) $value; + } + + return $value; + } + + /** + * Render the given timestamp based on the local timezone + * + * Since {@see DbConnection::defaultTimezoneOffset()} tells the database the timezone with just an offset, + * this will prepare the rendered value in a way that it plays fine with daylight savings. + * + * @param int $value + * @return string + */ + protected function timestampForSql($value) + { + if ($this->getDatasource()->getDbType() === 'pgsql') { + // We don't tell PostgreSQL the user's timezone + $dateTime = (new DateTime()) + ->setTimezone(new DateTimeZone('UTC')) + ->setTimestamp($value); + } else { + $dateTime = new DateTime(); + // Get "current" offset the database will use + $offsetToUTC = $dateTime->getOffset(); + // Set timezone to UTC and initialize it with the timestamp + $dateTime->setTimezone(new DateTimeZone('UTC'))->setTimestamp($value); + // Normalize every datetime based on the only offset the database knows about + if ($offsetToUTC >= 0) { + $dateTime->add(new DateInterval("PT{$offsetToUTC}S")); + } else { + $offsetToUTC = abs($offsetToUTC); + $dateTime->sub(new DateInterval("PT{$offsetToUTC}S")); + } + } + + return $dateTime->format('Y-m-d H:i:s'); + } + + /** + * Check for timestamp fields + * + * TODO: This is not here to do automagic timestamp stuff. One may + * override this function for custom voodoo, IdoQuery right now + * does. IMO we need to split whereToSql functionality, however + * I'd prefer to wait with this unless we understood how other + * backends will work. We probably should also rename this + * function to isTimestampColumn(). + * + * @param string $field Field Field name to checked + * @return bool Whether this field expects timestamps + */ + public function isTimestamp($field) + { + return false; + } + + /** + * Get the count query + * + * @return Zend_Db_Select + */ + public function getCountQuery() + { + // TODO: there may be situations where we should clone the "select" + $count = $this->dbSelect(); + $this->applyFilterSql($count); + $group = $this->getGroup(); + if ($this->useSubqueryCount || $group) { + if (! empty($this->columns)) { + $count->columns($this->columns); + } + if ($group) { + $count->group($group); + } + $columns = array('cnt' => 'COUNT(*)'); + return $this->db->select()->from($count, $columns); + } + + $count->columns(array('cnt' => 'COUNT(*)')); + return $count; + } + + /** + * Count all rows of the result set + * + * @return int + */ + public function count(): int + { + if ($this->count === null) { + $this->count = parent::count(); + } + + return $this->count; + } + + /** + * Return the select and count query as a textual representation + * + * @return string A string containing the select and count query, using unix style newlines as linebreaks + */ + public function dump() + { + return "QUERY\n=====\n" + . $this->getSelectQuery() + . "\n\nCOUNT\n=====\n" + . $this->getCountQuery() + . "\n\n"; + } + + public function __clone() + { + parent::__clone(); + $this->select = clone $this->select; + } + + /** + * @return string + */ + public function __toString() + { + try { + $select = (string) $this->getSelectQuery(); + return $this->getIsSubQuery() ? ('(' . $select . ')') : $select; + } catch (Exception $e) { + Logger::debug('Failed to render DbQuery. An error occured: %s', $e); + return ''; + } + } + + /** + * Add a GROUP BY clause + * + * @param string|array $group + * + * @return $this + */ + public function group($group) + { + $this->group = $group; + return $this; + } + + /** + * Return the GROUP BY clause + * + * @return string|array + */ + public function getGroup() + { + return $this->group; + } + + /** + * Return whether the given table has been joined + * + * @param string $table + * + * @return bool + */ + public function hasJoinedTable($table) + { + $fromPart = $this->select->getPart(Zend_Db_Select::FROM); + if (isset($fromPart[$table])) { + return true; + } + + foreach ($fromPart as $options) { + if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) { + return true; + } + } + + return false; + } + + /** + * Return the alias used for joining the given table + * + * @param string $table + * + * @return string|null null in case no alias is being used + * + * @throws ProgrammingError In case the given table has not been joined + */ + public function getJoinedTableAlias($table) + { + $fromPart = $this->select->getPart(Zend_Db_Select::FROM); + if (isset($fromPart[$table])) { + if ($fromPart[$table]['joinType'] === Zend_Db_Select::FROM) { + throw new ProgrammingError('Table "%s" has not been joined', $table); + } + + return; // No alias in use + } + + foreach ($fromPart as $alias => $options) { + if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) { + return $alias; + } + } + + throw new ProgrammingError('Table "%s" has not been joined', $table); + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function join($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinInner($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a LEFT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinLeft($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinLeft($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a RIGHT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinRight($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinRight($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a FULL OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinFull($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinFull($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a CROSS JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinCross($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinCross($name, $cols, $schema); + return $this; + } + + /** + * Add a NATURAL JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinNatural($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinNatural($name, $cols, $schema); + return $this; + } + + /** + * Add a UNION clause to the query + * + * @param array $select Select clauses for the union + * @param string $type Type of UNION to use + * + * @return $this + */ + public function union($select = array(), $type = Zend_Db_Select::SQL_UNION) + { + $this->select->union($select, $type); + return $this; + } +} diff --git a/library/Icinga/Data/Extensible.php b/library/Icinga/Data/Extensible.php new file mode 100644 index 0000000..ad690d8 --- /dev/null +++ b/library/Icinga/Data/Extensible.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Exception\StatementException; + +/** + * Interface for data insertion + */ +interface Extensible +{ + /** + * Insert the given data for the given target + * + * @param string $target + * @param array $data + * + * @throws StatementException + */ + public function insert($target, array $data); +} diff --git a/library/Icinga/Data/Fetchable.php b/library/Icinga/Data/Fetchable.php new file mode 100644 index 0000000..342740a --- /dev/null +++ b/library/Icinga/Data/Fetchable.php @@ -0,0 +1,47 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for retrieving data + */ +interface Fetchable +{ + /** + * Retrieve an array containing all rows of the result set + * + * @return array + */ + public function fetchAll(); + + /** + * Fetch the first row of the result set + * + * @return mixed + */ + public function fetchRow(); + + /** + * Fetch the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn(); + + /** + * Fetch the first column of the first row of the result set + * + * @return string + */ + public function fetchOne(); + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @return array + */ + public function fetchPairs(); +} diff --git a/library/Icinga/Data/Filter/Filter.php b/library/Icinga/Data/Filter/Filter.php new file mode 100644 index 0000000..f5d8bdf --- /dev/null +++ b/library/Icinga/Data/Filter/Filter.php @@ -0,0 +1,255 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Web\UrlParams; +use Icinga\Exception\ProgrammingError; + +/** + * Filter + * + * Base class for filters (why?) and factory for the different FilterOperators + */ +abstract class Filter +{ + protected $id = '1'; + + public function setId($id) + { + $this->id = (string) $id; + return $this; + } + + abstract public function isExpression(); + + abstract public function isChain(); + + abstract public function isEmpty(); + + abstract public function toQueryString(); + + abstract public function andFilter(Filter $filter); + + abstract public function orFilter(Filter $filter); + + /** + * Whether the give row matches this Filter + * + * @param mixed $row Preferrably an stdClass instance + * @return bool + */ + abstract public function matches($row); + + public function getUrlParams() + { + return UrlParams::fromQueryString($this->toQueryString()); + } + + public function getById($id) + { + if ((string) $id === $this->getId()) { + return $this; + } + throw new ProgrammingError( + 'Trying to get invalid filter index "%s" from "%s" ("%s")', + $id, + $this, + $this->id + ); + } + + public function getId() + { + return $this->id; + } + + public function isRootNode() + { + return false === strpos($this->id, '-'); + } + + abstract public function listFilteredColumns(); + + public function applyChanges($changes) + { + $filter = $this; + $pairs = array(); + foreach ($changes as $k => $v) { + if (preg_match('/^(column|value|sign|operator)_([\d-]+)$/', $k, $m)) { + $pairs[$m[2]][$m[1]] = $v; + } + } + $operators = array(); + foreach ($pairs as $id => $fs) { + if (array_key_exists('operator', $fs)) { + $operators[$id] = $fs['operator']; + } else { + $f = $filter->getById($id); + $f->setColumn($fs['column']); + if ($f->getSign() !== $fs['sign']) { + if ($f->isRootNode()) { + $filter = $f->setSign($fs['sign']); + } else { + $filter->replaceById($id, $f->setSign($fs['sign'])); + } + } + $f->setExpression($fs['value']); + } + } + + krsort($operators, SORT_NATURAL); + foreach ($operators as $id => $operator) { + $f = $filter->getById($id); + if ($f->getOperatorName() !== $operator) { + if ($f->isRootNode()) { + $filter = $f->setOperatorName($operator); + } else { + $filter->replaceById($id, $f->setOperatorName($operator)); + } + } + } + + return $filter; + } + + public function getParentId() + { + if ($this->isRootNode()) { + throw new ProgrammingError('Filter root nodes have no parent'); + } + return substr($this->id, 0, strrpos($this->id, '-')); + } + + public function getParent() + { + return $this->getById($this->getParentId()); + } + + public function hasId($id) + { + if ($id === $this->getId()) { + return true; + } + return false; + } + + /** + * Where Filter factory + * + * @param string $col Column to be filtered + * @param string $filter Filter expression + * + * @throws FilterException + * @return FilterExpression + */ + public static function where($col, $filter) + { + return new FilterExpression($col, '=', $filter); + } + + public static function expression($col, $op, $expression) + { + switch ($op) { + case '=': + return new FilterMatch($col, $op, $expression); + case '<': + return new FilterLessThan($col, $op, $expression); + case '>': + return new FilterGreaterThan($col, $op, $expression); + case '>=': + return new FilterEqualOrGreaterThan($col, $op, $expression); + case '<=': + return new FilterEqualOrLessThan($col, $op, $expression); + case '!=': + return new FilterMatchNot($col, $op, $expression); + default: + throw new ProgrammingError( + 'There is no such filter sign: %s', + $op + ); + } + } + + /** + * Or FilterOperator factory + * + * @param Filter $filter,... Unlimited optional list of Filters + * + * @return FilterOr + */ + public static function matchAny() + { + $args = func_get_args(); + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + return new FilterOr($args); + } + + /** + * Or FilterOperator factory + * + * @param Filter $filter,... Unlimited optional list of Filters + * + * @return FilterAnd + */ + public static function matchAll() + { + $args = func_get_args(); + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + return new FilterAnd($args); + } + + /** + * FilterNot factory, negates the given filter + * + * @param Filter $filter Filter to be negated + * + * @return FilterNot + */ + public static function not() + { + $args = func_get_args(); + if (count($args) === 1) { + if (is_array($args[0])) { + $args = $args[0]; + } + } + if (count($args) > 1) { + return new FilterNot(array(new FilterAnd($args))); + } else { + return new FilterNot($args); + } + } + + public static function chain($operator, $filters = array()) + { + switch ($operator) { + case 'AND': + return self::matchAll($filters); + case 'OR': + return self::matchAny($filters); + case 'NOT': + return self::not($filters); + } + throw new ProgrammingError( + '"%s" is not a valid filter chain operator', + $operator + ); + } + + /** + * Create filter from queryString + * + * This is still pretty basic, need improvement + * + * @return static + */ + public static function fromQueryString($query) + { + return FilterQueryString::parse($query); + } +} diff --git a/library/Icinga/Data/Filter/FilterAnd.php b/library/Icinga/Data/Filter/FilterAnd.php new file mode 100644 index 0000000..96b68cc --- /dev/null +++ b/library/Icinga/Data/Filter/FilterAnd.php @@ -0,0 +1,42 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +/** + * Filter list AND + * + * Binary AND, all contained filters must succeed + */ +class FilterAnd extends FilterChain +{ + protected $operatorName = 'AND'; + + protected $operatorSymbol = '&'; + + /** + * Whether the given row object matches this filter + * + * @object $row + * @return boolean + */ + public function matches($row) + { + foreach ($this->filters as $filter) { + if (! $filter->matches($row)) { + return false; + } + } + return true; + } + + public function andFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterChain.php b/library/Icinga/Data/Filter/FilterChain.php new file mode 100644 index 0000000..0f1e071 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterChain.php @@ -0,0 +1,286 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; + +/** + * FilterChain + * + * A FilterChain contains a list ... + */ +abstract class FilterChain extends Filter +{ + protected $filters = array(); + + protected $operatorName; + + protected $operatorSymbol; + + protected $allowedColumns; + + /** + * Set the filters + * + * @param array $filters + * + * @return $this + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + $this->refreshChildIds(); + + return $this; + } + + public function hasId($id) + { + foreach ($this->filters() as $filter) { + if ($filter->hasId($id)) { + return true; + } + } + return parent::hasId($id); + } + + public function getById($id) + { + foreach ($this->filters() as $filter) { + if ($filter->hasId($id)) { + return $filter->getById($id); + } + } + return parent::getById($id); + } + + public function removeId($id) + { + if ($id === $this->getId()) { + $this->filters = array(); + return $this; + } + $remove = null; + foreach ($this->filters as $key => $filter) { + if ($filter->getId() === $id) { + $remove = $key; + } elseif ($filter instanceof FilterChain) { + $filter->removeId($id); + } + } + if ($remove !== null) { + unset($this->filters[$remove]); + $this->filters = array_values($this->filters); + } + $this->refreshChildIds(); + return $this; + } + + public function replaceById($id, $filter) + { + $found = false; + foreach ($this->filters as $k => $child) { + if ($child->getId() == $id) { + $this->filters[$k] = $filter; + $found = true; + break; + } + if ($child->hasId($id)) { + $child->replaceById($id, $filter); + $found = true; + break; + } + } + if (! $found) { + throw new ProgrammingError('You tried to replace an unexistant child filter'); + } + $this->refreshChildIds(); + return $this; + } + + protected function refreshChildIds() + { + $i = 0; + $id = $this->getId(); + foreach ($this->filters as $filter) { + $i++; + $filter->setId($id . '-' . $i); + } + return $this; + } + + public function setId($id) + { + return parent::setId($id)->refreshChildIds(); + } + + public function getOperatorName() + { + return $this->operatorName; + } + + public function setOperatorName($name) + { + if ($name !== $this->operatorName) { + return Filter::chain($name, $this->filters); + } + return $this; + } + + public function getOperatorSymbol() + { + return $this->operatorSymbol; + } + + public function setAllowedFilterColumns(array $columns) + { + $this->allowedColumns = $columns; + return $this; + } + + /** + * List and return all column names referenced in this filter + * + * @param array $columns The columns listed so far + * + * @return array + */ + public function listFilteredColumns(array $columns = array()) + { + foreach ($this->filters as $filter) { + if ($filter instanceof FilterExpression) { + $column= $filter->getColumn(); + if (! in_array($column, $columns, true)) { + $columns[] = $column; + } + } else { + $columns = $filter->listFilteredColumns($columns); + } + } + + return $columns; + } + + public function toQueryString() + { + $parts = array(); + if (empty($this->filters)) { + return ''; + } + foreach ($this->filters() as $filter) { + if (! $filter->isEmpty()) { + $parts[] = $filter->toQueryString(); + } + } + + // TODO: getLevel?? + if (strpos($this->getId(), '-')) { + return '(' . implode($this->getOperatorSymbol(), $parts) . ')'; + } else { + return implode($this->getOperatorSymbol(), $parts); + } + } + + /** + * Get simple string representation + * + * Useful for debugging only + * + * @return string + */ + public function __toString() + { + if (empty($this->filters)) { + return ''; + } + $parts = array(); + foreach ($this->filters as $filter) { + if ($filter instanceof FilterChain) { + $parts[] = '(' . $filter . ')'; + } else { + $parts[] = (string) $filter; + } + } + $op = ' ' . $this->getOperatorSymbol() . ' '; + return implode($op, $parts); + } + + public function __construct($filters = array()) + { + foreach ($filters as $filter) { + $this->addFilter($filter); + } + } + + public function isExpression() + { + return false; + } + + public function isChain() + { + return true; + } + + public function isEmpty() + { + return empty($this->filters); + } + + public function addFilter(Filter $filter) + { + if (! empty($this->allowedColumns)) { + $this->validateFilterColumns($filter); + } + + $this->filters[] = $filter; + $filter->setId($this->getId() . '-' . $this->count()); + return $this; + } + + protected function validateFilterColumns(Filter $filter) + { + if ($filter->isExpression()) { + $valid = false; + foreach ($this->allowedColumns as $column) { + if (is_callable($column)) { + if (call_user_func($column, $filter->getColumn())) { + $valid = true; + break; + } + } elseif ($filter->getColumn() === $column) { + $valid = true; + break; + } + } + + if (! $valid) { + throw new QueryException('Invalid filter column provided: %s', $filter->getColumn()); + } + } else { + foreach ($filter->filters() as $subFilter) { + $this->validateFilterColumns($subFilter); + } + } + } + + public function &filters() + { + return $this->filters; + } + + public function count() + { + return count($this->filters); + } + + public function __clone() + { + foreach ($this->filters as & $filter) { + $filter = clone $filter; + } + } +} diff --git a/library/Icinga/Data/Filter/FilterEqual.php b/library/Icinga/Data/Filter/FilterEqual.php new file mode 100644 index 0000000..da53d3f --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqual.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqual extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} === (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php new file mode 100644 index 0000000..d7bd5b8 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqualOrGreaterThan.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqualOrGreaterThan extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} >= (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterEqualOrLessThan.php b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php new file mode 100644 index 0000000..8016fc4 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterEqualOrLessThan.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterEqualOrLessThan extends FilterExpression +{ + public function __toString() + { + return $this->column . ' <= ' . $this->expression; + } + + public function toQueryString() + { + return $this->column . '<=' . $this->expression; + } + + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} <= (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterException.php b/library/Icinga/Data/Filter/FilterException.php new file mode 100644 index 0000000..842d7ab --- /dev/null +++ b/library/Icinga/Data/Filter/FilterException.php @@ -0,0 +1,15 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\IcingaException; + +/** + * Filter Exception Class + * + * Filter Exceptions should be thrown on filter parse errors or similar + */ +class FilterException extends IcingaException +{ +} diff --git a/library/Icinga/Data/Filter/FilterExpression.php b/library/Icinga/Data/Filter/FilterExpression.php new file mode 100644 index 0000000..73fb625 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterExpression.php @@ -0,0 +1,224 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Exception; + +class FilterExpression extends Filter +{ + protected $column; + protected $sign; + protected $expression; + + /** + * Does this filter compare case sensitive? + * + * @var bool + */ + protected $caseSensitive; + + public function __construct($column, $sign, $expression) + { + $column = trim($column); + $this->column = $column; + $this->sign = $sign; + $this->expression = $expression; + $this->caseSensitive = true; + } + + public function isExpression() + { + return true; + } + + public function isChain() + { + return false; + } + + public function isEmpty() + { + return false; + } + + public function getColumn() + { + return $this->column; + } + + public function getSign() + { + return $this->sign; + } + + public function setColumn($column) + { + $this->column = $column; + return $this; + } + + public function getExpression() + { + return $this->expression; + } + + /** + * Return whether this filter compares case sensitive + * + * @return bool + */ + public function getCaseSensitive() + { + return $this->caseSensitive; + } + + public function setExpression($expression) + { + $this->expression = $expression; + return $this; + } + + public function setSign($sign) + { + if ($sign !== $this->sign) { + return Filter::expression($this->column, $sign, $this->expression); + } + return $this; + } + + /** + * Set this filter's case sensitivity + * + * @param bool $caseSensitive + * + * @return $this + */ + public function setCaseSensitive($caseSensitive = true) + { + $this->caseSensitive = $caseSensitive; + return $this; + } + + public function listFilteredColumns() + { + return array($this->getColumn()); + } + + public function __toString() + { + if ($this->isBooleanTrue()) { + return $this->column; + } + + $expression = is_array($this->expression) ? + '( ' . implode(' | ', $this->expression) . ' )' : + $this->expression; + + return sprintf( + '%s %s %s', + $this->column, + $this->sign, + $expression + ); + } + + public function toQueryString() + { + if ($this->isBooleanTrue()) { + return $this->column; + } + + $expression = is_array($this->expression) ? + '(' . implode('|', array_map('rawurlencode', $this->expression)) . ')' : + rawurlencode($this->expression); + + return $this->column . $this->sign . $expression; + } + + protected function isBooleanTrue() + { + return $this->sign === '=' && $this->expression === true; + } + + /** + * If $var is a scalar, do the same as strtolower() would do. + * If $var is an array, map $this->strtolowerRecursive() to its elements. + * Otherwise, return $var unchanged. + * + * @param mixed $var + * + * @return mixed + */ + protected function strtolowerRecursive($var) + { + if ($var === null) { + return ''; + } + if (is_scalar($var)) { + return strtolower($var); + } + if (is_array($var)) { + return array_map(array($this, 'strtolowerRecursive'), $var); + } + return $var; + } + + public function matches($row) + { + try { + $rowValue = $row->{$this->column}; + } catch (Exception $e) { + // TODO: REALLY? Exception? + return false; + } + + if ($this->caseSensitive) { + $expression = $this->expression; + } else { + $rowValue = $this->strtolowerRecursive($rowValue); + $expression = $this->strtolowerRecursive($this->expression); + } + + if (is_array($expression)) { + return in_array($rowValue, $expression); + } + + $expression = (string) $expression; + if (strpos($expression, '*') === false) { + if (is_array($rowValue)) { + return in_array($expression, $rowValue); + } + + return (string) $rowValue === $expression; + } + + $parts = array(); + foreach (preg_split('~\*~', $expression) as $part) { + $parts[] = preg_quote($part, '/'); + } + $pattern = '/^' . implode('.*', $parts) . '$/'; + + if (is_array($rowValue)) { + foreach ($rowValue as $candidate) { + if (preg_match($pattern, $candidate)) { + return true; + } + } + + return false; + } + + return $rowValue !== null && preg_match($pattern, $rowValue); + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($this, $filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterGreaterThan.php b/library/Icinga/Data/Filter/FilterGreaterThan.php new file mode 100644 index 0000000..92a0e62 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterGreaterThan.php @@ -0,0 +1,16 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterGreaterThan extends FilterExpression +{ + public function matches($row) + { + if (! isset($row->{$this->column})) { + // TODO: REALLY? Exception? + return false; + } + return (string) $row->{$this->column} > (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterLessThan.php b/library/Icinga/Data/Filter/FilterLessThan.php new file mode 100644 index 0000000..c13a1ce --- /dev/null +++ b/library/Icinga/Data/Filter/FilterLessThan.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterLessThan extends FilterExpression +{ + public function __toString() + { + return $this->column . ' < ' . $this->expression; + } + + public function toQueryString() + { + return $this->column . '<' . $this->expression; + } + + public function matches($row) + { + if (! isset($row->{$this->column})) { + return false; + } + + return (string) $row->{$this->column} < (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterMatch.php b/library/Icinga/Data/Filter/FilterMatch.php new file mode 100644 index 0000000..a3befad --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatch.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatch extends FilterExpression +{ +} diff --git a/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php new file mode 100644 index 0000000..9eca173 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchCaseInsensitive.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchCaseInsensitive extends FilterMatch +{ + public function __construct($column, $sign, $expression) + { + parent::__construct($column, $sign, $expression); + $this->caseSensitive = false; + } +} diff --git a/library/Icinga/Data/Filter/FilterMatchNot.php b/library/Icinga/Data/Filter/FilterMatchNot.php new file mode 100644 index 0000000..1e5050e --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchNot.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchNot extends FilterExpression +{ + public function matches($row) + { + return !parent::matches($row); + } +} diff --git a/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php new file mode 100644 index 0000000..3838fa2 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterMatchNotCaseInsensitive.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterMatchNotCaseInsensitive extends FilterMatchNot +{ + public function __construct($column, $sign, $expression) + { + parent::__construct($column, $sign, $expression); + $this->caseSensitive = false; + } +} diff --git a/library/Icinga/Data/Filter/FilterNot.php b/library/Icinga/Data/Filter/FilterNot.php new file mode 100644 index 0000000..b61f497 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterNot.php @@ -0,0 +1,58 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterNot extends FilterChain +{ + protected $operatorName = 'NOT'; + + protected $operatorSymbol = '!'; // BULLSHIT + +// TODO: Max count 1 or autocreate sub-and? + + public function matches($row) + { + foreach ($this->filters() as $filter) { + if ($filter->matches($row)) { + return false; + } + } + return true; + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return Filter::matchAny($filter); + } + + public function toQueryString() + { + $parts = array(); + if (empty($this->filters)) { + return ''; + } + + foreach ($this->filters() as $filter) { + $parts[] = $filter->toQueryString(); + } + if (count($parts) === 1) { + return '!' . $parts[0]; + } else { + return '!(' . implode('&', $parts) . ')'; + } + } + + public function __toString() + { + if (count($this->filters) === 1) { + return '! ' . $this->filters[0]; + } + return '! (' . implode('&', $this->filters) . ')'; + } +} diff --git a/library/Icinga/Data/Filter/FilterNotEqual.php b/library/Icinga/Data/Filter/FilterNotEqual.php new file mode 100644 index 0000000..8915a3d --- /dev/null +++ b/library/Icinga/Data/Filter/FilterNotEqual.php @@ -0,0 +1,12 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterNotEqual extends FilterExpression +{ + public function matches($row) + { + return (string) $row->{$this->column} !== (string) $this->expression; + } +} diff --git a/library/Icinga/Data/Filter/FilterOr.php b/library/Icinga/Data/Filter/FilterOr.php new file mode 100644 index 0000000..aca91f3 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterOr.php @@ -0,0 +1,39 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterOr extends FilterChain +{ + protected $operatorName = 'OR'; + + protected $operatorSymbol = '|'; + + public function matches($row) + { + foreach ($this->filters as $filter) { + if ($filter->matches($row)) { + return true; + } + } + return false; + } + + public function setOperatorName($name) + { + if ($this->count() > 1 && $name === 'NOT') { + return Filter::not(clone $this); + } + return parent::setOperatorName($name); + } + + public function andFilter(Filter $filter) + { + return Filter::matchAll($this, $filter); + } + + public function orFilter(Filter $filter) + { + return $this->addFilter($filter); + } +} diff --git a/library/Icinga/Data/Filter/FilterParseException.php b/library/Icinga/Data/Filter/FilterParseException.php new file mode 100644 index 0000000..f2b732b --- /dev/null +++ b/library/Icinga/Data/Filter/FilterParseException.php @@ -0,0 +1,10 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +use Icinga\Exception\IcingaException; + +class FilterParseException extends IcingaException +{ +} diff --git a/library/Icinga/Data/Filter/FilterQueryString.php b/library/Icinga/Data/Filter/FilterQueryString.php new file mode 100644 index 0000000..8535df5 --- /dev/null +++ b/library/Icinga/Data/Filter/FilterQueryString.php @@ -0,0 +1,320 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Filter; + +class FilterQueryString +{ + protected $string; + + protected $pos; + + protected $debug = array(); + + protected $reportDebug = false; + + protected $length; + + protected function __construct() + { + } + + protected function debug($msg, $level = 0, $op = null) + { + if ($op === null) { + $op = 'NULL'; + } + $this->debug[] = sprintf( + '%s[%d=%s] (%s): %s', + str_repeat('* ', $level), + $this->pos, + $this->string[$this->pos - 1], + $op, + $msg + ); + } + + public static function parse($string) + { + $parser = new static(); + return $parser->parseQueryString($string); + } + + protected function readNextKey() + { + $str = $this->readUnlessSpecialChar(); + + if ($str === false) { + return $str; + } + return rawurldecode($str); + } + + protected function readNextValue() + { + if ($this->nextChar() === '(') { + $this->readChar(); + $var = preg_split('~\|~', $this->readUnless(')')); + if ($this->readChar() !== ')') { + $this->parseError(null, 'Expected ")"'); + } + } else { + $var = rawurldecode($this->readUnless(array(')', '&', '|', '>', '<'))); + } + return $var; + } + + protected function readNextExpression() + { + if ('' === ($key = $this->readNextKey())) { + return false; + } + + foreach (array('<', '>') as $sign) { + if (false !== ($pos = strpos($key, $sign))) { + if ($this->nextChar() === '=') { + break; + } + $var = substr($key, $pos + 1); + $key = substr($key, 0, $pos); + + if (ctype_digit($var)) { + $var = (float) $var; + } + + return Filter::expression($key, $sign, $var); + } + } + if (in_array($this->nextChar(), array('=', '>', '<', '!'))) { + $sign = $this->readChar(); + } else { + $sign = false; + } + if ($sign === false) { + return Filter::expression($key, '=', true); + } + + $toFloat = false; + if ($sign === '=') { + $last = substr($key, -1); + if ($last === '>' || $last === '<') { + $sign = $last . $sign; + $key = substr($key, 0, -1); + $toFloat = true; + } + // TODO: Same as above for unescaped <> - do we really need this? + } elseif ($sign === '>' || $sign === '<' || $sign === '!') { + $toFloat = $sign === '>' || $sign === '<'; + if ($this->nextChar() === '=') { + $sign .= $this->readChar(); + } + } + + $var = $this->readNextValue(); + if ($toFloat && ctype_digit($var)) { + $var = (float) $var; + } + + return Filter::expression($key, $sign, $var); + } + + protected function parseError($char = null, $extraMsg = null) + { + if ($extraMsg === null) { + $extra = ''; + } else { + $extra = ': ' . $extraMsg; + } + if ($char === null) { + $char = $this->string[$this->pos]; + } + if ($this->reportDebug) { + $extra .= "\n" . implode("\n", $this->debug); + } + + throw new FilterParseException( + 'Invalid filter "%s", unexpected %s at pos %d%s', + $this->string, + $char, + $this->pos, + $extra + ); + } + + protected function readFilters($nestingLevel = 0, $op = null) + { + $filters = array(); + while ($this->pos < $this->length) { + if ($op === '!' && count($filters) === 1) { + break; + } + $filter = $this->readNextExpression(); + $next = $this->readChar(); + + + if ($filter === false) { + $this->debug('Got no next expression, next is ' . $next, $nestingLevel, $op); + if ($next === '!') { + $not = $this->readFilters($nestingLevel + 1, '!'); + $filters[] = $not; + if (in_array($this->nextChar(), array('|', '&', ')'))) { + $next = $this->readChar(); + $this->debug('Got NOT, next is now: ' . $next, $nestingLevel, $op); + } else { + $this->debug('Breaking after NOT: ' . $not, $nestingLevel, $op); + break; + } + } + + if ($op === null && count($filters) > 0 && ($next === '&' || $next === '|')) { + $op = $next; + continue; + } + + if ($next === false) { + // Nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + $this->debug('Closing without filter: ' . $next, $nestingLevel, $op); + break; + } + $this->parseError($next); + } + if ($next === '(') { + $filters[] = $this->readFilters($nestingLevel + 1, null); + continue; + } + if ($next === $op) { + continue; + } + $this->parseError($next, "$op level $nestingLevel"); + } else { + $this->debug('Got new expression: ' . $filter, $nestingLevel, $op); + + $filters[] = $filter; + + if ($next === false) { + $this->debug('Next is false, nothing to read but got filter', $nestingLevel, $op); + // Got filter, nothing more to read + break; + } + + if ($op === '!') { + $this->pos--; + break; + } + if ($next === $op) { + $this->debug('Next matches operator', $nestingLevel, $op); + continue; // Break?? + } + + if ($next === ')') { + if ($nestingLevel > 0) { + $this->debug('Closing with filter: ' . $next, $nestingLevel, $op); + break; + } + $this->parseError($next); + } + if ($op === null && in_array($next, array('&', '|'))) { + $this->debug('Setting op to ' . $next, $nestingLevel, $op); + $op = $next; + continue; + } + $this->parseError($next); + } + } + + if ($nestingLevel === 0 && $this->pos < $this->length) { + $this->parseError($op, 'Did not read full filter'); + } + + if ($nestingLevel === 0 && count($filters) === 1 && $op !== '!') { + // There is only one filter expression, no chain + $this->debug('Returning first filter only: ' . $filters[0], $nestingLevel, $op); + return $filters[0]; + } + + if ($op === null && count($filters) === 1) { + $this->debug('No op, single filter, setting AND', $nestingLevel, $op); + $op = '&'; + } + $this->debug(sprintf('Got %d filters, returning', count($filters)), $nestingLevel, $op); + + switch ($op) { + case '&': + return Filter::matchAll($filters); + case '|': + return Filter::matchAny($filters); + case '!': + return Filter::not($filters); + case null: + return Filter::matchAll(); + default: + $this->parseError($op); + } + } + + protected function parseQueryString($string) + { + $this->pos = 0; + + $this->string = $string; + + $this->length = $string ? strlen($string) : 0; + + if ($this->length === 0) { + return Filter::matchAll(); + } + return $this->readFilters(); + } + + protected function readUnless($char) + { + $buffer = ''; + while (false !== ($c = $this->readChar())) { + if (is_array($char)) { + if (in_array($c, $char)) { + $this->pos--; + break; + } + } else { + if ($c === $char) { + $this->pos--; + break; + } + } + $buffer .= $c; + } + + return $buffer; + } + + protected function readUnlessSpecialChar() + { + return $this->readUnless(array('=', '(', ')', '&', '|', '>', '<', '!')); + } + + protected function readExpressionOperator() + { + return $this->readUnless(array('=', '>', '<', '!')); + } + + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + return false; + } + + protected function nextChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos]; + } + return false; + } +} diff --git a/library/Icinga/Data/FilterColumns.php b/library/Icinga/Data/FilterColumns.php new file mode 100644 index 0000000..7eaacea --- /dev/null +++ b/library/Icinga/Data/FilterColumns.php @@ -0,0 +1,21 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface FilterColumns +{ + /** + * Return a filterable's filter columns with their optional label as key + * + * @return array + */ + public function getFilterColumns(); + + /** + * Return a filterable's search columns + * + * @return array + */ + public function getSearchColumns(); +} diff --git a/library/Icinga/Data/Filterable.php b/library/Icinga/Data/Filterable.php new file mode 100644 index 0000000..ceca22f --- /dev/null +++ b/library/Icinga/Data/Filterable.php @@ -0,0 +1,27 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; + +/** + * Interface for filtering a result set + * + * @deprecated(EL): addFilter and applyFilter do the same in all usages. + * addFilter could be replaced w/ getFilter()->add(). We must no require classes implementing this interface to + * implement redundant methods over and over again. This interface must be moved to the namespace Icinga\Data\Filter. + * It lacks documentation. + */ +interface Filterable +{ + public function applyFilter(Filter $filter); + + public function setFilter(Filter $filter); + + public function getFilter(); + + public function addFilter(Filter $filter); + + public function where($condition, $value = null); +} diff --git a/library/Icinga/Data/Identifiable.php b/library/Icinga/Data/Identifiable.php new file mode 100644 index 0000000..7435026 --- /dev/null +++ b/library/Icinga/Data/Identifiable.php @@ -0,0 +1,17 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for objects that are identifiable by an ID of any type + */ +interface Identifiable +{ + /** + * Get the ID associated with this Identifiable object + * + * @return mixed + */ + public function getId(); +} diff --git a/library/Icinga/Data/Inspectable.php b/library/Icinga/Data/Inspectable.php new file mode 100644 index 0000000..d40ce57 --- /dev/null +++ b/library/Icinga/Data/Inspectable.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * An object for which the user can retrieve status information + * + * This interface is useful for providing summaries or diagnostic information about objects + * to users. + */ +interface Inspectable +{ + /** + * Inspect this object to gain extended information about its health + * + * @return Inspection The inspection result + */ + public function inspect(); +} diff --git a/library/Icinga/Data/Inspection.php b/library/Icinga/Data/Inspection.php new file mode 100644 index 0000000..4ee7626 --- /dev/null +++ b/library/Icinga/Data/Inspection.php @@ -0,0 +1,129 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Application\Logger; +use Icinga\Exception\ProgrammingError; + +/** + * Contains information about an object in the form of human-readable log entries and indicates if the object has errors + */ +class Inspection +{ + /** + * @var array + */ + protected $log = array(); + + /** + * @var string + */ + protected $description; + + /** + * @var string|Inspection + */ + protected $error; + + /** + * @param $description Describes the object that is being inspected + */ + public function __construct($description) + { + $this->description = $description; + } + + /** + * Get the name of this Inspection + * + * @return mixed + */ + public function getDescription() + { + return $this->description; + } + + /** + * Append the given log entry or nested inspection + * + * @throws ProgrammingError When called after erroring + * + * @param $entry string|Inspection A log entry or nested inspection + */ + public function write($entry) + { + if (isset($this->error)) { + throw new ProgrammingError('Inspection object used after error'); + } + if ($entry instanceof Inspection) { + $this->log[$entry->description] = $entry->toArray(); + } else { + Logger::debug($entry); + $this->log[] = $entry; + } + } + + /** + * Append the given log entry and fail this inspection with the given error + * + * @param $entry string|Inspection A log entry or nested inspection + * + * @throws ProgrammingError When called multiple times + * + * @return this fluent interface + */ + public function error($entry) + { + if (isset($this->error)) { + throw new ProgrammingError('Inspection object used after error'); + } + Logger::error($entry); + $this->log[] = $entry; + $this->error = $entry; + return $this; + } + + /** + * If the inspection resulted in an error + * + * @return bool + */ + public function hasError() + { + return isset($this->error); + } + + /** + * The error that caused the inspection to fail + * + * @return Inspection|string + */ + public function getError() + { + return $this->error; + } + + /** + * Convert the inspection to an array + * + * @return array An array of strings that describe the state in a human-readable form, each array element + * represents one log entry about this object. + */ + public function toArray() + { + return $this->log; + } + + /** + * Return a text representation of the inspection log entries + */ + public function __toString() + { + return sprintf( + 'Inspection: description: "%s" error: "%s"', + $this->description, + $this->error + ); + } +} diff --git a/library/Icinga/Data/Limitable.php b/library/Icinga/Data/Limitable.php new file mode 100644 index 0000000..8591a79 --- /dev/null +++ b/library/Icinga/Data/Limitable.php @@ -0,0 +1,48 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for retrieving just a portion of a result set + */ +interface Limitable +{ + /** + * Set a limit count and offset + * + * @param int $count Number of rows to return + * @param int $offset Start returning after this many rows + * + * @return self + */ + public function limit($count = null, $offset = null); + + /** + * Whether a limit is set + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit if any + * + * @return int|null + */ + public function getLimit(); + + /** + * Whether an offset is set + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset if any + * + * @return int|null + */ + public function getOffset(); +} diff --git a/library/Icinga/Data/Paginatable.php b/library/Icinga/Data/Paginatable.php new file mode 100644 index 0000000..468cca2 --- /dev/null +++ b/library/Icinga/Data/Paginatable.php @@ -0,0 +1,10 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Countable; + +interface Paginatable extends Limitable, Countable +{ +} diff --git a/library/Icinga/Data/PivotTable.php b/library/Icinga/Data/PivotTable.php new file mode 100644 index 0000000..6c7f806 --- /dev/null +++ b/library/Icinga/Data/PivotTable.php @@ -0,0 +1,396 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Application\Icinga; +use Icinga\Web\Paginator\Adapter\QueryAdapter; +use Zend_Paginator; + +class PivotTable implements Sortable +{ + /** + * The query to fetch as pivot table + * + * @var SimpleQuery + */ + protected $baseQuery; + + /** + * X-axis pivot column + * + * @var string + */ + protected $xAxisColumn; + + /** + * Y-axis pivot column + * + * @var string + */ + protected $yAxisColumn; + + /** + * Column for sorting the result set + * + * @var array + */ + protected $order = array(); + + /** + * The filter being applied on the query for the x-axis + * + * @var Filter + */ + protected $xAxisFilter; + + /** + * The filter being applied on the query for the y-axis + * + * @var Filter + */ + protected $yAxisFilter; + + /** + * The query to fetch the leading x-axis rows and their headers + * + * @var SimpleQuery + */ + protected $xAxisQuery; + + /** + * The query to fetch the leading y-axis rows and their headers + * + * @var SimpleQuery + */ + protected $yAxisQuery; + + /** + * X-axis header column + * + * @var string|null + */ + protected $xAxisHeader; + + /** + * Y-axis header column + * + * @var string|null + */ + protected $yAxisHeader; + + /** + * Create a new pivot table + * + * @param SimpleQuery $query The query to fetch as pivot table + * @param string $xAxisColumn X-axis pivot column + * @param string $yAxisColumn Y-axis pivot column + */ + public function __construct(SimpleQuery $query, $xAxisColumn, $yAxisColumn) + { + $this->baseQuery = $query; + $this->xAxisColumn = $xAxisColumn; + $this->yAxisColumn = $yAxisColumn; + } + + /** + * {@inheritdoc} + */ + public function getOrder() + { + return $this->order; + } + + /** + * {@inheritdoc} + */ + public function hasOrder() + { + return ! empty($this->order); + } + + /** + * {@inheritdoc} + */ + public function order($field, $direction = null) + { + $this->order[$field] = $direction; + return $this; + } + + /** + * Set the filter to apply on the query for the x-axis + * + * @param Filter $filter + * + * @return $this + */ + public function setXAxisFilter(Filter $filter = null) + { + $this->xAxisFilter = $filter; + return $this; + } + + /** + * Set the filter to apply on the query for the y-axis + * + * @param Filter $filter + * + * @return $this + */ + public function setYAxisFilter(Filter $filter = null) + { + $this->yAxisFilter = $filter; + return $this; + } + + /** + * Get the x-axis header + * + * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()} + * + * @return string + */ + public function getXAxisHeader() + { + return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn; + } + + /** + * Set the x-axis header + * + * @param string $xAxisHeader + * + * @return $this + */ + public function setXAxisHeader($xAxisHeader) + { + $this->xAxisHeader = (string) $xAxisHeader; + return $this; + } + + /** + * Get the y-axis header + * + * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()} + * + * @return string + */ + public function getYAxisHeader() + { + return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn; + } + + /** + * Set the y-axis header + * + * @param string $yAxisHeader + * + * @return $this + */ + public function setYAxisHeader($yAxisHeader) + { + $this->yAxisHeader = (string) $yAxisHeader; + return $this; + } + + /** + * Return the value for the given request parameter + * + * @param string $axis The axis for which to return the parameter ('x' or 'y') + * @param string $param The parameter name to return + * @param int $default The default value to return + * + * @return int + */ + protected function getPaginationParameter($axis, $param, $default = null) + { + $request = Icinga::app()->getRequest(); + + $value = $request->getParam($param, ''); + if (strpos($value, ',') > 0) { + $parts = explode(',', $value, 2); + return intval($parts[$axis === 'x' ? 0 : 1]); + } + + return $default !== null ? $default : 0; + } + + /** + * Query horizontal (x) axis + * + * @return SimpleQuery + */ + protected function queryXAxis() + { + if ($this->xAxisQuery === null) { + $this->xAxisQuery = clone $this->baseQuery; + $this->xAxisQuery->clearGroupingRules(); + $xAxisHeader = $this->getXAxisHeader(); + $columns = array($this->xAxisColumn, $xAxisHeader); + $this->xAxisQuery->group(array_unique($columns)); // xAxisColumn and header may be the same column + $this->xAxisQuery->columns($columns); + + if ($this->xAxisFilter !== null) { + $this->xAxisQuery->addFilter($this->xAxisFilter); + } + + $this->xAxisQuery->order( + $xAxisHeader, + isset($this->order[$xAxisHeader]) ? $this->order[$xAxisHeader] : self::SORT_ASC + ); + } + + return $this->xAxisQuery; + } + + /** + * Query vertical (y) axis + * + * @return SimpleQuery + */ + protected function queryYAxis() + { + if ($this->yAxisQuery === null) { + $this->yAxisQuery = clone $this->baseQuery; + $this->yAxisQuery->clearGroupingRules(); + $yAxisHeader = $this->getYAxisHeader(); + $columns = array($this->yAxisColumn, $yAxisHeader); + $this->yAxisQuery->group(array_unique($columns)); // yAxisColumn and header may be the same column + $this->yAxisQuery->columns($columns); + + if ($this->yAxisFilter !== null) { + $this->yAxisQuery->addFilter($this->yAxisFilter); + } + + $this->yAxisQuery->order( + $yAxisHeader, + isset($this->order[$yAxisHeader]) ? $this->order[$yAxisHeader] : self::SORT_ASC + ); + } + return $this->yAxisQuery; + } + + /** + * Return a pagination adapter for the x-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Zend_Paginator + */ + public function paginateXAxis($limit = null, $page = null) + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('x', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('x', 'page', 1); + } + } + + $query = $this->queryXAxis(); + $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + $paginator = new Zend_Paginator(new QueryAdapter($query)); + $paginator->setItemCountPerPage($limit); + $paginator->setCurrentPageNumber($page); + return $paginator; + } + + /** + * Return a pagination adapter for the y-axis query + * + * $limit and $page are taken from the current request if not given. + * + * @param int $limit The maximum amount of entries to fetch + * @param int $page The page to set as current one + * + * @return Zend_Paginator + */ + public function paginateYAxis($limit = null, $page = null) + { + if ($limit === null || $page === null) { + if ($limit === null) { + $limit = $this->getPaginationParameter('y', 'limit', 20); + } + + if ($page === null) { + $page = $this->getPaginationParameter('y', 'page', 1); + } + } + + $query = $this->queryYAxis(); + $query->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + $paginator = new Zend_Paginator(new QueryAdapter($query)); + $paginator->setItemCountPerPage($limit); + $paginator->setCurrentPageNumber($page); + return $paginator; + } + + /** + * Return the pivot table as an array of pivot data and pivot header + * + * @return array + */ + public function toArray() + { + if (($this->xAxisFilter === null && $this->yAxisFilter === null) + || ($this->xAxisFilter !== null && $this->yAxisFilter !== null) + ) { + $xAxis = $this->queryXAxis()->fetchPairs(); + $yAxis = $this->queryYAxis()->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + $yAxisKeys = array_keys($yAxis); + } else { + if ($this->xAxisFilter !== null) { + $xAxis = $this->queryXAxis()->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + $yAxis = $this->queryYAxis()->where($this->xAxisColumn, $xAxisKeys)->fetchPairs(); + $yAxisKeys = array_keys($yAxis); + } else { // $this->yAxisFilter !== null + $yAxis = $this->queryYAxis()->fetchPairs(); + $yAxisKeys = array_keys($yAxis); + $xAxis = $this->queryXAxis()->where($this->yAxisColumn, $yAxisKeys)->fetchPairs(); + $xAxisKeys = array_keys($xAxis); + } + } + $pivotData = array(); + $pivotHeader = array( + 'cols' => $xAxis, + 'rows' => $yAxis + ); + if (! empty($xAxis) && ! empty($yAxis)) { + $this->baseQuery + ->where($this->xAxisColumn, array_map( + function ($key) { + return (string) $key; + }, + $xAxisKeys + )) + ->where($this->yAxisColumn, array_map( + function ($key) { + return (string) $key; + }, + $yAxisKeys + )); + + foreach ($yAxisKeys as $yAxisKey) { + foreach ($xAxisKeys as $xAxisKey) { + $pivotData[$yAxisKey][$xAxisKey] = null; + } + } + + foreach ($this->baseQuery as $row) { + $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row; + } + } + return array($pivotData, $pivotHeader); + } +} diff --git a/library/Icinga/Data/QueryInterface.php b/library/Icinga/Data/QueryInterface.php new file mode 100644 index 0000000..e723857 --- /dev/null +++ b/library/Icinga/Data/QueryInterface.php @@ -0,0 +1,8 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface QueryInterface extends Fetchable, Filterable, Paginatable, Sortable +{ +} diff --git a/library/Icinga/Data/Queryable.php b/library/Icinga/Data/Queryable.php new file mode 100644 index 0000000..75cdc98 --- /dev/null +++ b/library/Icinga/Data/Queryable.php @@ -0,0 +1,20 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for specifying data sources + */ +interface Queryable +{ + /** + * Set the target and fields to query + * + * @param string $target + * @param array $fields + * + * @return Fetchable + */ + public function from($target, array $fields = null); +} diff --git a/library/Icinga/Data/Reducible.php b/library/Icinga/Data/Reducible.php new file mode 100644 index 0000000..6ece17e --- /dev/null +++ b/library/Icinga/Data/Reducible.php @@ -0,0 +1,23 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\StatementException; + +/** + * Interface for data deletion + */ +interface Reducible +{ + /** + * Delete entries in the given target, optionally limiting the affected entries by using a filter + * + * @param string $target + * @param Filter $filter + * + * @throws StatementException + */ + public function delete($target, Filter $filter = null); +} diff --git a/library/Icinga/Data/ResourceFactory.php b/library/Icinga/Data/ResourceFactory.php new file mode 100644 index 0000000..5b477c7 --- /dev/null +++ b/library/Icinga/Data/ResourceFactory.php @@ -0,0 +1,138 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Application\Config; +use Icinga\Util\ConfigAwareFactory; +use Icinga\Exception\ConfigurationError; +use Icinga\Data\Db\DbConnection; +use Icinga\Protocol\Ldap\LdapConnection; +use Icinga\Protocol\File\FileReader; + +/** + * Create resources from names or resource configuration + */ +class ResourceFactory implements ConfigAwareFactory +{ + /** + * Resource configuration + * + * @var Config + */ + private static $resources; + + /** + * Set resource configurations + * + * @param Config $config + */ + public static function setConfig($config) + { + self::$resources = $config; + } + + /** + * Get the configuration for a specific resource + * + * @param $resourceName String The resource's name + * + * @return ConfigObject The configuration of the resource + * + * @throws ConfigurationError + */ + public static function getResourceConfig($resourceName) + { + self::assertResourcesExist(); + $resourceConfig = self::$resources->getSection($resourceName); + if ($resourceConfig->isEmpty()) { + throw new ConfigurationError( + 'Cannot load resource config "%s". Resource does not exist', + $resourceName + ); + } + return $resourceConfig; + } + + /** + * Get the configuration of all existing resources, or all resources of the given type + * + * @param string $type Filter for resource type + * + * @return Config The resources configuration + */ + public static function getResourceConfigs($type = null) + { + self::assertResourcesExist(); + if ($type === null) { + return self::$resources; + } + $resources = array(); + foreach (self::$resources as $name => $resource) { + if ($resource->get('type') === $type) { + $resources[$name] = $resource; + } + } + return Config::fromArray($resources); + } + + /** + * Check if the existing resources are set. If not, load them from resources.ini + * + * @throws ConfigurationError + */ + private static function assertResourcesExist() + { + if (self::$resources === null) { + self::$resources = Config::app('resources'); + } + } + + /** + * Create and return a resource based on the given configuration + * + * @param ConfigObject $config The configuration of the resource to create + * + * @return Selectable The resource + * @throws ConfigurationError In case of an unsupported type or invalid configuration + */ + public static function createResource(ConfigObject $config) + { + switch (strtolower($config->type)) { + case 'db': + $resource = new DbConnection($config); + break; + case 'ldap': + if (empty($config->root_dn)) { + throw new ConfigurationError('LDAP root DN missing'); + } + + $resource = new LdapConnection($config); + break; + case 'file': + $resource = new FileReader($config); + break; + case 'ini': + $resource = Config::fromIni($config->ini); + break; + default: + throw new ConfigurationError( + 'Unsupported resource type "%s"', + $config->type + ); + } + + return $resource; + } + + /** + * Create a resource from name + * + * @param string $resourceName + * @return DbConnection|LdapConnection + */ + public static function create($resourceName) + { + return self::createResource(self::getResourceConfig($resourceName)); + } +} diff --git a/library/Icinga/Data/Selectable.php b/library/Icinga/Data/Selectable.php new file mode 100644 index 0000000..ace4e79 --- /dev/null +++ b/library/Icinga/Data/Selectable.php @@ -0,0 +1,17 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for classes providing a data source to fetch data from + */ +interface Selectable +{ + /** + * Provide a data source to fetch data from + * + * @return Queryable + */ + public function select(); +} diff --git a/library/Icinga/Data/SimpleQuery.php b/library/Icinga/Data/SimpleQuery.php new file mode 100644 index 0000000..1ef0c27 --- /dev/null +++ b/library/Icinga/Data/SimpleQuery.php @@ -0,0 +1,650 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Iterator; +use IteratorAggregate; +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; + +class SimpleQuery implements QueryInterface, Queryable, Iterator +{ + /** + * Query data source + * + * @var mixed + */ + protected $ds; + + /** + * This query's iterator + * + * @var Iterator + */ + protected $iterator; + + /** + * The current position of this query's iterator + * + * @var int + */ + protected $iteratorPosition; + + /** + * The amount of rows previously calculated + * + * @var int + */ + protected $cachedCount; + + /** + * The target you are going to query + * + * @var mixed + */ + protected $target; + + /** + * The columns you asked for + * + * All columns if null, no column if empty??? Alias handling goes here! + * + * @var array + */ + protected $desiredColumns = array(); + + /** + * The columns you are interested in + * + * All columns if null, no column if empty??? Alias handling goes here! + * + * @var array + */ + protected $columns = array(); + + /** + * The columns and their aliases flipped in order to handle aliased sort columns + * + * Supposed to be used and populated by $this->compare *only*. + * + * @var array + */ + protected $flippedColumns; + + /** + * The columns you're using to sort the query result + * + * @var array + */ + protected $order = array(); + + /** + * Number of rows to return + * + * @var int + */ + protected $limitCount; + + /** + * Result starts with this row + * + * @var int + */ + protected $limitOffset; + + /** + * Whether to peek ahead for more results + * + * @var bool + */ + protected $peekAhead; + + /** + * Whether the query did not yield all available results + * + * @var bool + */ + protected $hasMore; + + protected $filter; + + /** + * Constructor + * + * @param mixed $ds + */ + public function __construct($ds, $columns = null) + { + $this->ds = $ds; + $this->filter = Filter::matchAll(); + if ($columns !== null) { + $this->desiredColumns = $columns; + } + $this->init(); + if ($this->desiredColumns !== null) { + $this->columns($this->desiredColumns); + } + } + + /** + * Initialize query + * + * Overwrite this instead of __construct (it's called at the end of the construct) to + * implement custom initialization logic on construction time + */ + protected function init() + { + } + + /** + * Get the data source + * + * @return mixed + */ + public function getDatasource() + { + return $this->ds; + } + + /** + * Return the current position of this query's iterator + * + * @return int + */ + public function getIteratorPosition() + { + return $this->iteratorPosition; + } + + /** + * Start or rewind the iteration + */ + public function rewind(): void + { + if ($this->iterator === null) { + $iterator = $this->ds->query($this); + if ($iterator instanceof IteratorAggregate) { + $this->iterator = $iterator->getIterator(); + } else { + $this->iterator = $iterator; + } + } + + $this->iterator->rewind(); + $this->iteratorPosition = null; + Benchmark::measure('Query result iteration started'); + } + + /** + * Fetch and return the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + return $this->iterator->current(); + } + + /** + * Return whether the current row of this query's result is valid + * + * @return bool + */ + public function valid(): bool + { + $valid = $this->iterator->valid(); + if ($valid && $this->peekAhead && $this->hasLimit() && $this->iteratorPosition + 1 === $this->getLimit()) { + $this->hasMore = true; + $valid = false; // We arrived at the last result, which is the requested extra row, so stop the iteration + } elseif (! $valid) { + $this->hasMore = false; + } + + if (! $valid) { + Benchmark::measure('Query result iteration finished'); + return false; + } elseif ($this->iteratorPosition === null) { + $this->iteratorPosition = 0; + } + + return true; + } + + /** + * Return the key for the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->iterator->key(); + } + + /** + * Advance to the next row of this query's result + */ + public function next(): void + { + $this->iterator->next(); + $this->iteratorPosition += 1; + } + + /** + * Choose a table and the columns you are interested in + * + * Query will return all available columns if none are given here. + * + * @param mixed $target + * @param array $fields + * + * @return $this + */ + public function from($target, array $fields = null) + { + $this->target = $target; + if ($fields !== null) { + $this->columns($fields); + } + return $this; + } + + /** + * Add a where condition to the query by and + * + * The syntax of the condition and valid values are defined by the concrete backend-specific query implementation. + * + * @param string $condition + * @param mixed $value + * + * @return $this + */ + public function where($condition, $value = null) + { + // TODO: more intelligence please + $this->filter->addFilter(Filter::expression($condition, '=', $value)); + return $this; + } + + public function getFilter() + { + return $this->filter; + } + + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + public function addFilter(Filter $filter) + { + $this->filter->addFilter($filter); + return $this; + } + + public function setFilter(Filter $filter) + { + $this->filter = $filter; + return $this; + } + + public function setOrderColumns(array $orderColumns) + { + throw new IcingaException('This function does nothing and will be removed'); + } + + /** + * Split order field into its field and sort direction + * + * @param string $field + * + * @return array + */ + public function splitOrder($field) + { + $fieldAndDirection = explode(' ', $field, 2); + if (count($fieldAndDirection) === 1) { + $direction = null; + } else { + $field = $fieldAndDirection[0]; + $direction = (strtoupper(trim($fieldAndDirection[1])) === 'DESC') ? + Sortable::SORT_DESC : Sortable::SORT_ASC; + } + return array($field, $direction); + } + + /** + * Sort result set by the given field (and direction) + * + * Preferred usage: + * <code> + * $query->order('field, 'ASC') + * </code> + * + * @param string $field + * @param string $direction + * + * @return $this + */ + public function order($field, $direction = null) + { + if ($direction === null) { + list($field, $direction) = $this->splitOrder($field); + if ($direction === null) { + $direction = Sortable::SORT_ASC; + } + } else { + switch (($direction = strtoupper($direction))) { + case Sortable::SORT_ASC: + case Sortable::SORT_DESC: + break; + default: + $direction = Sortable::SORT_ASC; + break; + } + } + $this->order[] = array($field, $direction); + return $this; + } + + /** + * Compare $a with $b based on this query's sort rules and column aliases + * + * @param object $a + * @param object $b + * @param int $orderIndex + * + * @return int + */ + public function compare($a, $b, $orderIndex = 0) + { + if (! array_key_exists($orderIndex, $this->order)) { + return 0; // Last column to sort reached, rows are considered being equal + } + + if ($this->flippedColumns === null) { + $this->flippedColumns = array_flip($this->columns); + } + + $column = $this->order[$orderIndex][0]; + if (array_key_exists($column, $this->flippedColumns) && is_string($this->flippedColumns[$column])) { + $column = $this->flippedColumns[$column]; + } + + $result = strcmp(strtolower($a->$column ?? ''), strtolower($b->$column ?? '')); + if ($result === 0) { + return $this->compare($a, $b, ++$orderIndex); + } + + $direction = $this->order[$orderIndex][1]; + if ($direction === self::SORT_ASC) { + return $result; + } else { + return $result * -1; + } + } + + /** + * Clear the order if any + * + * @return $this + */ + public function clearOrder() + { + $this->order = array(); + return $this; + } + + /** + * Whether an order is set + * + * @return bool + */ + public function hasOrder() + { + return ! empty($this->order); + } + + /** + * Get the order + * + * @return array + */ + public function getOrder() + { + return $this->order; + } + + /** + * Set whether this query should peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @return $this + */ + public function peekAhead($state = true) + { + $this->peekAhead = (bool) $state; + return $this; + } + + /** + * Return whether this query did not yield all available results + * + * @return bool + * + * @throws ProgrammingError In case the query did not run yet + */ + public function hasMore() + { + if ($this->hasMore === null) { + throw new ProgrammingError('Query did not run. Cannot determine whether there are more results.'); + } + + return $this->hasMore; + } + + /** + * Return whether this query will or has yielded any result + * + * @return bool + */ + public function hasResult() + { + return $this->cachedCount > 0 || $this->iteratorPosition !== null || $this->fetchRow() !== false; + } + + /** + * Set a limit count and offset to the query + * + * @param int $count Number of rows to return + * @param int $offset Start returning after this many rows + * + * @return $this + */ + public function limit($count = null, $offset = null) + { + $this->limitCount = $count !== null ? (int) $count : null; + $this->limitOffset = (int) $offset; + return $this; + } + + /** + * Whether a limit is set + * + * @return bool + */ + public function hasLimit() + { + return $this->limitCount !== null && $this->limitCount > 0; + } + + /** + * Get the limit if any + * + * @return int|null + */ + public function getLimit() + { + return $this->peekAhead && $this->hasLimit() ? $this->limitCount + 1 : $this->limitCount; + } + + /** + * Whether an offset is set + * + * @return bool + */ + public function hasOffset() + { + return $this->limitOffset > 0; + } + + /** + * Get the offset if any + * + * @return int|null + */ + public function getOffset() + { + return $this->limitOffset; + } + + /** + * Retrieve an array containing all rows of the result set + * + * @return array + */ + public function fetchAll() + { + Benchmark::measure('Fetching all results started'); + $results = $this->ds->fetchAll($this); + Benchmark::measure('Fetching all results finished'); + + if ($this->peekAhead && $this->hasLimit() && count($results) === $this->getLimit()) { + $this->hasMore = true; + array_pop($results); + } else { + $this->hasMore = false; + } + + return $results; + } + + /** + * Fetch the first row of the result set + * + * @return mixed + */ + public function fetchRow() + { + Benchmark::measure('Fetching one row started'); + $row = $this->ds->fetchRow($this); + Benchmark::measure('Fetching one row finished'); + return $row; + } + + /** + * Fetch the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn() + { + Benchmark::measure('Fetching one column started'); + $values = $this->ds->fetchColumn($this); + Benchmark::measure('Fetching one column finished'); + + if ($this->peekAhead && $this->hasLimit() && count($values) === $this->getLimit()) { + $this->hasMore = true; + array_pop($values); + } else { + $this->hasMore = false; + } + + return $values; + } + + /** + * Fetch the first column of the first row of the result set + * + * @return string + */ + public function fetchOne() + { + Benchmark::measure('Fetching one value started'); + $value = $this->ds->fetchOne($this); + Benchmark::measure('Fetching one value finished'); + return $value; + } + + /** + * Fetch all rows of the result set as an array of key-value pairs + * + * The first column is the key, the second column is the value. + * + * @return array + */ + public function fetchPairs() + { + Benchmark::measure('Fetching pairs started'); + $pairs = $this->ds->fetchPairs($this); + Benchmark::measure('Fetching pairs finished'); + + if ($this->peekAhead && $this->hasLimit() && count($pairs) === $this->getLimit()) { + $this->hasMore = true; + array_pop($pairs); + } else { + $this->hasMore = false; + } + + return $pairs; + } + + /** + * Count all rows of the result set, ignoring limit and offset + * + * @return int + */ + public function count(): int + { + $query = clone $this; + $query->limit(0, 0); + Benchmark::measure('Counting all results started'); + $count = $this->ds->count($query); + $this->cachedCount = $count; + Benchmark::measure('Counting all results finished'); + return $count; + } + + /** + * Set columns + * + * @param array $columns + * + * @return $this + */ + public function columns(array $columns) + { + $this->columns = $columns; + $this->flippedColumns = null; // Reset, due to updated columns + return $this; + } + + public function getColumns() + { + return $this->columns; + } + + /** + * Deep clone self::$filter + */ + public function __clone() + { + $this->filter = clone $this->filter; + } +} diff --git a/library/Icinga/Data/SortRules.php b/library/Icinga/Data/SortRules.php new file mode 100644 index 0000000..c93bdda --- /dev/null +++ b/library/Icinga/Data/SortRules.php @@ -0,0 +1,14 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +interface SortRules +{ + /** + * Return some sort rules + * + * @return array + */ + public function getSortRules(); +} diff --git a/library/Icinga/Data/Sortable.php b/library/Icinga/Data/Sortable.php new file mode 100644 index 0000000..11d38c3 --- /dev/null +++ b/library/Icinga/Data/Sortable.php @@ -0,0 +1,49 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +/** + * Interface for sorting a result set + */ +interface Sortable +{ + /** + * Sort ascending + */ + const SORT_ASC = 'ASC'; + + /** + * Sort descending + */ + const SORT_DESC = 'DESC'; + + /** + * Sort result set by the given field (and direction) + * + * Preferred usage: + * <code> + * $query->order('field, 'ASC') + * </code> + * + * @param string $field + * @param string $direction + * + * @return self + */ + public function order($field, $direction = null); + + /** + * Whether an order is set + * + * @return bool + */ + public function hasOrder(); + + /** + * Get the order if any + * + * @return array|null + */ + public function getOrder(); +} diff --git a/library/Icinga/Data/Tree/SimpleTree.php b/library/Icinga/Data/Tree/SimpleTree.php new file mode 100644 index 0000000..e89f589 --- /dev/null +++ b/library/Icinga/Data/Tree/SimpleTree.php @@ -0,0 +1,90 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use IteratorAggregate; +use LogicException; +use Traversable; + +/** + * A simple tree + */ +class SimpleTree implements IteratorAggregate +{ + /** + * Root node + * + * @var TreeNode + */ + protected $sentinel; + + /** + * Nodes + * + * @var array + */ + protected $nodes = array(); + + /** + * Create a new simple tree + */ + public function __construct() + { + $this->sentinel = new TreeNode(); + } + + /** + * Add a child node + * + * @param TreeNode $child + * @param TreeNode $parent + * + * @return $this + */ + public function addChild(TreeNode $child, TreeNode $parent = null) + { + if ($parent === null) { + $parent = $this->sentinel; + } elseif (! isset($this->nodes[$parent->getId()])) { + throw new LogicException(sprintf( + 'Can\'t append child node %s to parent node %s: Parent node does not exist', + $child->getId(), + $parent->getId() + )); + } + if (isset($this->nodes[$child->getId()])) { + throw new LogicException(sprintf( + 'Can\'t append child node %s to parent node %s: Child node does already exist', + $child->getId(), + $parent->getId() + )); + } + $this->nodes[$child->getId()] = $child; + $parent->appendChild($child); + return $this; + } + + /** + * Get a node by its ID + * + * @param mixed $id + * + * @return TreeNode|null + */ + public function getNode($id) + { + if (! isset($this->nodes[$id])) { + return null; + } + return $this->nodes[$id]; + } + + /** + * @return TreeNodeIterator + */ + public function getIterator(): Traversable + { + return new TreeNodeIterator($this->sentinel); + } +} diff --git a/library/Icinga/Data/Tree/TreeNode.php b/library/Icinga/Data/Tree/TreeNode.php new file mode 100644 index 0000000..66bce79 --- /dev/null +++ b/library/Icinga/Data/Tree/TreeNode.php @@ -0,0 +1,109 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use Icinga\Data\Identifiable; + +class TreeNode implements Identifiable +{ + /** + * The node's ID + * + * @var mixed + */ + protected $id; + + /** + * The node's value + * + * @var mixed + */ + protected $value; + + /** + * The node's children + * + * @var array + */ + protected $children = array(); + + /** + * Set the node's ID + * + * @param mixed $id ID of the node + * + * @return $this + */ + public function setId($id) + { + $this->id = $id; + return $this; + } + + /** + * (non-PHPDoc) + * @see Identifiable::getId() For the method documentation. + */ + public function getId() + { + return $this->id; + } + + /** + * Set the node's value + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + return $this; + } + + /** + * Get the node's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Append a child node as the last child of this node + * + * @param TreeNode $child The child to append + * + * @return $this + */ + public function appendChild(TreeNode $child) + { + $this->children[] = $child; + return $this; + } + + + /** + * Get whether the node has children + * + * @return bool + */ + public function hasChildren() + { + return ! empty($this->children); + } + + /** + * Get the node's children + * + * @return array + */ + public function getChildren() + { + return $this->children; + } +} diff --git a/library/Icinga/Data/Tree/TreeNodeIterator.php b/library/Icinga/Data/Tree/TreeNodeIterator.php new file mode 100644 index 0000000..cffc9f4 --- /dev/null +++ b/library/Icinga/Data/Tree/TreeNodeIterator.php @@ -0,0 +1,75 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data\Tree; + +use ArrayIterator; +use RecursiveIterator; + +/** + * Iterator over a tree node's children + */ +class TreeNodeIterator implements RecursiveIterator +{ + /** + * The node's children + * + * @var array + */ + protected $children; + + /** + * Create a new iterator over a tree node's children + * + * @param TreeNode $node + */ + public function __construct(TreeNode $node) + { + $this->children = new ArrayIterator($node->getChildren()); + } + + public function current(): TreeNode + { + return $this->children->current(); + } + + public function key(): int + { + return $this->children->key(); + } + + public function next(): void + { + $this->children->next(); + } + + public function rewind(): void + { + $this->children->rewind(); + } + + public function valid(): bool + { + return $this->children->valid(); + } + + public function hasChildren(): bool + { + return $this->current()->hasChildren(); + } + + public function getChildren(): TreeNodeIterator + { + return new static($this->current()); + } + + /** + * Get whether the iterator is empty + * + * @return bool + */ + public function isEmpty() + { + return ! $this->children->count(); + } +} diff --git a/library/Icinga/Data/Updatable.php b/library/Icinga/Data/Updatable.php new file mode 100644 index 0000000..ff70b99 --- /dev/null +++ b/library/Icinga/Data/Updatable.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\StatementException; + +/** + * Interface for data updating + */ +interface Updatable +{ + /** + * Update the target with the given data and optionally limit the affected entries by using a filter + * + * @param string $target + * @param array $data + * @param Filter $filter + * + * @throws StatementException + */ + public function update($target, array $data, Filter $filter = null); +} |