diff options
Diffstat (limited to 'library/Icinga/Repository')
-rw-r--r-- | library/Icinga/Repository/DbRepository.php | 1077 | ||||
-rw-r--r-- | library/Icinga/Repository/IniRepository.php | 420 | ||||
-rw-r--r-- | library/Icinga/Repository/LdapRepository.php | 71 | ||||
-rw-r--r-- | library/Icinga/Repository/Repository.php | 1261 | ||||
-rw-r--r-- | library/Icinga/Repository/RepositoryQuery.php | 797 |
5 files changed, 3626 insertions, 0 deletions
diff --git a/library/Icinga/Repository/DbRepository.php b/library/Icinga/Repository/DbRepository.php new file mode 100644 index 0000000..039d221 --- /dev/null +++ b/library/Icinga/Repository/DbRepository.php @@ -0,0 +1,1077 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Zend_Db_Expr; +use Icinga\Data\Db\DbConnection; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Reducible; +use Icinga\Data\Updatable; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\StatementException; +use Icinga\Util\StringHelper; + +/** + * Abstract base class for concrete database repository implementations + * + * Additionally provided features: + * <ul> + * <li>Support for table aliases</li> + * <li>Automatic table prefix handling</li> + * <li>Insert, update and delete capabilities</li> + * <li>Differentiation between statement and query columns</li> + * <li>Capability to join additional tables depending on the columns being selected or used in a filter</li> + * </ul> + * + * @method DbConnection getDataSource($table = null) + */ +abstract class DbRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The datasource being used + * + * @var DbConnection + */ + protected $ds; + + /** + * The table aliases being applied + * + * This must be initialized by repositories which are going to make use of table aliases. Every table for which + * aliased columns are provided must be defined in this array using its name as key and the alias being used as + * value. Failure to do so will result in invalid queries. + * + * @var array + */ + protected $tableAliases; + + /** + * The join probability rules + * + * This may be initialized by repositories which make use of the table join capability. It allows to define + * probability rules to enhance control how ambiguous column aliases are associated with the correct table. + * To define a rule use the name of a base table as key and another array of table names as probable join + * targets ordered by priority. (Ascending: Lower means higher priority) + * <code> + * array( + * 'table_name' => array('target1', 'target2', 'target3') + * ) + * </code> + * + * @todo Support for tree-ish rules + * + * @var array + */ + protected $joinProbabilities; + + /** + * The statement columns being provided + * + * This may be initialized by repositories which are going to make use of table aliases. It allows to provide + * alias-less column names to be used for a statement. The array needs to be in the following format: + * <code> + * array( + * 'table_name' => array( + * 'column1', + * 'alias1' => 'column2', + * 'alias2' => 'column3' + * ) + * ) + * </code> + * + * @var array + */ + protected $statementColumns; + + /** + * An array to map table names to statement columns/aliases + * + * @var array + */ + protected $statementAliasTableMap; + + /** + * A flattened array to map statement columns to aliases + * + * @var array + */ + protected $statementAliasColumnMap; + + /** + * An array to map table names to statement columns + * + * @var array + */ + protected $statementColumnTableMap; + + /** + * A flattened array to map aliases to statement columns + * + * @var array + */ + protected $statementColumnAliasMap; + + /** + * List of column names or aliases mapped to their table where the COLLATE SQL-instruction has been removed + * + * This list is being populated in case of a PostgreSQL backend only, + * to ensure case-insensitive string comparison in WHERE clauses. + * + * @var array + */ + protected $caseInsensitiveColumns; + + /** + * Create a new DB repository object + * + * In case $this->queryColumns has already been initialized, this initializes + * $this->caseInsensitiveColumns in case of a PostgreSQL connection. + * + * @param DbConnection $ds The datasource to use + */ + public function __construct(DbConnection $ds) + { + parent::__construct($ds); + + if ($ds->getDbType() === 'pgsql' && $this->queryColumns !== null) { + $this->queryColumns = $this->removeCollateInstruction($this->queryColumns); + } + } + + /** + * Return the query columns being provided + * + * Initializes $this->caseInsensitiveColumns in case of a PostgreSQL connection. + * + * @return array + */ + public function getQueryColumns() + { + if ($this->queryColumns === null) { + $this->queryColumns = parent::getQueryColumns(); + if ($this->ds->getDbType() === 'pgsql') { + $this->queryColumns = $this->removeCollateInstruction($this->queryColumns); + } + } + + return $this->queryColumns; + } + + /** + * Return the table aliases to be applied + * + * Calls $this->initializeTableAliases() in case $this->tableAliases is null. + * + * @return array + */ + public function getTableAliases() + { + if ($this->tableAliases === null) { + $this->tableAliases = $this->initializeTableAliases(); + } + + return $this->tableAliases; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the table aliases lazily + * + * @return array + */ + protected function initializeTableAliases() + { + return array(); + } + + /** + * Return the join probability rules + * + * Calls $this->initializeJoinProbabilities() in case $this->joinProbabilities is null. + * + * @return array + */ + public function getJoinProbabilities() + { + if ($this->joinProbabilities === null) { + $this->joinProbabilities = $this->initializeJoinProbabilities(); + } + + return $this->joinProbabilities; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the join probabilities lazily + * + * @return array + */ + protected function initializeJoinProbabilities() + { + return array(); + } + + /** + * Remove each COLLATE SQL-instruction from all given query columns + * + * @param array $queryColumns + * + * @return array $queryColumns, the updated version + */ + protected function removeCollateInstruction($queryColumns) + { + foreach ($queryColumns as $table => & $columns) { + foreach ($columns as $alias => & $column) { + // Using a regex here because COLLATE may occur anywhere in the string + $column = preg_replace('/ COLLATE .+$/', '', $column, -1, $count); + if ($count > 0) { + $this->caseInsensitiveColumns[$table][is_string($alias) ? $alias : $column] = true; + } + } + } + + return $queryColumns; + } + + /** + * Initialize table, column and alias maps + * + * @throws ProgrammingError In case $this->queryColumns does not provide any column information + */ + protected function initializeAliasMaps() + { + parent::initializeAliasMaps(); + + foreach ($this->aliasTableMap as $alias => $table) { + if ($table !== null) { + if (strpos($alias, '.') !== false) { + $prefixedAlias = str_replace('.', '_', $alias); + } else { + $prefixedAlias = $table . '_' . $alias; + } + + if (array_key_exists($prefixedAlias, $this->aliasTableMap)) { + if ($this->aliasTableMap[$prefixedAlias] !== null) { + $existingTable = $this->aliasTableMap[$prefixedAlias]; + $existingColumn = $this->aliasColumnMap[$prefixedAlias]; + $this->aliasTableMap[$existingTable . '.' . $prefixedAlias] = $existingTable; + $this->aliasColumnMap[$existingTable . '.' . $prefixedAlias] = $existingColumn; + $this->aliasTableMap[$prefixedAlias] = null; + $this->aliasColumnMap[$prefixedAlias] = null; + } + + $this->aliasTableMap[$table . '.' . $prefixedAlias] = $table; + $this->aliasColumnMap[$table . '.' . $prefixedAlias] = $this->aliasColumnMap[$alias]; + } else { + $this->aliasTableMap[$prefixedAlias] = $table; + $this->aliasColumnMap[$prefixedAlias] = $this->aliasColumnMap[$alias]; + } + } + } + } + + /** + * Return the given table with the datasource's prefix being prepended + * + * @param array|string $table + * + * @return array|string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function prependTablePrefix($table) + { + $prefix = $this->ds->getTablePrefix(); + if (! $prefix) { + return $table; + } + + if (is_array($table)) { + foreach ($table as & $tableName) { + if (strpos($tableName, $prefix) === false) { + $tableName = $prefix . $tableName; + } + } + } elseif (is_string($table)) { + $table = (strpos($table, $prefix) === false ? $prefix : '') . $table; + } else { + throw new IcingaException('Table prefix handling for type "%s" is not supported', type($table)); + } + + return $table; + } + + /** + * Remove the datasource's prefix from the given table name and return the remaining part + * + * @param array|string $table + * + * @return array|string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function removeTablePrefix($table) + { + $prefix = $this->ds->getTablePrefix(); + if (! $prefix) { + return $table; + } + + if (is_array($table)) { + foreach ($table as & $tableName) { + if (strpos($tableName, $prefix) === 0) { + $tableName = str_replace($prefix, '', $tableName); + } + } + } elseif (is_string($table)) { + if (strpos($table, $prefix) === 0) { + $table = str_replace($prefix, '', $table); + } + } else { + throw new IcingaException('Table prefix handling for type "%s" is not supported', type($table)); + } + + return $table; + } + + /** + * Return the given table with its alias being applied + * + * @param array|string $table + * @param string $virtualTable + * + * @return array|string + */ + protected function applyTableAlias($table, $virtualTable = null) + { + if (! is_array($table)) { + $tableAliases = $this->getTableAliases(); + if ($virtualTable !== null && isset($tableAliases[$virtualTable])) { + return array($tableAliases[$virtualTable] => $table); + } + + if (isset($tableAliases[($nonPrefixedTable = $this->removeTablePrefix($table))])) { + return array($tableAliases[$nonPrefixedTable] => $table); + } + } + + return $table; + } + + /** + * Return the given table with its alias being cleared + * + * @param array|string $table + * + * @return string + * + * @throws IcingaException In case $table is not of a supported type + */ + protected function clearTableAlias($table) + { + if (is_string($table)) { + return $table; + } + + if (is_array($table)) { + return reset($table); + } + + throw new IcingaException('Table alias handling for type "%s" is not supported', type($table)); + } + + /** + * 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()) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + foreach ($types as $alias => $type) { + unset($types[$alias]); + $types[$this->requireStatementColumn($table, $alias)] = $type; + } + + return $this->ds->insert($realTable, $this->requireStatementColumns($table, $bind), $types); + } + + /** + * 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()) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + foreach ($types as $alias => $type) { + unset($types[$alias]); + $types[$this->requireStatementColumn($table, $alias)] = $type; + } + + return $this->ds->update($realTable, $this->requireStatementColumns($table, $bind), $filter, $types); + } + + /** + * 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) + { + $realTable = $this->clearTableAlias($this->requireTable($table)); + + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + return $this->ds->delete($realTable, $filter); + } + + /** + * Return the statement columns being provided + * + * Calls $this->initializeStatementColumns() in case $this->statementColumns is null. + * + * @return array + */ + public function getStatementColumns() + { + if ($this->statementColumns === null) { + $this->statementColumns = $this->initializeStatementColumns(); + } + + return $this->statementColumns; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the statement columns lazily + * + * @return array + */ + protected function initializeStatementColumns() + { + return array(); + } + + /** + * Return an array to map table names to statement columns/aliases + * + * @return array + */ + protected function getStatementAliasTableMap() + { + if ($this->statementAliasTableMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementAliasTableMap; + } + + /** + * Return a flattened array to map statement columns to aliases + * + * @return array + */ + protected function getStatementAliasColumnMap() + { + if ($this->statementAliasColumnMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementAliasColumnMap; + } + + /** + * Return an array to map table names to statement columns + * + * @return array + */ + protected function getStatementColumnTableMap() + { + if ($this->statementColumnTableMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementColumnTableMap; + } + + /** + * Return a flattened array to map aliases to statement columns + * + * @return array + */ + protected function getStatementColumnAliasMap() + { + if ($this->statementColumnAliasMap === null) { + $this->initializeStatementMaps(); + } + + return $this->statementColumnAliasMap; + } + + /** + * Initialize $this->statementAliasTableMap and $this->statementAliasColumnMap + */ + protected function initializeStatementMaps() + { + $this->statementAliasTableMap = array(); + $this->statementAliasColumnMap = array(); + $this->statementColumnTableMap = array(); + $this->statementColumnAliasMap = array(); + foreach ($this->getStatementColumns() as $table => $columns) { + foreach ($columns as $alias => $column) { + $key = is_string($alias) ? $alias : $column; + if (array_key_exists($key, $this->statementAliasTableMap)) { + if ($this->statementAliasTableMap[$key] !== null) { + $existingTable = $this->statementAliasTableMap[$key]; + $existingColumn = $this->statementAliasColumnMap[$key]; + $this->statementAliasTableMap[$existingTable . '.' . $key] = $existingTable; + $this->statementAliasColumnMap[$existingTable . '.' . $key] = $existingColumn; + $this->statementAliasTableMap[$key] = null; + $this->statementAliasColumnMap[$key] = null; + } + + $this->statementAliasTableMap[$table . '.' . $key] = $table; + $this->statementAliasColumnMap[$table . '.' . $key] = $column; + } else { + $this->statementAliasTableMap[$key] = $table; + $this->statementAliasColumnMap[$key] = $column; + } + + if (array_key_exists($column, $this->statementColumnTableMap)) { + if ($this->statementColumnTableMap[$column] !== null) { + $existingTable = $this->statementColumnTableMap[$column]; + $existingAlias = $this->statementColumnAliasMap[$column]; + $this->statementColumnTableMap[$existingTable . '.' . $column] = $existingTable; + $this->statementColumnAliasMap[$existingTable . '.' . $column] = $existingAlias; + $this->statementColumnTableMap[$column] = null; + $this->statementColumnAliasMap[$column] = null; + } + + $this->statementColumnTableMap[$table . '.' . $column] = $table; + $this->statementColumnAliasMap[$table . '.' . $column] = $key; + } else { + $this->statementColumnTableMap[$column] = $table; + $this->statementColumnAliasMap[$column] = $key; + } + } + } + } + + /** + * Return whether this repository is capable of converting values for the given table and optional column + * + * This does not check whether any conversion for the given table is available if $column is not given, as it + * may be possible that columns from another table where joined in which would otherwise not being converted. + * + * @param string $table + * @param string $column + * + * @return bool + */ + public function providesValueConversion($table, $column = null) + { + if ($column !== null) { + if ($column instanceof Zend_Db_Expr) { + return false; + } + + if ($this->validateQueryColumnAssociation($table, $column)) { + return parent::providesValueConversion($table, $column); + } + + if (($tableName = $this->findTableName($column, $table))) { + return parent::providesValueConversion($tableName, $column); + } + + return false; + } + + $conversionRules = $this->getConversionRules(); + return !empty($conversionRules); + } + + /** + * Return the name of the conversion method for the given alias or column name and context + * + * If a query column or a filter column, which is part of a query filter, needs to be converted, + * you'll need to pass $query, otherwise the column is considered a statement column. + * + * @param string $table The datasource's table + * @param string $name The alias or column name for which to return a conversion method + * @param string $context The context of the conversion: persist or retrieve + * @param RepositoryQuery $query If given the column is considered a query column, + * statement column otherwise + * + * @return string + * + * @throws ProgrammingError In case a conversion rule is found but not any conversion method + */ + protected function getConverter($table, $name, $context, RepositoryQuery $query = null) + { + if ($name instanceof Zend_Db_Expr) { + return; + } + + if (! ($query !== null && $this->validateQueryColumnAssociation($table, $name)) + && !($query === null && $this->validateStatementColumnAssociation($table, $name)) + ) { + $table = $this->findTableName($name, $table); + if (! $table) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + return; + } + } + + throw new ProgrammingError('Column name validation seems to have failed. Did you require the column?'); + } + } + + return parent::getConverter($table, $name, $context, $query); + } + + /** + * Validate that the requested table exists + * + * This will prepend the datasource's table prefix and will apply the table's alias, if any. + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return array|string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + $virtualTable = null; + $statementColumns = $this->getStatementColumns(); + if (! isset($statementColumns[$table])) { + $newTable = parent::requireTable($table); + if ($newTable !== $table) { + $virtualTable = $table; + } + + $table = $newTable; + } else { + $virtualTables = $this->getVirtualTables(); + if (isset($virtualTables[$table])) { + $virtualTable = $table; + $table = $virtualTables[$table]; + } + } + + return $this->prependTablePrefix($this->applyTableAlias($table, $virtualTable)); + } + + /** + * Return the alias for the given table or null if none has been defined + * + * @param string $table + * + * @return string|null + */ + public function resolveTableAlias($table) + { + $tableAliases = $this->getTableAliases(); + if (isset($tableAliases[$table])) { + return $tableAliases[$table]; + } + } + + /** + * Return the alias for the given query column name or null in case the query column name does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleQueryColumnAlias($table, $column) + { + $alias = parent::reassembleQueryColumnAlias($table, $column); + if ($alias === null + && !$this->validateQueryColumnAssociation($table, $column) + && ($tableName = $this->findTableName($column, $table)) + ) { + return parent::reassembleQueryColumnAlias($tableName, $column); + } + + return $alias; + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * Attempts to join the given column from a different table if its association to the given table cannot be + * verified. + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context, + * if not given no join will be attempted + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + if ($name instanceof Zend_Db_Expr) { + return $name; + } + + if ($query === null || $this->validateQueryColumnAssociation($table, $name)) { + return parent::requireQueryColumn($table, $name, $query); + } + + $column = $this->joinColumn($name, $table, $query); + if ($column === null) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + $column = $desiredColumns[$name]; + } + } + + if ($column === null) { + throw new ProgrammingError( + 'Unable to find a valid table for column "%s" to join into "%s"', + $name, + $table + ); + } + } + + return $column; + } + + /** + * Validate that the given column is a valid filter target and return it or the actual name if it's an alias + * + * Attempts to join the given column from a different table if its association to the given table cannot be + * verified. In case of a PostgreSQL connection and if a COLLATE SQL-instruction is part of the resolved column, + * this applies LOWER() on the column and, if given, strtolower() on the filter's expression. + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context, + * if not given the column is considered being used for a statement filter + * @param FilterExpression $filter An optional filter to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid filter column + * @throws ProgrammingError In case the given column is not found in $table and cannot be joined in + */ + public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null) + { + if ($name instanceof Zend_Db_Expr) { + return $name; + } + + $joined = false; + if ($query === null) { + $column = $this->requireStatementColumn($table, $name); + } elseif ($this->validateQueryColumnAssociation($table, $name)) { + $column = parent::requireFilterColumn($table, $name, $query, $filter); + } else { + $column = $this->joinColumn($name, $table, $query); + if ($column === null) { + if ($query !== null) { + // It may be an aliased Zend_Db_Expr + $desiredColumns = $query->getColumns(); + if (isset($desiredColumns[$name]) && $desiredColumns[$name] instanceof Zend_Db_Expr) { + $column = $desiredColumns[$name]; + } + } + + if ($column === null) { + throw new ProgrammingError( + 'Unable to find a valid table for column "%s" to join into "%s"', + $name, + $table + ); + } + } else { + $joined = true; + } + } + + if (! empty($this->caseInsensitiveColumns)) { + if ($joined) { + $table = $this->findTableName($name, $table); + } + + if ($column === $name) { + if ($query === null) { + $name = $this->reassembleStatementColumnAlias($table, $name); + } else { + $name = $this->reassembleQueryColumnAlias($table, $name); + } + } + + if (isset($this->caseInsensitiveColumns[$table][$name])) { + $column = 'LOWER(' . $column . ')'; + if ($filter !== null) { + $expression = $filter->getExpression(); + if (is_array($expression)) { + $filter->setExpression(array_map('strtolower', $expression)); + } else { + $filter->setExpression(strtolower($expression)); + } + } + } + } + + return $column; + } + + /** + * Return the statement column name for the given alias or null in case the alias does not exist + * + * @param string $table + * @param string $alias + * + * @return string|null + */ + public function resolveStatementColumnAlias($table, $alias) + { + $statementAliasColumnMap = $this->getStatementAliasColumnMap(); + if (isset($statementAliasColumnMap[$alias])) { + return $statementAliasColumnMap[$alias]; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($statementAliasColumnMap[$prefixedAlias])) { + return $statementAliasColumnMap[$prefixedAlias]; + } + } + + /** + * Return the alias for the given statement column name or null in case the statement column does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleStatementColumnAlias($table, $column) + { + $statementColumnAliasMap = $this->getStatementColumnAliasMap(); + if (isset($statementColumnAliasMap[$column])) { + return $statementColumnAliasMap[$column]; + } + + $prefixedColumn = $table . '.' . $column; + if (isset($statementColumnAliasMap[$prefixedColumn])) { + return $statementColumnAliasMap[$prefixedColumn]; + } + } + + /** + * Return whether the given alias or statement column name is available in the given table + * + * @param string $table + * @param string $alias + * + * @return bool + */ + public function validateStatementColumnAssociation($table, $alias) + { + $statementAliasTableMap = $this->getStatementAliasTableMap(); + if (isset($statementAliasTableMap[$alias])) { + return $statementAliasTableMap[$alias] === $table; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($statementAliasTableMap[$prefixedAlias])) { + return true; + } + + $statementColumnTableMap = $this->getStatementColumnTableMap(); + if (isset($statementColumnTableMap[$alias])) { + return $statementColumnTableMap[$alias] === $table; + } + + return isset($statementColumnTableMap[$prefixedAlias]); + } + + /** + * Return whether the given column name or alias of the given table is a valid statement column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasStatementColumn($table, $name) + { + if (($this->resolveStatementColumnAlias($table, $name) === null + && $this->reassembleStatementColumnAlias($table, $name) === null) + || !$this->validateStatementColumnAssociation($table, $name) + ) { + return parent::hasStatementColumn($table, $name); + } + + return true; + } + + /** + * Validate that the given column is a valid statement column and return it or the actual name if it's an alias + * + * @param string $table The table for which to require the column + * @param string $name The name or alias of the column to validate + * + * @return string The given column's name + * + * @throws StatementException In case the given column is not a statement column + */ + public function requireStatementColumn($table, $name) + { + if (($column = $this->resolveStatementColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleStatementColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + return parent::requireStatementColumn($table, $name); + } + + if (! $this->validateStatementColumnAssociation($table, $alias)) { + throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table); + } + + return $column; + } + + /** + * Join alias or column $name into $table using $query + * + * Attempts to find a valid table for the given alias or column name and a method labelled join<TableName> + * to process the actual join logic. If neither of those is found, null is returned. + * The method is called with the same parameters but in reversed order. + * + * @param string $name The alias or column name to join into $target + * @param string $target The table to join $name into + * @param RepositoryQUery $query The query to apply the JOIN-clause on + * + * @return string|null The resolved alias or $name, null if no join logic is found + */ + public function joinColumn($name, $target, RepositoryQuery $query) + { + if (! ($tableName = $this->findTableName($name, $target))) { + return; + } + + if (($column = $this->resolveQueryColumnAlias($tableName, $name)) === null) { + $column = $name; + } + + if (($joinIdentifier = $this->resolveTableAlias($tableName)) === null) { + $joinIdentifier = $this->prependTablePrefix($tableName); + } + if ($query->getQuery()->hasJoinedTable($joinIdentifier)) { + return $column; + } + + $joinMethod = 'join' . StringHelper::cname($tableName); + if (! method_exists($this, $joinMethod)) { + throw new ProgrammingError( + 'Unable to join table "%s" into "%s". Method "%s" not found', + $tableName, + $target, + $joinMethod + ); + } + + $this->$joinMethod($query, $target, $name); + return $column; + } + + /** + * Return the table name for the given alias or column name + * + * @param string $column The alias or column name + * @param string $origin The base table of a SELECT query + * + * @return string|null null in case no table is found + */ + protected function findTableName($column, $origin) + { + // First, try to produce an exact match since it's faster and cheaper + $aliasTableMap = $this->getAliasTableMap(); + if (isset($aliasTableMap[$column])) { + $table = $aliasTableMap[$column]; + } else { + $columnTableMap = $this->getColumnTableMap(); + if (isset($columnTableMap[$column])) { + $table = $columnTableMap[$column]; + } + } + + // But only return it if it's a probable join... + $joinProbabilities = $this->getJoinProbabilities(); + if (isset($joinProbabilities[$origin])) { + $probableJoins = $joinProbabilities[$origin]; + } + + // ...if probability can be determined + if (isset($table) && (empty($probableJoins) || in_array($table, $probableJoins, true))) { + return $table; + } + + // Without a proper exact match, there is only one fast and cheap way to find a suitable table.. + if (! empty($probableJoins)) { + foreach ($probableJoins as $table) { + if (isset($aliasTableMap[$table . '.' . $column])) { + return $table; + } + } + } + + // Last chance to find a table. Though, this usually ends up with a QueryException.. + foreach ($aliasTableMap as $prefixedAlias => $table) { + if (strpos($prefixedAlias, '.') !== false) { + list($_, $alias) = explode('.', $prefixedAlias, 2); + if ($alias === $column) { + return $table; + } + } + } + } +} diff --git a/library/Icinga/Repository/IniRepository.php b/library/Icinga/Repository/IniRepository.php new file mode 100644 index 0000000..7385b3e --- /dev/null +++ b/library/Icinga/Repository/IniRepository.php @@ -0,0 +1,420 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Exception; +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Updatable; +use Icinga\Data\Reducible; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\StatementException; + +/** + * Abstract base class for concrete INI repository implementations + * + * Additionally provided features: + * <ul> + * <li>Insert, update and delete capabilities</li> + * <li>Triggers for inserts, updates and deletions</li> + * <li>Lazy initialization of table specific configs</li> + * </ul> + */ +abstract class IniRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The configuration files used as table specific datasources + * + * This must be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'table_name' => array( + * 'name' => 'name_of_the_ini_file_without_extension', + * 'keyColumn' => 'the_name_of_the_column_to_use_as_key_column', + * ['module' => 'the_name_of_the_module_if_any'] + * ) + * ) + * </code> + * + * @var array + */ + protected $configs; + + /** + * The tables for which triggers are available when inserting, updating or deleting rows + * + * This may be initialized by concrete repository implementations and describes for which table names triggers + * are available. The repository attempts to find a method depending on the type of event and table for which + * to run the trigger. The name of such a method is expected to be declared using lowerCamelCase. + * (e.g. group_membership will be translated to onUpdateGroupMembership and groupmembership will be translated + * to onUpdateGroupmembership) The available events are onInsert, onUpdate and onDelete. + * + * @var array + */ + protected $triggers; + + /** + * Create a new INI repository object + * + * @param Config|null $ds The data source to use + * + * @throws ProgrammingError In case the given data source does not provide a valid key column + */ + public function __construct(Config $ds = null) + { + parent::__construct($ds); // First! Due to init(). + + if ($ds !== null && !$ds->getConfigObject()->getKeyColumn()) { + throw new ProgrammingError('INI repositories require their data source to provide a valid key column'); + } + } + + /** + * {@inheritDoc} + * + * @return Config + */ + public function getDataSource($table = null) + { + if ($this->ds !== null) { + return parent::getDataSource($table); + } + + $table = $table ?: $this->getBaseTable(); + $configs = $this->getConfigs(); + if (! isset($configs[$table])) { + throw new ProgrammingError('Config for table "%s" missing', $table); + } elseif (! $configs[$table] instanceof Config) { + $configs[$table] = $this->createConfig($configs[$table], $table); + } + + if (! $configs[$table]->getConfigObject()->getKeyColumn()) { + throw new ProgrammingError( + 'INI repositories require their data source to provide a valid key column' + ); + } + + return $configs[$table]; + } + + /** + * Return the configuration files used as table specific datasources + * + * Calls $this->initializeConfigs() in case $this->configs is null. + * + * @return array + */ + public function getConfigs() + { + if ($this->configs === null) { + $this->configs = $this->initializeConfigs(); + } + + return $this->configs; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the configs lazily + * + * @return array + */ + protected function initializeConfigs() + { + return array(); + } + + /** + * Return the tables for which triggers are available when inserting, updating or deleting rows + * + * Calls $this->initializeTriggers() in case $this->triggers is null. + * + * @return array + */ + public function getTriggers() + { + if ($this->triggers === null) { + $this->triggers = $this->initializeTriggers(); + } + + return $this->triggers; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the triggers lazily + * + * @return array + */ + protected function initializeTriggers() + { + return array(); + } + + /** + * Run a trigger for the given table and row which is about to be inserted + * + * @param string $table + * @param ConfigObject $new + * + * @return ConfigObject + */ + public function onInsert($table, ConfigObject $new) + { + $trigger = $this->getTrigger($table, 'onInsert'); + if ($trigger !== null) { + $row = $this->$trigger($new); + if ($row !== null) { + $new = $row; + } + } + + return $new; + } + + /** + * Run a trigger for the given table and row which is about to be updated + * + * @param string $table + * @param ConfigObject $old + * @param ConfigObject $new + * + * @return ConfigObject + */ + public function onUpdate($table, ConfigObject $old, ConfigObject $new) + { + $trigger = $this->getTrigger($table, 'onUpdate'); + if ($trigger !== null) { + $row = $this->$trigger($old, $new); + if ($row !== null) { + $new = $row; + } + } + + return $new; + } + + /** + * Run a trigger for the given table and row which has been deleted + * + * @param string $table + * @param ConfigObject $old + * + * @return ConfigObject + */ + public function onDelete($table, ConfigObject $old) + { + $trigger = $this->getTrigger($table, 'onDelete'); + if ($trigger !== null) { + $this->$trigger($old); + } + } + + /** + * Return the name of the trigger method for the given table and event-type + * + * @param string $table The table name for which to return a trigger method + * @param string $event The name of the event type + * + * @return string + */ + protected function getTrigger($table, $event) + { + if (! in_array($table, $this->getTriggers())) { + return; + } + + $identifier = join('', array_map('ucfirst', explode('_', $table))); + if (method_exists($this, $event . $identifier)) { + return $event . $identifier; + } + } + + /** + * Insert the given data for the given target + * + * $data must provide a proper value for the data source's key column. + * + * @param string $target + * @param array $data + * + * @throws StatementException In case the operation has failed + */ + public function insert($target, array $data) + { + $ds = $this->getDataSource($target); + $newData = $this->requireStatementColumns($target, $data); + + $config = $this->onInsert($target, new ConfigObject($newData)); + $section = $this->extractSectionName($config, $ds->getConfigObject()->getKeyColumn()); + + if ($ds->hasSection($section)) { + throw new StatementException(t('Cannot insert. Section "%s" does already exist'), $section); + } + + $ds->setSection($section, $config); + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to insert. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * 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 In case the operation has failed + */ + public function update($target, array $data, Filter $filter = null) + { + $ds = $this->getDataSource($target); + $newData = $this->requireStatementColumns($target, $data); + + $keyColumn = $ds->getConfigObject()->getKeyColumn(); + if ($filter === null && isset($newData[$keyColumn])) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + $query = $ds->select(); + if ($filter !== null) { + $query->addFilter($this->requireFilter($target, $filter)); + } + + /** @var ConfigObject $config */ + $newSection = null; + foreach ($query as $section => $config) { + if ($newSection !== null) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + $newConfig = clone $config; + foreach ($newData as $column => $value) { + if ($column === $keyColumn) { + if ($value !== $config->get($keyColumn)) { + $newSection = $value; + } + } else { + $newConfig->$column = $value; + } + } + + // This is necessary as the query result set contains the key column. + unset($newConfig->$keyColumn); + + if ($newSection) { + if ($ds->hasSection($newSection)) { + throw new StatementException(t('Cannot update. Section "%s" does already exist'), $newSection); + } + + $ds->removeSection($section)->setSection( + $newSection, + $this->onUpdate($target, $config, $newConfig) + ); + } else { + $ds->setSection( + $section, + $this->onUpdate($target, $config, $newConfig) + ); + } + } + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to update. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Delete entries in the given target, optionally limiting the affected entries by using a filter + * + * @param string $target + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function delete($target, Filter $filter = null) + { + $ds = $this->getDataSource($target); + + $query = $ds->select(); + if ($filter !== null) { + $query->addFilter($this->requireFilter($target, $filter)); + } + + /** @var ConfigObject $config */ + foreach ($query as $section => $config) { + $ds->removeSection($section); + $this->onDelete($target, $config); + } + + try { + $ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to delete. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Create and return a Config for the given meta and table + * + * @param array $meta + * @param string $table + * + * @return Config + * + * @throws ProgrammingError In case the given meta is invalid + */ + protected function createConfig(array $meta, $table) + { + if (! isset($meta['name'])) { + throw new ProgrammingError('Config file name missing for table "%s"', $table); + } elseif (! isset($meta['keyColumn'])) { + throw new ProgrammingError('Config key column name missing for table "%s"', $table); + } + + if (isset($meta['module'])) { + $config = Config::module($meta['module'], $meta['name']); + } else { + $config = Config::app($meta['name']); + } + + $config->getConfigObject()->setKeyColumn($meta['keyColumn']); + return $config; + } + + /** + * Extract and return the section name off of the given $config + * + * @param array|ConfigObject $config + * @param string $keyColumn + * + * @return string + * + * @throws ProgrammingError In case no valid section name is available + */ + protected function extractSectionName(&$config, $keyColumn) + { + if (! is_array($config) && !$config instanceof ConfigObject) { + throw new ProgrammingError('$config is neither an array nor a ConfigObject'); + } elseif (! isset($config[$keyColumn])) { + throw new ProgrammingError('$config does not provide a value for key column "%s"', $keyColumn); + } + + $section = $config[$keyColumn]; + unset($config[$keyColumn]); + return $section; + } +} diff --git a/library/Icinga/Repository/LdapRepository.php b/library/Icinga/Repository/LdapRepository.php new file mode 100644 index 0000000..e7a380b --- /dev/null +++ b/library/Icinga/Repository/LdapRepository.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Icinga\Protocol\Ldap\LdapConnection; + +/** + * Abstract base class for concrete LDAP repository implementations + * + * Additionally provided features: + * <ul> + * <li>Attribute name normalization</li> + * </ul> + */ +abstract class LdapRepository extends Repository +{ + /** + * The datasource being used + * + * @var LdapConnection + */ + protected $ds; + + /** + * Normed attribute names based on known LDAP environments + * + * @var array + */ + protected $normedAttributes = array( + 'uid' => 'uid', + 'gid' => 'gid', + 'user' => 'user', + 'group' => 'group', + 'member' => 'member', + 'memberuid' => 'memberUid', + 'posixgroup' => 'posixGroup', + 'uniquemember' => 'uniqueMember', + 'groupofnames' => 'groupOfNames', + 'inetorgperson' => 'inetOrgPerson', + 'samaccountname' => 'sAMAccountName', + 'groupofuniquenames' => 'groupOfUniqueNames' + ); + + /** + * Create a new LDAP repository object + * + * @param LdapConnection $ds The data source to use + */ + public function __construct(LdapConnection $ds) + { + parent::__construct($ds); + } + + /** + * Return the given attribute name normed to known LDAP enviroments, if possible + * + * @param string $name + * + * @return string + */ + protected function getNormedAttribute($name) + { + $loweredName = strtolower($name ?? ''); + if (array_key_exists($loweredName, $this->normedAttributes)) { + return $this->normedAttributes[$loweredName]; + } + + return $name; + } +} diff --git a/library/Icinga/Repository/Repository.php b/library/Icinga/Repository/Repository.php new file mode 100644 index 0000000..bda93aa --- /dev/null +++ b/library/Icinga/Repository/Repository.php @@ -0,0 +1,1261 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use DateTime; +use Icinga\Application\Logger; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Selectable; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; +use Icinga\Exception\StatementException; +use Icinga\Util\ASN1; +use Icinga\Util\StringHelper; +use InvalidArgumentException; + +/** + * Abstract base class for concrete repository implementations + * + * To utilize this class and its features, the following is required: + * <ul> + * <li>Concrete implementations need to initialize Repository::$queryColumns</li> + * <li>The datasource passed to a repository must implement the Selectable interface</li> + * <li>The datasource must yield an instance of Queryable when its select() method is called</li> + * </ul> + */ +abstract class Repository implements Selectable +{ + /** + * The format to use when converting values of type date_time + */ + const DATETIME_FORMAT = 'd/m/Y g:i A'; + + /** + * The name of this repository + * + * @var string + */ + protected $name; + + /** + * The datasource being used + * + * @var Selectable + */ + protected $ds; + + /** + * The base table name this repository is responsible for + * + * This will be automatically set to the first key of $queryColumns if not explicitly set. + * + * @var string + */ + protected $baseTable; + + /** + * The virtual tables being provided + * + * This may be initialized by concrete repository implementations with an array + * where a key is the name of a virtual table and its value the real table name. + * + * @var array + */ + protected $virtualTables; + + /** + * The query columns being provided + * + * This must be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'baseTable' => array( + * 'column1', + * 'alias1' => 'column2', + * 'alias2' => 'column3' + * ) + * ) + * </code> + * + * @var array + */ + protected $queryColumns; + + /** + * The columns (or aliases) which are not permitted to be queried + * + * Blacklisted query columns can still occur in a filter expression or sort rule. + * + * @var array An array of strings + */ + protected $blacklistedQueryColumns; + + /** + * Whether the blacklisted query columns are in the legacy format + * + * @var bool + */ + protected $legacyBlacklistedQueryColumns; + + /** + * The filter columns being provided + * + * This may be intialized by concrete repository implementations, in the following format + * <code> + * array( + * 'alias_or_column_name', + * 'label_to_show_in_the_filter_editor' => 'alias_or_column_name' + * ) + * </code> + * + * @var array + */ + protected $filterColumns; + + /** + * Whether the provided filter columns are in the legacy format + * + * @var bool + */ + protected $legacyFilterColumns; + + /** + * The search columns (or aliases) being provided + * + * @var array An array of strings + */ + protected $searchColumns; + + /** + * Whether the provided search columns are in the legacy format + * + * @var bool + */ + protected $legacySearchColumns; + + /** + * The sort rules to be applied on a query + * + * This may be initialized by concrete repository implementations, in the following format + * <code> + * array( + * 'alias_or_column_name' => array( + * 'order' => 'asc' + * ), + * 'alias_or_column_name' => array( + * 'columns' => array( + * 'once_more_the_alias_or_column_name_as_in_the_parent_key', + * 'an_additional_alias_or_column_name_with_a_specific_direction asc' + * ), + * 'order' => 'desc' + * ), + * 'alias_or_column_name' => array( + * 'columns' => array('a_different_alias_or_column_name_designated_to_act_as_the_only_sort_column') + * // Ascendant sort by default + * ) + * ) + * </code> + * Note that it's mandatory to supply the alias name in case there is one. + * + * @var array + */ + protected $sortRules; + + /** + * Whether the provided sort rules are in the legacy format + * + * @var bool + */ + protected $legacySortRules; + + /** + * The value conversion rules to apply on a query or statement + * + * This may be initialized by concrete repository implementations and describes for which aliases or column + * names what type of conversion is available. For entries, where the key is the alias/column and the value + * is the type identifier, the repository attempts to find a conversion method for the alias/column first and, + * if none is found, then for the type. If an entry only provides a value, which is the alias/column, the + * repository only attempts to find a conversion method for the alias/column. The name of a conversion method + * is expected to be declared using lowerCamelCase. (e.g. user_name will be translated to persistUserName and + * groupname will be translated to retrieveGroupname) + * + * @var array + */ + protected $conversionRules; + + /** + * An array to map table names to aliases + * + * @var array + */ + protected $aliasTableMap; + + /** + * A flattened array to map query columns to aliases + * + * @var array + */ + protected $aliasColumnMap; + + /** + * An array to map table names to query columns + * + * @var array + */ + protected $columnTableMap; + + /** + * A flattened array to map aliases to query columns + * + * @var array + */ + protected $columnAliasMap; + + /** + * Create a new repository object + * + * @param Selectable|null $ds The datasource to use. + * Only pass null if you have overridden {@link getDataSource()}! + */ + public function __construct(Selectable $ds = null) + { + $this->ds = $ds; + $this->aliasTableMap = array(); + $this->aliasColumnMap = array(); + $this->columnTableMap = array(); + $this->columnAliasMap = array(); + + $this->init(); + } + + /** + * Initialize this repository + * + * Supposed to be overwritten by concrete repository implementations. + */ + protected function init() + { + } + + /** + * Set this repository's name + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Return this repository's name + * + * In case no name has been explicitly set yet, the class name is returned. + * + * @return string + */ + public function getName() + { + return $this->name ?: __CLASS__; + } + + /** + * Return the datasource being used for the given table + * + * @param string $table + * + * @return Selectable + * + * @throws ProgrammingError In case no datasource is available + */ + public function getDataSource($table = null) + { + if ($this->ds === null) { + throw new ProgrammingError( + 'No data source available. It is required to either pass it' + . ' at initialization time or by overriding this method.' + ); + } + + return $this->ds; + } + + /** + * Return the base table name this repository is responsible for + * + * @return string + * + * @throws ProgrammingError In case no base table name has been set and + * $this->queryColumns does not provide one either + */ + public function getBaseTable() + { + if ($this->baseTable === null) { + $queryColumns = $this->getQueryColumns(); + reset($queryColumns); + $this->baseTable = key($queryColumns); + if (is_int($this->baseTable) || !is_array($queryColumns[$this->baseTable])) { + throw new ProgrammingError('"%s" is not a valid base table', $this->baseTable); + } + } + + return $this->baseTable; + } + + /** + * Return the virtual tables being provided + * + * Calls $this->initializeVirtualTables() in case $this->virtualTables is null. + * + * @return array + */ + public function getVirtualTables() + { + if ($this->virtualTables === null) { + $this->virtualTables = $this->initializeVirtualTables(); + } + + return $this->virtualTables; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the virtual tables lazily + * + * @return array + */ + protected function initializeVirtualTables() + { + return array(); + } + + /** + * Return the query columns being provided + * + * Calls $this->initializeQueryColumns() in case $this->queryColumns is null. + * + * @return array + */ + public function getQueryColumns() + { + if ($this->queryColumns === null) { + $this->queryColumns = $this->initializeQueryColumns(); + } + + return $this->queryColumns; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the query columns lazily + * + * @return array + */ + protected function initializeQueryColumns() + { + return array(); + } + + /** + * Return the columns (or aliases) which are not permitted to be queried + * + * Calls $this->initializeBlacklistedQueryColumns() in case $this->blacklistedQueryColumns is null. + * + * @param string $table + * + * @return array + */ + public function getBlacklistedQueryColumns($table = null) + { + if ($this->blacklistedQueryColumns === null) { + $this->legacyBlacklistedQueryColumns = false; + + $blacklistedQueryColumns = $this->initializeBlacklistedQueryColumns($table); + if (is_int(key($blacklistedQueryColumns))) { + $this->blacklistedQueryColumns[$table] = $blacklistedQueryColumns; + } else { + $this->blacklistedQueryColumns = $blacklistedQueryColumns; + } + } elseif ($this->legacyBlacklistedQueryColumns === null) { + $this->legacyBlacklistedQueryColumns = is_int(key($this->blacklistedQueryColumns)); + } + + if ($this->legacyBlacklistedQueryColumns) { + return $this->blacklistedQueryColumns; + } elseif (! isset($this->blacklistedQueryColumns[$table])) { + $this->blacklistedQueryColumns[$table] = $this->initializeBlacklistedQueryColumns($table); + } + + return $this->blacklistedQueryColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the + * blacklisted query columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeBlacklistedQueryColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the filter columns being provided + * + * Calls $this->initializeFilterColumns() in case $this->filterColumns is null. + * + * @param string $table + * + * @return array + */ + public function getFilterColumns($table = null) + { + if ($this->filterColumns === null) { + $this->legacyFilterColumns = false; + + $filterColumns = $this->initializeFilterColumns($table); + $foundTables = array_intersect_key($this->getQueryColumns(), $filterColumns); + if (empty($foundTables)) { + $this->filterColumns[$table] = $filterColumns; + } else { + $this->filterColumns = $filterColumns; + } + } elseif ($this->legacyFilterColumns === null) { + $foundTables = array_intersect_key($this->getQueryColumns(), $this->filterColumns); + $this->legacyFilterColumns = empty($foundTables); + } + + if ($this->legacyFilterColumns) { + return $this->filterColumns; + } elseif (! isset($this->filterColumns[$table])) { + $this->filterColumns[$table] = $this->initializeFilterColumns($table); + } + + return $this->filterColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the filter columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeFilterColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the search columns being provided + * + * Calls $this->initializeSearchColumns() in case $this->searchColumns is null. + * + * @param string $table + * + * @return array + */ + public function getSearchColumns($table = null) + { + if ($this->searchColumns === null) { + $this->legacySearchColumns = false; + + $searchColumns = $this->initializeSearchColumns($table); + if (is_int(key($searchColumns))) { + $this->searchColumns[$table] = $searchColumns; + } else { + $this->searchColumns = $searchColumns; + } + } elseif ($this->legacySearchColumns === null) { + $this->legacySearchColumns = is_int(key($this->searchColumns)); + } + + if ($this->legacySearchColumns) { + return $this->searchColumns; + } elseif (! isset($this->searchColumns[$table])) { + $this->searchColumns[$table] = $this->initializeSearchColumns($table); + } + + return $this->searchColumns[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the search columns lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeSearchColumns() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the sort rules to be applied on a query + * + * Calls $this->initializeSortRules() in case $this->sortRules is null. + * + * @param string $table + * + * @return array + */ + public function getSortRules($table = null) + { + if ($this->sortRules === null) { + $this->legacySortRules = false; + + $sortRules = $this->initializeSortRules($table); + $foundTables = array_intersect_key($this->getQueryColumns(), $sortRules); + if (empty($foundTables)) { + $this->sortRules[$table] = $sortRules; + } else { + $this->sortRules = $sortRules; + } + } elseif ($this->legacySortRules === null) { + $foundTables = array_intersect_key($this->getQueryColumns(), $this->sortRules); + $this->legacySortRules = empty($foundTables); + } + + if ($this->legacySortRules) { + return $this->sortRules; + } elseif (! isset($this->sortRules[$table])) { + $this->sortRules[$table] = $this->initializeSortRules($table); + } + + return $this->sortRules[$table]; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize + * the sort rules lazily or dependent on a query's current base table + * + * @param string $table + * + * @return array + */ + protected function initializeSortRules() + { + // $table is not part of the signature due to PHP strict standards + return array(); + } + + /** + * Return the value conversion rules to apply on a query + * + * Calls $this->initializeConversionRules() in case $this->conversionRules is null. + * + * @return array + */ + public function getConversionRules() + { + if ($this->conversionRules === null) { + $this->conversionRules = $this->initializeConversionRules(); + } + + return $this->conversionRules; + } + + /** + * Overwrite this in your repository implementation in case you need to initialize the conversion rules lazily + * + * @return array + */ + protected function initializeConversionRules() + { + return array(); + } + + /** + * Return an array to map table names to aliases + * + * @return array + */ + protected function getAliasTableMap() + { + if (empty($this->aliasTableMap)) { + $this->initializeAliasMaps(); + } + + return $this->aliasTableMap; + } + + /** + * Return a flattened array to map query columns to aliases + * + * @return array + */ + protected function getAliasColumnMap() + { + if (empty($this->aliasColumnMap)) { + $this->initializeAliasMaps(); + } + + return $this->aliasColumnMap; + } + + /** + * Return an array to map table names to query columns + * + * @return array + */ + protected function getColumnTableMap() + { + if (empty($this->columnTableMap)) { + $this->initializeAliasMaps(); + } + + return $this->columnTableMap; + } + + /** + * Return a flattened array to map aliases to query columns + * + * @return array + */ + protected function getColumnAliasMap() + { + if (empty($this->columnAliasMap)) { + $this->initializeAliasMaps(); + } + + return $this->columnAliasMap; + } + + /** + * Initialize $this->aliasTableMap and $this->aliasColumnMap + * + * @throws ProgrammingError In case $this->queryColumns does not provide any column information + */ + protected function initializeAliasMaps() + { + $queryColumns = $this->getQueryColumns(); + if (empty($queryColumns)) { + throw new ProgrammingError('Repositories are required to initialize $this->queryColumns first'); + } + + foreach ($queryColumns as $table => $columns) { + foreach ($columns as $alias => $column) { + if (! is_string($alias)) { + $key = $column; + } else { + $key = $alias; + $column = preg_replace('~\n\s*~', ' ', $column); + } + + if (array_key_exists($key, $this->aliasTableMap)) { + if ($this->aliasTableMap[$key] !== null) { + $existingTable = $this->aliasTableMap[$key]; + $existingColumn = $this->aliasColumnMap[$key]; + $this->aliasTableMap[$existingTable . '.' . $key] = $existingTable; + $this->aliasColumnMap[$existingTable . '.' . $key] = $existingColumn; + $this->aliasTableMap[$key] = null; + $this->aliasColumnMap[$key] = null; + } + + $this->aliasTableMap[$table . '.' . $key] = $table; + $this->aliasColumnMap[$table . '.' . $key] = $column; + } else { + $this->aliasTableMap[$key] = $table; + $this->aliasColumnMap[$key] = $column; + } + + if (array_key_exists($column, $this->columnTableMap)) { + if ($this->columnTableMap[$column] !== null) { + $existingTable = $this->columnTableMap[$column]; + $existingAlias = $this->columnAliasMap[$column]; + $this->columnTableMap[$existingTable . '.' . $column] = $existingTable; + $this->columnAliasMap[$existingTable . '.' . $column] = $existingAlias; + $this->columnTableMap[$column] = null; + $this->columnAliasMap[$column] = null; + } + + $this->columnTableMap[$table . '.' . $column] = $table; + $this->columnAliasMap[$table . '.' . $column] = $key; + } else { + $this->columnTableMap[$column] = $table; + $this->columnAliasMap[$column] = $key; + } + } + } + } + + /** + * Return a new query for the given columns + * + * @param array $columns The desired columns, if null all columns will be queried + * + * @return RepositoryQuery + */ + public function select(array $columns = null) + { + $query = new RepositoryQuery($this); + $query->from($this->getBaseTable(), $columns); + return $query; + } + + /** + * Return whether this repository is capable of converting values for the given table and optional column + * + * @param string $table + * @param string $column + * + * @return bool + */ + public function providesValueConversion($table, $column = null) + { + $conversionRules = $this->getConversionRules(); + if (empty($conversionRules)) { + return false; + } + + if (! isset($conversionRules[$table])) { + return false; + } elseif ($column === null) { + return true; + } + + $alias = $this->reassembleQueryColumnAlias($table, $column) ?: $column; + return array_key_exists($alias, $conversionRules[$table]) || in_array($alias, $conversionRules[$table]); + } + + /** + * Convert a value supposed to be transmitted to the data source + * + * @param string $table The table where to persist the value + * @param string $name The alias or column name + * @param mixed $value The value to convert + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->getConverter) + * + * @return mixed If conversion was possible, the converted value, + * otherwise the unchanged value + */ + public function persistColumn($table, $name, $value, RepositoryQuery $query = null) + { + $converter = $this->getConverter($table, $name, 'persist', $query); + if ($converter !== null) { + $value = $this->$converter($value, $name, $table, $query); + } + + return $value; + } + + /** + * Convert a value which was fetched from the data source + * + * @param string $table The table the value has been fetched from + * @param string $name The alias or column name + * @param mixed $value The value to convert + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->getConverter) + * + * @return mixed If conversion was possible, the converted value, + * otherwise the unchanged value + */ + public function retrieveColumn($table, $name, $value, RepositoryQuery $query = null) + { + $converter = $this->getConverter($table, $name, 'retrieve', $query); + if ($converter !== null) { + $value = $this->$converter($value, $name, $table, $query); + } + + return $value; + } + + /** + * Return the name of the conversion method for the given alias or column name and context + * + * @param string $table The datasource's table + * @param string $name The alias or column name for which to return a conversion method + * @param string $context The context of the conversion: persist or retrieve + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return string + * + * @throws ProgrammingError In case a conversion rule is found but not any conversion method + */ + protected function getConverter($table, $name, $context, RepositoryQuery $query = null) + { + $conversionRules = $this->getConversionRules(); + if (! isset($conversionRules[$table])) { + return; + } + + $tableRules = $conversionRules[$table]; + if (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) { + $alias = $name; + } + + // Check for a conversion method for the alias/column first + if (array_key_exists($alias, $tableRules) || in_array($alias, $tableRules)) { + $methodName = $context . join('', array_map('ucfirst', explode('_', $alias))); + if (method_exists($this, $methodName)) { + return $methodName; + } + } + + // The conversion method for the type is just a fallback, but it is required to exist if defined + if (isset($tableRules[$alias])) { + $identifier = join('', array_map('ucfirst', explode('_', $tableRules[$alias]))); + if (! method_exists($this, $context . $identifier)) { + // Do not throw an error in case at least one conversion method exists + if (! method_exists($this, ($context === 'persist' ? 'retrieve' : 'persist') . $identifier)) { + throw new ProgrammingError( + 'Cannot find any conversion method for type "%s"' + . '. Add a proper conversion method or remove the type definition', + $tableRules[$alias] + ); + } + + Logger::debug( + 'Conversion method "%s" for type definition "%s" does not exist in repository "%s".', + $context . $identifier, + $tableRules[$alias], + $this->getName() + ); + } else { + return $context . $identifier; + } + } + } + + /** + * Convert a timestamp or DateTime object to a string formatted using static::DATETIME_FORMAT + * + * @param mixed $value + * + * @return string + */ + protected function persistDateTime($value) + { + if (is_numeric($value)) { + $value = date(static::DATETIME_FORMAT, $value); + } elseif ($value instanceof DateTime) { + $value = date(static::DATETIME_FORMAT, $value->getTimestamp()); // Using date here, to ignore any timezone + } elseif ($value !== null) { + throw new ProgrammingError( + 'Cannot persist value "%s" as type date_time. It\'s not a timestamp or DateTime object', + $value + ); + } + + return $value; + } + + /** + * Convert a string formatted using static::DATETIME_FORMAT to a unix timestamp + * + * @param string $value + * + * @return int + */ + protected function retrieveDateTime($value) + { + if (is_numeric($value)) { + $value = (int) $value; + } elseif (is_string($value)) { + $dateTime = DateTime::createFromFormat(static::DATETIME_FORMAT, $value); + if ($dateTime === false) { + Logger::debug( + 'Unable to parse string "%s" as type date_time with format "%s" in repository "%s"', + $value, + static::DATETIME_FORMAT, + $this->getName() + ); + $value = null; + } else { + $value = $dateTime->getTimestamp(); + } + } elseif ($value !== null) { + throw new ProgrammingError( + 'Cannot retrieve value "%s" as type date_time. It\'s not a integer or (numeric) string', + $value + ); + } + + return $value; + } + + /** + * Convert the given array to an comma separated string + * + * @param array|string $value + * + * @return string + */ + protected function persistCommaSeparatedString($value) + { + if (is_array($value)) { + $value = join(',', array_map('trim', $value)); + } elseif ($value !== null && !is_string($value)) { + throw new ProgrammingError('Cannot persist value "%s" as comma separated string', $value); + } + + return $value; + } + + /** + * Convert the given comma separated string to an array + * + * @param string $value + * + * @return array + */ + protected function retrieveCommaSeparatedString($value) + { + if ($value && is_string($value)) { + $value = StringHelper::trimSplit($value); + } elseif ($value !== null) { + throw new ProgrammingError('Cannot retrieve value "%s" as array. It\'s not a string', $value); + } + + return $value; + } + + /** + * Parse the given value based on the ASN.1 standard (GeneralizedTime) and return its timestamp representation + * + * @param string|null $value + * + * @return int + * + * @see https://tools.ietf.org/html/rfc4517#section-3.3.13 + */ + protected function retrieveGeneralizedTime($value) + { + if ($value === null) { + return $value; + } + + try { + return ASN1::parseGeneralizedTime($value)->getTimeStamp(); + } catch (InvalidArgumentException $e) { + Logger::debug(sprintf('Repository "%s": %s', $this->getName(), $e->getMessage())); + } + } + + /** + * Validate that the requested table exists and resolve it's real name if necessary + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * (unused by the base implementation) + * + * @return string The table's name, may differ from the given one + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + $queryColumns = $this->getQueryColumns(); + if (! isset($queryColumns[$table])) { + throw new ProgrammingError('Table "%s" not found', $table); + } + + $virtualTables = $this->getVirtualTables(); + if (isset($virtualTables[$table])) { + $table = $virtualTables[$table]; + } + + return $table; + } + + /** + * Recurse the given filter, require each column for the given table and convert all values + * + * @param string $table The table being filtered + * @param Filter $filter The filter to recurse + * @param RepositoryQuery $query An optional query to pass as context + * (Directly passed through to $this->requireFilterColumn) + * @param bool $clone Whether to clone $filter first + * + * @return Filter The udpated filter + */ + public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true) + { + if ($clone) { + $filter = clone $filter; + } + + if ($filter->isExpression()) { + $column = $filter->getColumn(); + $filter->setColumn($this->requireFilterColumn($table, $column, $query, $filter)); + $filter->setExpression($this->persistColumn($table, $column, $filter->getExpression(), $query)); + } elseif ($filter->isChain()) { + foreach ($filter->filters() as $chainOrExpression) { + $this->requireFilter($table, $chainOrExpression, $query, false); + } + } + + return $filter; + } + + /** + * Return this repository's query columns of the given table mapped to their respective aliases + * + * @param string $table + * + * @return array + * + * @throws ProgrammingError In case $table does not exist + */ + public function requireAllQueryColumns($table) + { + $queryColumns = $this->getQueryColumns(); + if (! array_key_exists($table, $queryColumns)) { + throw new ProgrammingError('Table name "%s" not found', $table); + } + + $blacklist = $this->getBlacklistedQueryColumns($table); + $columns = array(); + foreach ($queryColumns[$table] as $alias => $column) { + $name = is_string($alias) ? $alias : $column; + if (! in_array($name, $blacklist)) { + $columns[$alias] = $this->resolveQueryColumnAlias($table, $name); + } + } + + return $columns; + } + + /** + * Return the query column name for the given alias or null in case the alias does not exist + * + * @param string $table + * @param string $alias + * + * @return string|null + */ + public function resolveQueryColumnAlias($table, $alias) + { + $aliasColumnMap = $this->getAliasColumnMap(); + if (isset($aliasColumnMap[$alias])) { + return $aliasColumnMap[$alias]; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($aliasColumnMap[$prefixedAlias])) { + return $aliasColumnMap[$prefixedAlias]; + } + } + + /** + * Return the alias for the given query column name or null in case the query column name does not exist + * + * @param string $table + * @param string $column + * + * @return string|null + */ + public function reassembleQueryColumnAlias($table, $column) + { + $columnAliasMap = $this->getColumnAliasMap(); + if (isset($columnAliasMap[$column])) { + return $columnAliasMap[$column]; + } + + $prefixedColumn = $table . '.' . $column; + if (isset($columnAliasMap[$prefixedColumn])) { + return $columnAliasMap[$prefixedColumn]; + } + } + + /** + * Return whether the given alias or query column name is available in the given table + * + * @param string $table + * @param string $alias + * + * @return bool + */ + public function validateQueryColumnAssociation($table, $alias) + { + $aliasTableMap = $this->getAliasTableMap(); + if (isset($aliasTableMap[$alias])) { + return $aliasTableMap[$alias] === $table; + } + + $prefixedAlias = $table . '.' . $alias; + if (isset($aliasTableMap[$prefixedAlias])) { + return true; + } + + $columnTableMap = $this->getColumnTableMap(); + if (isset($columnTableMap[$alias])) { + return $columnTableMap[$alias] === $table; + } + + return isset($columnTableMap[$prefixedAlias]); + } + + /** + * Return whether the given column name or alias is a valid query column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasQueryColumn($table, $name) + { + if ($this->resolveQueryColumnAlias($table, $name) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) { + return false; + } + + return !in_array($alias, $this->getBlacklistedQueryColumns($table)) + && $this->validateQueryColumnAssociation($table, $name); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation) + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new QueryException(t('Query column "%s" not found'), $name); + } + + if (in_array($alias, $this->getBlacklistedQueryColumns($table))) { + throw new QueryException(t('Column "%s" cannot be queried'), $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new QueryException(t('Query column "%s" not found in table "%s"'), $name, $table); + } + + return $column; + } + + /** + * Return whether the given column name or alias is a valid filter column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasFilterColumn($table, $name) + { + return ($this->resolveQueryColumnAlias($table, $name) !== null + || $this->reassembleQueryColumnAlias($table, $name) !== null) + && $this->validateQueryColumnAssociation($table, $name); + } + + /** + * Validate that the given column is a valid filter target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context (unused by the base implementation) + * @param FilterExpression $filter An optional filter to pass as context (unused by the base implementation) + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid filter column + */ + public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new QueryException(t('Filter column "%s" not found'), $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new QueryException(t('Filter column "%s" not found in table "%s"'), $name, $table); + } + + return $column; + } + + /** + * Return whether the given column name or alias of the given table is a valid statement column + * + * @param string $table The table where to look for the column or alias + * @param string $name The column name or alias to check + * + * @return bool + */ + public function hasStatementColumn($table, $name) + { + return $this->hasQueryColumn($table, $name); + } + + /** + * Validate that the given column is a valid statement column and return it or the actual name if it's an alias + * + * @param string $table The table for which to require the column + * @param string $name The name or alias of the column to validate + * + * @return string The given column's name + * + * @throws StatementException In case the given column is not a statement column + */ + public function requireStatementColumn($table, $name) + { + if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) { + $alias = $name; + } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) { + $column = $name; + } else { + throw new StatementException('Statement column "%s" not found', $name); + } + + if (in_array($alias, $this->getBlacklistedQueryColumns($table))) { + throw new StatementException('Column "%s" cannot be referenced in a statement', $name); + } + + if (! $this->validateQueryColumnAssociation($table, $alias)) { + throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table); + } + + return $column; + } + + /** + * Resolve the given aliases or column names of the given table supposed to be persisted and convert their values + * + * @param string $table + * @param array $data + * + * @return array + */ + public function requireStatementColumns($table, array $data) + { + $resolved = array(); + foreach ($data as $alias => $value) { + $resolved[$this->requireStatementColumn($table, $alias)] = $this->persistColumn($table, $alias, $value); + } + + return $resolved; + } +} diff --git a/library/Icinga/Repository/RepositoryQuery.php b/library/Icinga/Repository/RepositoryQuery.php new file mode 100644 index 0000000..84f7c6e --- /dev/null +++ b/library/Icinga/Repository/RepositoryQuery.php @@ -0,0 +1,797 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Repository; + +use Iterator; +use IteratorAggregate; +use Traversable; +use Icinga\Application\Benchmark; +use Icinga\Application\Logger; +use Icinga\Data\QueryInterface; +use Icinga\Data\Filter\Filter; +use Icinga\Data\FilterColumns; +use Icinga\Data\SortRules; +use Icinga\Exception\QueryException; + +/** + * Query class supposed to mediate between a repository and its datasource's query + */ +class RepositoryQuery implements QueryInterface, SortRules, FilterColumns, Iterator +{ + /** + * The repository being used + * + * @var Repository + */ + protected $repository; + + /** + * The real query being used + * + * @var QueryInterface + */ + protected $query; + + /** + * The current target to be queried + * + * @var mixed + */ + protected $target; + + /** + * The real query's iterator + * + * @var Iterator + */ + protected $iterator; + + /** + * This query's custom aliases + * + * @var array + */ + protected $customAliases; + + /** + * Create a new repository query + * + * @param Repository $repository The repository to use + */ + public function __construct(Repository $repository) + { + $this->repository = $repository; + } + + /** + * Clone all state relevant properties of this query + */ + public function __clone() + { + if ($this->query !== null) { + $this->query = clone $this->query; + } + if ($this->iterator !== null) { + $this->iterator = clone $this->iterator; + } + } + + /** + * Return a string representation of this query + * + * @return string + */ + public function __toString() + { + return (string) $this->query; + } + + /** + * Return the real query being used + * + * @return QueryInterface + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set where to fetch which columns + * + * This notifies the repository about each desired query column. + * + * @param mixed $target The target from which to fetch the columns + * @param array $columns If null or an empty array, all columns will be fetched + * + * @return $this + */ + public function from($target, array $columns = null) + { + $this->query = $this->repository->getDataSource($target)->select(); + $this->query->from($this->repository->requireTable($target, $this)); + $this->query->columns($this->prepareQueryColumns($target, $columns)); + $this->target = $target; + return $this; + } + + /** + * Return the columns to fetch + * + * @return array + */ + public function getColumns() + { + return $this->query->getColumns(); + } + + /** + * Set which columns to fetch + * + * This notifies the repository about each desired query column. + * + * @param array $columns If null or an empty array, all columns will be fetched + * + * @return $this + */ + public function columns(array $columns) + { + $this->query->columns($this->prepareQueryColumns($this->target, $columns)); + return $this; + } + + /** + * Resolve the given columns supposed to be fetched + * + * This notifies the repository about each desired query column. + * + * @param mixed $target The target where to look for each column + * @param array $desiredColumns Pass null or an empty array to require all query columns + * + * @return array The desired columns indexed by their respective alias + */ + protected function prepareQueryColumns($target, array $desiredColumns = null) + { + $this->customAliases = array(); + if (empty($desiredColumns)) { + $columns = $this->repository->requireAllQueryColumns($target); + } else { + $columns = array(); + foreach ($desiredColumns as $customAlias => $columnAlias) { + $resolvedColumn = $this->repository->requireQueryColumn($target, $columnAlias, $this); + if ($resolvedColumn !== $columnAlias) { + if (is_string($customAlias)) { + $columns[$customAlias] = $resolvedColumn; + $this->customAliases[$customAlias] = $columnAlias; + } else { + $columns[$columnAlias] = $resolvedColumn; + } + } elseif (is_string($customAlias)) { + $columns[$customAlias] = $columnAlias; + $this->customAliases[$customAlias] = $columnAlias; + } else { + $columns[] = $columnAlias; + } + } + } + + return $columns; + } + + /** + * Return the native column alias for the given custom alias + * + * If no custom alias is found with the given name, it is returned unchanged. + * + * @param string $customAlias + * + * @return string + */ + protected function getNativeAlias($customAlias) + { + if (isset($this->customAliases[$customAlias])) { + return $this->customAliases[$customAlias]; + } + + return $customAlias; + } + + /** + * Return this query's available filter columns with their optional label as key + * + * @return array + */ + public function getFilterColumns() + { + return $this->repository->getFilterColumns($this->target); + } + + /** + * Return this query's available search columns + * + * @return array + */ + public function getSearchColumns() + { + return $this->repository->getSearchColumns($this->target); + } + + /** + * Filter this query using the given column and value + * + * This notifies the repository about the required filter column. + * + * @param string $column + * @param mixed $value + * + * @return $this + */ + public function where($column, $value = null) + { + $this->addFilter(Filter::where($column, $value)); + return $this; + } + + /** + * Add an additional filter expression to this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function applyFilter(Filter $filter) + { + return $this->addFilter($filter); + } + + /** + * Set a filter for this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function setFilter(Filter $filter) + { + $this->query->setFilter($this->repository->requireFilter($this->target, $filter, $this)); + return $this; + } + + /** + * Add an additional filter expression to this query + * + * This notifies the repository about each required filter column. + * + * @param Filter $filter + * + * @return $this + */ + public function addFilter(Filter $filter) + { + $this->query->addFilter($this->repository->requireFilter($this->target, $filter, $this)); + return $this; + } + + /** + * Return the current filter + * + * @return Filter + */ + public function getFilter() + { + return $this->query->getFilter(); + } + + /** + * Return the sort rules being applied on this query + * + * @return array + */ + public function getSortRules() + { + return $this->repository->getSortRules($this->target); + } + + /** + * Add a sort rule for this query + * + * If called without a specific column, the repository's defaul sort rules will be applied. + * This notifies the repository about each column being required as filter column. + * + * @param string $field The name of the column by which to sort the query's result + * @param string $direction The direction to use when sorting (asc or desc, default is asc) + * @param bool $ignoreDefault Whether to ignore any default sort rules if $field is given + * + * @return $this + */ + public function order($field = null, $direction = null, $ignoreDefault = false) + { + $sortRules = $this->getSortRules(); + if ($field === null) { + // Use first available sort rule as default + if (empty($sortRules)) { + // Return early in case of no sort defaults and no given $field + return $this; + } + + $sortColumns = reset($sortRules); + if (! array_key_exists('columns', $sortColumns)) { + $sortColumns['columns'] = array(key($sortRules)); + } + if ($direction !== null || !array_key_exists('order', $sortColumns)) { + $sortColumns['order'] = $direction ?: static::SORT_ASC; + } + } else { + $alias = $this->repository->reassembleQueryColumnAlias($this->target, $field) ?: $field; + if (! $ignoreDefault && array_key_exists($alias, $sortRules)) { + $sortColumns = $sortRules[$alias]; + if (! array_key_exists('columns', $sortColumns)) { + $sortColumns['columns'] = array($alias); + } + if ($direction !== null || !array_key_exists('order', $sortColumns)) { + $sortColumns['order'] = $direction ?: static::SORT_ASC; + } + } else { + $sortColumns = array( + 'columns' => array($alias), + 'order' => $direction + ); + } + } + + $baseDirection = isset($sortColumns['order']) && strtoupper($sortColumns['order']) === static::SORT_DESC + ? static::SORT_DESC + : static::SORT_ASC; + + foreach ($sortColumns['columns'] as $column) { + list($column, $specificDirection) = $this->splitOrder($column); + + if ($this->hasLimit() && $this->repository->providesValueConversion($this->target, $column)) { + Logger::debug( + 'Cannot order by column "%s" in repository "%s". The query is' + . ' limited and applies value conversion rules on the column', + $column, + $this->repository->getName() + ); + continue; + } + + try { + $this->query->order( + $this->repository->requireFilterColumn($this->target, $column, $this), + $specificDirection ?: $baseDirection + // I would have liked the following solution, but hey, a coder should be allowed to produce crap... + // $specificDirection && (! $direction || $column !== $field) ? $specificDirection : $baseDirection + ); + } catch (QueryException $_) { + Logger::info('Cannot order by column "%s" in repository "%s"', $column, $this->repository->getName()); + } + } + + return $this; + } + + /** + * Extract and return the name and direction of the given sort column definition + * + * @param string $field + * + * @return array An array of two items: $columnName, $direction + */ + protected function splitOrder($field) + { + $columnAndDirection = explode(' ', $field, 2); + if (count($columnAndDirection) === 1) { + $column = $field; + $direction = null; + } else { + $column = $columnAndDirection[0]; + $direction = strtoupper($columnAndDirection[1]) === static::SORT_DESC + ? static::SORT_DESC + : static::SORT_ASC; + } + + return array($column, $direction); + } + + /** + * Return whether any sort rules were applied to this query + * + * @return bool + */ + public function hasOrder() + { + return $this->query->hasOrder(); + } + + /** + * Return the sort rules applied to this query + * + * @return array + */ + public function getOrder() + { + return $this->query->getOrder(); + } + + /** + * 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. + * + * @param bool $state + * + * @return $this + */ + public function peekAhead($state = true) + { + $this->query->peekAhead($state); + return $this; + } + + /** + * Return whether this query did not yield all available results + * + * @return bool + */ + public function hasMore() + { + return $this->query->hasMore(); + } + + /** + * Return whether this query will or has yielded any result + * + * @return bool + */ + public function hasResult() + { + return $this->query->hasResult(); + } + + /** + * Limit this query's results + * + * @param int $count When to stop returning results + * @param int $offset When to start returning results + * + * @return $this + */ + public function limit($count = null, $offset = null) + { + $this->query->limit($count, $offset); + return $this; + } + + /** + * Return whether this query does not return all available entries from its result + * + * @return bool + */ + public function hasLimit() + { + return $this->query->hasLimit(); + } + + /** + * Return the limit when to stop returning results + * + * @return int + */ + public function getLimit() + { + return $this->query->getLimit(); + } + + /** + * Return whether this query does not start returning results at the very first entry + * + * @return bool + */ + public function hasOffset() + { + return $this->query->hasOffset(); + } + + /** + * Return the offset when to start returning results + * + * @return int + */ + public function getOffset() + { + return $this->query->getOffset(); + } + + /** + * Fetch and return the first column of this query's first row + * + * @return mixed|false False in case of no result + */ + public function fetchOne() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $result = $this->query->fetchOne(); + if ($result !== false && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $column = isset($columns[0]) ? $columns[0] : $this->getNativeAlias(key($columns)); + return $this->repository->retrieveColumn($this->target, $column, $result, $this); + } + + return $result; + } + + /** + * Fetch and return the first row of this query's result + * + * @return object|false False in case of no result + */ + public function fetchRow() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $result = $this->query->fetchRow(); + if ($result !== false && $this->repository->providesValueConversion($this->target)) { + foreach ($this->getColumns() as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $result->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $result->$alias, + $this + ); + } + } + + return $result; + } + + /** + * Fetch and return the first column of all rows of the result set as an array + * + * @return array + */ + public function fetchColumn() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchColumn(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $aliases = array_keys($columns); + $column = is_int($aliases[0]) ? $columns[0] : $this->getNativeAlias($aliases[0]); + if ($this->repository->providesValueConversion($this->target, $column)) { + foreach ($results as & $value) { + $value = $this->repository->retrieveColumn($this->target, $column, $value, $this); + } + } + } + + return $results; + } + + /** + * Fetch and return all rows of this query's 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() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchPairs(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $columns = $this->getColumns(); + $aliases = array_keys($columns); + $colOne = $aliases[0] !== 0 ? $this->getNativeAlias($aliases[0]) : $columns[0]; + $colTwo = count($aliases) < 2 ? $colOne : ( + $aliases[1] !== 1 ? $this->getNativeAlias($aliases[1]) : $columns[1] + ); + + if ($this->repository->providesValueConversion($this->target, $colOne) + || $this->repository->providesValueConversion($this->target, $colTwo) + ) { + $newResults = array(); + foreach ($results as $colOneValue => $colTwoValue) { + $colOneValue = $this->repository->retrieveColumn($this->target, $colOne, $colOneValue, $this); + $newResults[$colOneValue] = $this->repository->retrieveColumn( + $this->target, + $colTwo, + $colTwoValue, + $this + ); + } + + $results = $newResults; + } + } + + return $results; + } + + /** + * Fetch and return all results of this query + * + * @return array + */ + public function fetchAll() + { + if (! $this->hasOrder()) { + $this->order(); + } + + $results = $this->query->fetchAll(); + if (! empty($results) && $this->repository->providesValueConversion($this->target)) { + $updateOrder = false; + $columns = $this->getColumns(); + $flippedColumns = array_flip($columns); + foreach ($results as $row) { + foreach ($columns as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $row->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $row->$alias, + $this + ); + } + + foreach (($this->getOrder() ?: array()) as $rule) { + $nativeAlias = $this->getNativeAlias($rule[0]); + if (! array_key_exists($rule[0], $flippedColumns) && property_exists($row, $rule[0])) { + if ($this->repository->providesValueConversion($this->target, $nativeAlias)) { + $updateOrder = true; + $row->{$rule[0]} = $this->repository->retrieveColumn( + $this->target, + $nativeAlias, + $row->{$rule[0]}, + $this + ); + } + } elseif (array_key_exists($rule[0], $flippedColumns)) { + if ($this->repository->providesValueConversion($this->target, $nativeAlias)) { + $updateOrder = true; + } + } + } + } + + if ($updateOrder) { + uasort($results, array($this->query, 'compare')); + } + } + + return $results; + } + + /** + * Count all results of this query + * + * @return int + */ + public function count(): int + { + return $this->query->count(); + } + + /** + * Return the current position of this query's iterator + * + * @return int + */ + public function getIteratorPosition() + { + return $this->query->getIteratorPosition(); + } + + /** + * Start or rewind the iteration + */ + public function rewind(): void + { + if ($this->iterator === null) { + if (! $this->hasOrder()) { + $this->order(); + } + + if ($this->query instanceof Traversable) { + $iterator = $this->query; + } else { + $iterator = $this->repository->getDataSource($this->target)->query($this->query); + } + + if ($iterator instanceof IteratorAggregate) { + $this->iterator = $iterator->getIterator(); + } else { + $this->iterator = $iterator; + } + } + + $this->iterator->rewind(); + Benchmark::measure('Query result iteration started'); + } + + /** + * Fetch and return the current row of this query's result + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + $row = $this->iterator->current(); + if ($this->repository->providesValueConversion($this->target)) { + foreach ($this->getColumns() as $alias => $column) { + if (! is_string($alias)) { + $alias = $column; + } + + $row->$alias = $this->repository->retrieveColumn( + $this->target, + $this->getNativeAlias($alias), + $row->$alias, + $this + ); + } + } + + return $row; + } + + /** + * Return whether the current row of this query's result is valid + * + * @return bool + */ + public function valid(): bool + { + if (! $this->iterator->valid()) { + Benchmark::measure('Query result iteration finished'); + return false; + } + + 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(); + } +} |