summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Repository
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icinga/Repository')
-rw-r--r--library/Icinga/Repository/DbRepository.php1077
-rw-r--r--library/Icinga/Repository/IniRepository.php420
-rw-r--r--library/Icinga/Repository/LdapRepository.php71
-rw-r--r--library/Icinga/Repository/Repository.php1261
-rw-r--r--library/Icinga/Repository/RepositoryQuery.php797
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();
+ }
+}