1<?php
2/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
3
4namespace Icinga\Repository;
5
6use DateTime;
7use Icinga\Application\Logger;
8use Icinga\Data\Filter\Filter;
9use Icinga\Data\Filter\FilterExpression;
10use Icinga\Data\Selectable;
11use Icinga\Exception\ProgrammingError;
12use Icinga\Exception\QueryException;
13use Icinga\Exception\StatementException;
14use Icinga\Util\ASN1;
15use Icinga\Util\StringHelper;
16use InvalidArgumentException;
17
18/**
19 * Abstract base class for concrete repository implementations
20 *
21 * To utilize this class and its features, the following is required:
22 * <ul>
23 *  <li>Concrete implementations need to initialize Repository::$queryColumns</li>
24 *  <li>The datasource passed to a repository must implement the Selectable interface</li>
25 *  <li>The datasource must yield an instance of Queryable when its select() method is called</li>
26 * </ul>
27 */
28abstract class Repository implements Selectable
29{
30    /**
31     * The format to use when converting values of type date_time
32     */
33    const DATETIME_FORMAT = 'd/m/Y g:i A';
34
35    /**
36     * The name of this repository
37     *
38     * @var string
39     */
40    protected $name;
41
42    /**
43     * The datasource being used
44     *
45     * @var Selectable
46     */
47    protected $ds;
48
49    /**
50     * The base table name this repository is responsible for
51     *
52     * This will be automatically set to the first key of $queryColumns if not explicitly set.
53     *
54     * @var string
55     */
56    protected $baseTable;
57
58    /**
59     * The virtual tables being provided
60     *
61     * This may be initialized by concrete repository implementations with an array
62     * where a key is the name of a virtual table and its value the real table name.
63     *
64     * @var array
65     */
66    protected $virtualTables;
67
68    /**
69     * The query columns being provided
70     *
71     * This must be initialized by concrete repository implementations, in the following format
72     * <code>
73     *  array(
74     *      'baseTable' => array(
75     *          'column1',
76     *          'alias1' => 'column2',
77     *          'alias2' => 'column3'
78     *      )
79     *  )
80     * </code>
81     *
82     * @var array
83     */
84    protected $queryColumns;
85
86    /**
87     * The columns (or aliases) which are not permitted to be queried
88     *
89     * Blacklisted query columns can still occur in a filter expression or sort rule.
90     *
91     * @var array   An array of strings
92     */
93    protected $blacklistedQueryColumns;
94
95    /**
96     * Whether the blacklisted query columns are in the legacy format
97     *
98     * @var bool
99     */
100    protected $legacyBlacklistedQueryColumns;
101
102    /**
103     * The filter columns being provided
104     *
105     * This may be intialized by concrete repository implementations, in the following format
106     * <code>
107     *  array(
108     *      'alias_or_column_name',
109     *      'label_to_show_in_the_filter_editor' => 'alias_or_column_name'
110     *  )
111     * </code>
112     *
113     * @var array
114     */
115    protected $filterColumns;
116
117    /**
118     * Whether the provided filter columns are in the legacy format
119     *
120     * @var bool
121     */
122    protected $legacyFilterColumns;
123
124    /**
125     * The search columns (or aliases) being provided
126     *
127     * @var array   An array of strings
128     */
129    protected $searchColumns;
130
131    /**
132     * Whether the provided search columns are in the legacy format
133     *
134     * @var bool
135     */
136    protected $legacySearchColumns;
137
138    /**
139     * The sort rules to be applied on a query
140     *
141     * This may be initialized by concrete repository implementations, in the following format
142     * <code>
143     *  array(
144     *      'alias_or_column_name' => array(
145     *          'order'     => 'asc'
146     *      ),
147     *      'alias_or_column_name' => array(
148     *          'columns'   => array(
149     *              'once_more_the_alias_or_column_name_as_in_the_parent_key',
150     *              'an_additional_alias_or_column_name_with_a_specific_direction asc'
151     *          ),
152     *          'order'     => 'desc'
153     *      ),
154     *      'alias_or_column_name' => array(
155     *          'columns'   => array('a_different_alias_or_column_name_designated_to_act_as_the_only_sort_column')
156     *          // Ascendant sort by default
157     *      )
158     *  )
159     * </code>
160     * Note that it's mandatory to supply the alias name in case there is one.
161     *
162     * @var array
163     */
164    protected $sortRules;
165
166    /**
167     * Whether the provided sort rules are in the legacy format
168     *
169     * @var bool
170     */
171    protected $legacySortRules;
172
173    /**
174     * The value conversion rules to apply on a query or statement
175     *
176     * This may be initialized by concrete repository implementations and describes for which aliases or column
177     * names what type of conversion is available. For entries, where the key is the alias/column and the value
178     * is the type identifier, the repository attempts to find a conversion method for the alias/column first and,
179     * if none is found, then for the type. If an entry only provides a value, which is the alias/column, the
180     * repository only attempts to find a conversion method for the alias/column. The name of a conversion method
181     * is expected to be declared using lowerCamelCase. (e.g. user_name will be translated to persistUserName and
182     * groupname will be translated to retrieveGroupname)
183     *
184     * @var array
185     */
186    protected $conversionRules;
187
188    /**
189     * An array to map table names to aliases
190     *
191     * @var array
192     */
193    protected $aliasTableMap;
194
195    /**
196     * A flattened array to map query columns to aliases
197     *
198     * @var array
199     */
200    protected $aliasColumnMap;
201
202    /**
203     * An array to map table names to query columns
204     *
205     * @var array
206     */
207    protected $columnTableMap;
208
209    /**
210     * A flattened array to map aliases to query columns
211     *
212     * @var array
213     */
214    protected $columnAliasMap;
215
216    /**
217     * Create a new repository object
218     *
219     * @param   Selectable|null $ds The datasource to use.
220     *                              Only pass null if you have overridden {@link getDataSource()}!
221     */
222    public function __construct(Selectable $ds = null)
223    {
224        $this->ds = $ds;
225        $this->aliasTableMap = array();
226        $this->aliasColumnMap = array();
227        $this->columnTableMap = array();
228        $this->columnAliasMap = array();
229
230        $this->init();
231    }
232
233    /**
234     * Initialize this repository
235     *
236     * Supposed to be overwritten by concrete repository implementations.
237     */
238    protected function init()
239    {
240    }
241
242    /**
243     * Set this repository's name
244     *
245     * @param   string  $name
246     *
247     * @return  $this
248     */
249    public function setName($name)
250    {
251        $this->name = $name;
252        return $this;
253    }
254
255    /**
256     * Return this repository's name
257     *
258     * In case no name has been explicitly set yet, the class name is returned.
259     *
260     * @return  string
261     */
262    public function getName()
263    {
264        return $this->name ?: __CLASS__;
265    }
266
267    /**
268     * Return the datasource being used for the given table
269     *
270     * @param   string  $table
271     *
272     * @return  Selectable
273     *
274     * @throws  ProgrammingError    In case no datasource is available
275     */
276    public function getDataSource($table = null)
277    {
278        if ($this->ds === null) {
279            throw new ProgrammingError(
280                'No data source available. It is required to either pass it'
281                . ' at initialization time or by overriding this method.'
282            );
283        }
284
285        return $this->ds;
286    }
287
288    /**
289     * Return the base table name this repository is responsible for
290     *
291     * @return  string
292     *
293     * @throws  ProgrammingError    In case no base table name has been set and
294     *                               $this->queryColumns does not provide one either
295     */
296    public function getBaseTable()
297    {
298        if ($this->baseTable === null) {
299            $queryColumns = $this->getQueryColumns();
300            reset($queryColumns);
301            $this->baseTable = key($queryColumns);
302            if (is_int($this->baseTable) || !is_array($queryColumns[$this->baseTable])) {
303                throw new ProgrammingError('"%s" is not a valid base table', $this->baseTable);
304            }
305        }
306
307        return $this->baseTable;
308    }
309
310    /**
311     * Return the virtual tables being provided
312     *
313     * Calls $this->initializeVirtualTables() in case $this->virtualTables is null.
314     *
315     * @return  array
316     */
317    public function getVirtualTables()
318    {
319        if ($this->virtualTables === null) {
320            $this->virtualTables = $this->initializeVirtualTables();
321        }
322
323        return $this->virtualTables;
324    }
325
326    /**
327     * Overwrite this in your repository implementation in case you need to initialize the virtual tables lazily
328     *
329     * @return  array
330     */
331    protected function initializeVirtualTables()
332    {
333        return array();
334    }
335
336    /**
337     * Return the query columns being provided
338     *
339     * Calls $this->initializeQueryColumns() in case $this->queryColumns is null.
340     *
341     * @return  array
342     */
343    public function getQueryColumns()
344    {
345        if ($this->queryColumns === null) {
346            $this->queryColumns = $this->initializeQueryColumns();
347        }
348
349        return $this->queryColumns;
350    }
351
352    /**
353     * Overwrite this in your repository implementation in case you need to initialize the query columns lazily
354     *
355     * @return  array
356     */
357    protected function initializeQueryColumns()
358    {
359        return array();
360    }
361
362    /**
363     * Return the columns (or aliases) which are not permitted to be queried
364     *
365     * Calls $this->initializeBlacklistedQueryColumns() in case $this->blacklistedQueryColumns is null.
366     *
367     * @param   string  $table
368     *
369     * @return  array
370     */
371    public function getBlacklistedQueryColumns($table = null)
372    {
373        if ($this->blacklistedQueryColumns === null) {
374            $this->legacyBlacklistedQueryColumns = false;
375
376            $blacklistedQueryColumns = $this->initializeBlacklistedQueryColumns($table);
377            if (is_int(key($blacklistedQueryColumns))) {
378                $this->blacklistedQueryColumns[$table] = $blacklistedQueryColumns;
379            } else {
380                $this->blacklistedQueryColumns = $blacklistedQueryColumns;
381            }
382        } elseif ($this->legacyBlacklistedQueryColumns === null) {
383            $this->legacyBlacklistedQueryColumns = is_int(key($this->blacklistedQueryColumns));
384        }
385
386        if ($this->legacyBlacklistedQueryColumns) {
387            return $this->blacklistedQueryColumns;
388        } elseif (! isset($this->blacklistedQueryColumns[$table])) {
389            $this->blacklistedQueryColumns[$table] = $this->initializeBlacklistedQueryColumns($table);
390        }
391
392        return $this->blacklistedQueryColumns[$table];
393    }
394
395    /**
396     * Overwrite this in your repository implementation in case you need to initialize the
397     * blacklisted query columns lazily or dependent on a query's current base table
398     *
399     * @param   string  $table
400     *
401     * @return  array
402     */
403    protected function initializeBlacklistedQueryColumns()
404    {
405        // $table is not part of the signature due to PHP strict standards
406        return array();
407    }
408
409    /**
410     * Return the filter columns being provided
411     *
412     * Calls $this->initializeFilterColumns() in case $this->filterColumns is null.
413     *
414     * @param   string  $table
415     *
416     * @return  array
417     */
418    public function getFilterColumns($table = null)
419    {
420        if ($this->filterColumns === null) {
421            $this->legacyFilterColumns = false;
422
423            $filterColumns = $this->initializeFilterColumns($table);
424            $foundTables = array_intersect_key($this->getQueryColumns(), $filterColumns);
425            if (empty($foundTables)) {
426                $this->filterColumns[$table] = $filterColumns;
427            } else {
428                $this->filterColumns = $filterColumns;
429            }
430        } elseif ($this->legacyFilterColumns === null) {
431            $foundTables = array_intersect_key($this->getQueryColumns(), $this->filterColumns);
432            $this->legacyFilterColumns = empty($foundTables);
433        }
434
435        if ($this->legacyFilterColumns) {
436            return $this->filterColumns;
437        } elseif (! isset($this->filterColumns[$table])) {
438            $this->filterColumns[$table] = $this->initializeFilterColumns($table);
439        }
440
441        return $this->filterColumns[$table];
442    }
443
444    /**
445     * Overwrite this in your repository implementation in case you need to initialize
446     * the filter columns lazily or dependent on a query's current base table
447     *
448     * @param   string  $table
449     *
450     * @return  array
451     */
452    protected function initializeFilterColumns()
453    {
454        // $table is not part of the signature due to PHP strict standards
455        return array();
456    }
457
458    /**
459     * Return the search columns being provided
460     *
461     * Calls $this->initializeSearchColumns() in case $this->searchColumns is null.
462     *
463     * @param   string  $table
464     *
465     * @return  array
466     */
467    public function getSearchColumns($table = null)
468    {
469        if ($this->searchColumns === null) {
470            $this->legacySearchColumns = false;
471
472            $searchColumns = $this->initializeSearchColumns($table);
473            if (is_int(key($searchColumns))) {
474                $this->searchColumns[$table] = $searchColumns;
475            } else {
476                $this->searchColumns = $searchColumns;
477            }
478        } elseif ($this->legacySearchColumns === null) {
479            $this->legacySearchColumns = is_int(key($this->searchColumns));
480        }
481
482        if ($this->legacySearchColumns) {
483            return $this->searchColumns;
484        } elseif (! isset($this->searchColumns[$table])) {
485            $this->searchColumns[$table] = $this->initializeSearchColumns($table);
486        }
487
488        return $this->searchColumns[$table];
489    }
490
491    /**
492     * Overwrite this in your repository implementation in case you need to initialize
493     * the search columns lazily or dependent on a query's current base table
494     *
495     * @param   string  $table
496     *
497     * @return  array
498     */
499    protected function initializeSearchColumns()
500    {
501        // $table is not part of the signature due to PHP strict standards
502        return array();
503    }
504
505    /**
506     * Return the sort rules to be applied on a query
507     *
508     * Calls $this->initializeSortRules() in case $this->sortRules is null.
509     *
510     * @param   string  $table
511     *
512     * @return  array
513     */
514    public function getSortRules($table = null)
515    {
516        if ($this->sortRules === null) {
517            $this->legacySortRules = false;
518
519            $sortRules = $this->initializeSortRules($table);
520            $foundTables = array_intersect_key($this->getQueryColumns(), $sortRules);
521            if (empty($foundTables)) {
522                $this->sortRules[$table] = $sortRules;
523            } else {
524                $this->sortRules = $sortRules;
525            }
526        } elseif ($this->legacySortRules === null) {
527            $foundTables = array_intersect_key($this->getQueryColumns(), $this->sortRules);
528            $this->legacySortRules = empty($foundTables);
529        }
530
531        if ($this->legacySortRules) {
532            return $this->sortRules;
533        } elseif (! isset($this->sortRules[$table])) {
534            $this->sortRules[$table] = $this->initializeSortRules($table);
535        }
536
537        return $this->sortRules[$table];
538    }
539
540    /**
541     * Overwrite this in your repository implementation in case you need to initialize
542     * the sort rules lazily or dependent on a query's current base table
543     *
544     * @param   string  $table
545     *
546     * @return  array
547     */
548    protected function initializeSortRules()
549    {
550        // $table is not part of the signature due to PHP strict standards
551        return array();
552    }
553
554    /**
555     * Return the value conversion rules to apply on a query
556     *
557     * Calls $this->initializeConversionRules() in case $this->conversionRules is null.
558     *
559     * @return  array
560     */
561    public function getConversionRules()
562    {
563        if ($this->conversionRules === null) {
564            $this->conversionRules = $this->initializeConversionRules();
565        }
566
567        return $this->conversionRules;
568    }
569
570    /**
571     * Overwrite this in your repository implementation in case you need to initialize the conversion rules lazily
572     *
573     * @return  array
574     */
575    protected function initializeConversionRules()
576    {
577        return array();
578    }
579
580    /**
581     * Return an array to map table names to aliases
582     *
583     * @return  array
584     */
585    protected function getAliasTableMap()
586    {
587        if (empty($this->aliasTableMap)) {
588            $this->initializeAliasMaps();
589        }
590
591        return $this->aliasTableMap;
592    }
593
594    /**
595     * Return a flattened array to map query columns to aliases
596     *
597     * @return  array
598     */
599    protected function getAliasColumnMap()
600    {
601        if (empty($this->aliasColumnMap)) {
602            $this->initializeAliasMaps();
603        }
604
605        return $this->aliasColumnMap;
606    }
607
608    /**
609     * Return an array to map table names to query columns
610     *
611     * @return  array
612     */
613    protected function getColumnTableMap()
614    {
615        if (empty($this->columnTableMap)) {
616            $this->initializeAliasMaps();
617        }
618
619        return $this->columnTableMap;
620    }
621
622    /**
623     * Return a flattened array to map aliases to query columns
624     *
625     * @return  array
626     */
627    protected function getColumnAliasMap()
628    {
629        if (empty($this->columnAliasMap)) {
630            $this->initializeAliasMaps();
631        }
632
633        return $this->columnAliasMap;
634    }
635
636    /**
637     * Initialize $this->aliasTableMap and $this->aliasColumnMap
638     *
639     * @throws  ProgrammingError    In case $this->queryColumns does not provide any column information
640     */
641    protected function initializeAliasMaps()
642    {
643        $queryColumns = $this->getQueryColumns();
644        if (empty($queryColumns)) {
645            throw new ProgrammingError('Repositories are required to initialize $this->queryColumns first');
646        }
647
648        foreach ($queryColumns as $table => $columns) {
649            foreach ($columns as $alias => $column) {
650                if (! is_string($alias)) {
651                    $key = $column;
652                } else {
653                    $key = $alias;
654                    $column = preg_replace('~\n\s*~', ' ', $column);
655                }
656
657                if (array_key_exists($key, $this->aliasTableMap)) {
658                    if ($this->aliasTableMap[$key] !== null) {
659                        $existingTable = $this->aliasTableMap[$key];
660                        $existingColumn = $this->aliasColumnMap[$key];
661                        $this->aliasTableMap[$existingTable . '.' . $key] = $existingTable;
662                        $this->aliasColumnMap[$existingTable . '.' . $key] = $existingColumn;
663                        $this->aliasTableMap[$key] = null;
664                        $this->aliasColumnMap[$key] = null;
665                    }
666
667                    $this->aliasTableMap[$table . '.' . $key] = $table;
668                    $this->aliasColumnMap[$table . '.' . $key] = $column;
669                } else {
670                    $this->aliasTableMap[$key] = $table;
671                    $this->aliasColumnMap[$key] = $column;
672                }
673
674                if (array_key_exists($column, $this->columnTableMap)) {
675                    if ($this->columnTableMap[$column] !== null) {
676                        $existingTable = $this->columnTableMap[$column];
677                        $existingAlias = $this->columnAliasMap[$column];
678                        $this->columnTableMap[$existingTable . '.' . $column] = $existingTable;
679                        $this->columnAliasMap[$existingTable . '.' . $column] = $existingAlias;
680                        $this->columnTableMap[$column] = null;
681                        $this->columnAliasMap[$column] = null;
682                    }
683
684                    $this->columnTableMap[$table . '.' . $column] = $table;
685                    $this->columnAliasMap[$table . '.' . $column] = $key;
686                } else {
687                    $this->columnTableMap[$column] = $table;
688                    $this->columnAliasMap[$column] = $key;
689                }
690            }
691        }
692    }
693
694    /**
695     * Return a new query for the given columns
696     *
697     * @param   array   $columns    The desired columns, if null all columns will be queried
698     *
699     * @return  RepositoryQuery
700     */
701    public function select(array $columns = null)
702    {
703        $query = new RepositoryQuery($this);
704        $query->from($this->getBaseTable(), $columns);
705        return $query;
706    }
707
708    /**
709     * Return whether this repository is capable of converting values for the given table and optional column
710     *
711     * @param   string  $table
712     * @param   string  $column
713     *
714     * @return  bool
715     */
716    public function providesValueConversion($table, $column = null)
717    {
718        $conversionRules = $this->getConversionRules();
719        if (empty($conversionRules)) {
720            return false;
721        }
722
723        if (! isset($conversionRules[$table])) {
724            return false;
725        } elseif ($column === null) {
726            return true;
727        }
728
729        $alias = $this->reassembleQueryColumnAlias($table, $column) ?: $column;
730        return array_key_exists($alias, $conversionRules[$table]) || in_array($alias, $conversionRules[$table]);
731    }
732
733    /**
734     * Convert a value supposed to be transmitted to the data source
735     *
736     * @param   string              $table      The table where to persist the value
737     * @param   string              $name       The alias or column name
738     * @param   mixed               $value      The value to convert
739     * @param   RepositoryQuery     $query      An optional query to pass as context
740     *                                          (Directly passed through to $this->getConverter)
741     *
742     * @return  mixed                           If conversion was possible, the converted value,
743     *                                          otherwise the unchanged value
744     */
745    public function persistColumn($table, $name, $value, RepositoryQuery $query = null)
746    {
747        $converter = $this->getConverter($table, $name, 'persist', $query);
748        if ($converter !== null) {
749            $value = $this->$converter($value, $name, $table, $query);
750        }
751
752        return $value;
753    }
754
755    /**
756     * Convert a value which was fetched from the data source
757     *
758     * @param   string              $table      The table the value has been fetched from
759     * @param   string              $name       The alias or column name
760     * @param   mixed               $value      The value to convert
761     * @param   RepositoryQuery     $query      An optional query to pass as context
762     *                                          (Directly passed through to $this->getConverter)
763     *
764     * @return  mixed                           If conversion was possible, the converted value,
765     *                                          otherwise the unchanged value
766     */
767    public function retrieveColumn($table, $name, $value, RepositoryQuery $query = null)
768    {
769        $converter = $this->getConverter($table, $name, 'retrieve', $query);
770        if ($converter !== null) {
771            $value = $this->$converter($value, $name, $table, $query);
772        }
773
774        return $value;
775    }
776
777    /**
778     * Return the name of the conversion method for the given alias or column name and context
779     *
780     * @param   string              $table      The datasource's table
781     * @param   string              $name       The alias or column name for which to return a conversion method
782     * @param   string              $context    The context of the conversion: persist or retrieve
783     * @param   RepositoryQuery     $query      An optional query to pass as context
784     *                                          (unused by the base implementation)
785     *
786     * @return  string
787     *
788     * @throws  ProgrammingError    In case a conversion rule is found but not any conversion method
789     */
790    protected function getConverter($table, $name, $context, RepositoryQuery $query = null)
791    {
792        $conversionRules = $this->getConversionRules();
793        if (! isset($conversionRules[$table])) {
794            return;
795        }
796
797        $tableRules = $conversionRules[$table];
798        if (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) {
799            $alias = $name;
800        }
801
802        // Check for a conversion method for the alias/column first
803        if (array_key_exists($alias, $tableRules) || in_array($alias, $tableRules)) {
804            $methodName = $context . join('', array_map('ucfirst', explode('_', $alias)));
805            if (method_exists($this, $methodName)) {
806                return $methodName;
807            }
808        }
809
810        // The conversion method for the type is just a fallback, but it is required to exist if defined
811        if (isset($tableRules[$alias])) {
812            $identifier = join('', array_map('ucfirst', explode('_', $tableRules[$alias])));
813            if (! method_exists($this, $context . $identifier)) {
814                // Do not throw an error in case at least one conversion method exists
815                if (! method_exists($this, ($context === 'persist' ? 'retrieve' : 'persist') . $identifier)) {
816                    throw new ProgrammingError(
817                        'Cannot find any conversion method for type "%s"'
818                        . '. Add a proper conversion method or remove the type definition',
819                        $tableRules[$alias]
820                    );
821                }
822
823                Logger::debug(
824                    'Conversion method "%s" for type definition "%s" does not exist in repository "%s".',
825                    $context . $identifier,
826                    $tableRules[$alias],
827                    $this->getName()
828                );
829            } else {
830                return $context . $identifier;
831            }
832        }
833    }
834
835    /**
836     * Convert a timestamp or DateTime object to a string formatted using static::DATETIME_FORMAT
837     *
838     * @param   mixed   $value
839     *
840     * @return  string
841     */
842    protected function persistDateTime($value)
843    {
844        if (is_numeric($value)) {
845            $value = date(static::DATETIME_FORMAT, $value);
846        } elseif ($value instanceof DateTime) {
847            $value = date(static::DATETIME_FORMAT, $value->getTimestamp()); // Using date here, to ignore any timezone
848        } elseif ($value !== null) {
849            throw new ProgrammingError(
850                'Cannot persist value "%s" as type date_time. It\'s not a timestamp or DateTime object',
851                $value
852            );
853        }
854
855        return $value;
856    }
857
858    /**
859     * Convert a string formatted using static::DATETIME_FORMAT to a unix timestamp
860     *
861     * @param   string  $value
862     *
863     * @return  int
864     */
865    protected function retrieveDateTime($value)
866    {
867        if (is_numeric($value)) {
868            $value = (int) $value;
869        } elseif (is_string($value)) {
870            $dateTime = DateTime::createFromFormat(static::DATETIME_FORMAT, $value);
871            if ($dateTime === false) {
872                Logger::debug(
873                    'Unable to parse string "%s" as type date_time with format "%s" in repository "%s"',
874                    $value,
875                    static::DATETIME_FORMAT,
876                    $this->getName()
877                );
878                $value = null;
879            } else {
880                $value = $dateTime->getTimestamp();
881            }
882        } elseif ($value !== null) {
883            throw new ProgrammingError(
884                'Cannot retrieve value "%s" as type date_time. It\'s not a integer or (numeric) string',
885                $value
886            );
887        }
888
889        return $value;
890    }
891
892    /**
893     * Convert the given array to an comma separated string
894     *
895     * @param   array|string    $value
896     *
897     * @return  string
898     */
899    protected function persistCommaSeparatedString($value)
900    {
901        if (is_array($value)) {
902            $value = join(',', array_map('trim', $value));
903        } elseif ($value !== null && !is_string($value)) {
904            throw new ProgrammingError('Cannot persist value "%s" as comma separated string', $value);
905        }
906
907        return $value;
908    }
909
910    /**
911     * Convert the given comma separated string to an array
912     *
913     * @param   string  $value
914     *
915     * @return  array
916     */
917    protected function retrieveCommaSeparatedString($value)
918    {
919        if ($value && is_string($value)) {
920            $value = StringHelper::trimSplit($value);
921        } elseif ($value !== null) {
922            throw new ProgrammingError('Cannot retrieve value "%s" as array. It\'s not a string', $value);
923        }
924
925        return $value;
926    }
927
928    /**
929     * Parse the given value based on the ASN.1 standard (GeneralizedTime) and return its timestamp representation
930     *
931     * @param   string|null     $value
932     *
933     * @return  int
934     *
935     * @see https://tools.ietf.org/html/rfc4517#section-3.3.13
936     */
937    protected function retrieveGeneralizedTime($value)
938    {
939        if ($value === null) {
940            return $value;
941        }
942
943        try {
944            return ASN1::parseGeneralizedTime($value)->getTimeStamp();
945        } catch (InvalidArgumentException $e) {
946            Logger::debug(sprintf('Repository "%s": %s', $this->getName(), $e->getMessage()));
947        }
948    }
949
950    /**
951     * Validate that the requested table exists and resolve it's real name if necessary
952     *
953     * @param   string              $table      The table to validate
954     * @param   RepositoryQuery     $query      An optional query to pass as context
955     *                                          (unused by the base implementation)
956     *
957     * @return  string                          The table's name, may differ from the given one
958     *
959     * @throws  ProgrammingError                In case the given table does not exist
960     */
961    public function requireTable($table, RepositoryQuery $query = null)
962    {
963        $queryColumns = $this->getQueryColumns();
964        if (! isset($queryColumns[$table])) {
965            throw new ProgrammingError('Table "%s" not found', $table);
966        }
967
968        $virtualTables = $this->getVirtualTables();
969        if (isset($virtualTables[$table])) {
970            $table = $virtualTables[$table];
971        }
972
973        return $table;
974    }
975
976    /**
977     * Recurse the given filter, require each column for the given table and convert all values
978     *
979     * @param   string              $table      The table being filtered
980     * @param   Filter              $filter     The filter to recurse
981     * @param   RepositoryQuery     $query      An optional query to pass as context
982     *                                          (Directly passed through to $this->requireFilterColumn)
983     * @param   bool                $clone      Whether to clone $filter first
984     *
985     * @return  Filter                          The udpated filter
986     */
987    public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true)
988    {
989        if ($clone) {
990            $filter = clone $filter;
991        }
992
993        if ($filter->isExpression()) {
994            $column = $filter->getColumn();
995            $filter->setColumn($this->requireFilterColumn($table, $column, $query, $filter));
996            $filter->setExpression($this->persistColumn($table, $column, $filter->getExpression(), $query));
997        } elseif ($filter->isChain()) {
998            foreach ($filter->filters() as $chainOrExpression) {
999                $this->requireFilter($table, $chainOrExpression, $query, false);
1000            }
1001        }
1002
1003        return $filter;
1004    }
1005
1006    /**
1007     * Return this repository's query columns of the given table mapped to their respective aliases
1008     *
1009     * @param   string  $table
1010     *
1011     * @return  array
1012     *
1013     * @throws  ProgrammingError    In case $table does not exist
1014     */
1015    public function requireAllQueryColumns($table)
1016    {
1017        $queryColumns = $this->getQueryColumns();
1018        if (! array_key_exists($table, $queryColumns)) {
1019            throw new ProgrammingError('Table name "%s" not found', $table);
1020        }
1021
1022        $blacklist = $this->getBlacklistedQueryColumns($table);
1023        $columns = array();
1024        foreach ($queryColumns[$table] as $alias => $column) {
1025            $name = is_string($alias) ? $alias : $column;
1026            if (! in_array($name, $blacklist)) {
1027                $columns[$alias] = $this->resolveQueryColumnAlias($table, $name);
1028            }
1029        }
1030
1031        return $columns;
1032    }
1033
1034    /**
1035     * Return the query column name for the given alias or null in case the alias does not exist
1036     *
1037     * @param   string  $table
1038     * @param   string  $alias
1039     *
1040     * @return  string|null
1041     */
1042    public function resolveQueryColumnAlias($table, $alias)
1043    {
1044        $aliasColumnMap = $this->getAliasColumnMap();
1045        if (isset($aliasColumnMap[$alias])) {
1046            return $aliasColumnMap[$alias];
1047        }
1048
1049        $prefixedAlias = $table . '.' . $alias;
1050        if (isset($aliasColumnMap[$prefixedAlias])) {
1051            return $aliasColumnMap[$prefixedAlias];
1052        }
1053    }
1054
1055    /**
1056     * Return the alias for the given query column name or null in case the query column name does not exist
1057     *
1058     * @param   string  $table
1059     * @param   string  $column
1060     *
1061     * @return  string|null
1062     */
1063    public function reassembleQueryColumnAlias($table, $column)
1064    {
1065        $columnAliasMap = $this->getColumnAliasMap();
1066        if (isset($columnAliasMap[$column])) {
1067            return $columnAliasMap[$column];
1068        }
1069
1070        $prefixedColumn = $table . '.' . $column;
1071        if (isset($columnAliasMap[$prefixedColumn])) {
1072            return $columnAliasMap[$prefixedColumn];
1073        }
1074    }
1075
1076    /**
1077     * Return whether the given alias or query column name is available in the given table
1078     *
1079     * @param   string  $table
1080     * @param   string  $alias
1081     *
1082     * @return  bool
1083     */
1084    public function validateQueryColumnAssociation($table, $alias)
1085    {
1086        $aliasTableMap = $this->getAliasTableMap();
1087        if (isset($aliasTableMap[$alias])) {
1088            return $aliasTableMap[$alias] === $table;
1089        }
1090
1091        $prefixedAlias = $table . '.' . $alias;
1092        if (isset($aliasTableMap[$prefixedAlias])) {
1093            return true;
1094        }
1095
1096        $columnTableMap = $this->getColumnTableMap();
1097        if (isset($columnTableMap[$alias])) {
1098            return $columnTableMap[$alias] === $table;
1099        }
1100
1101        return isset($columnTableMap[$prefixedAlias]);
1102    }
1103
1104    /**
1105     * Return whether the given column name or alias is a valid query column
1106     *
1107     * @param   string  $table  The table where to look for the column or alias
1108     * @param   string  $name   The column name or alias to check
1109     *
1110     * @return  bool
1111     */
1112    public function hasQueryColumn($table, $name)
1113    {
1114        if ($this->resolveQueryColumnAlias($table, $name) !== null) {
1115            $alias = $name;
1116        } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) === null) {
1117            return false;
1118        }
1119
1120        return !in_array($alias, $this->getBlacklistedQueryColumns($table))
1121            && $this->validateQueryColumnAssociation($table, $name);
1122    }
1123
1124    /**
1125     * Validate that the given column is a valid query target and return it or the actual name if it's an alias
1126     *
1127     * @param   string              $table  The table where to look for the column or alias
1128     * @param   string              $name   The name or alias of the column to validate
1129     * @param   RepositoryQuery     $query  An optional query to pass as context (unused by the base implementation)
1130     *
1131     * @return  string                      The given column's name
1132     *
1133     * @throws  QueryException              In case the given column is not a valid query column
1134     */
1135    public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
1136    {
1137        if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
1138            $alias = $name;
1139        } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
1140            $column = $name;
1141        } else {
1142            throw new QueryException(t('Query column "%s" not found'), $name);
1143        }
1144
1145        if (in_array($alias, $this->getBlacklistedQueryColumns($table))) {
1146            throw new QueryException(t('Column "%s" cannot be queried'), $name);
1147        }
1148
1149        if (! $this->validateQueryColumnAssociation($table, $alias)) {
1150            throw new QueryException(t('Query column "%s" not found in table "%s"'), $name, $table);
1151        }
1152
1153        return $column;
1154    }
1155
1156    /**
1157     * Return whether the given column name or alias is a valid filter column
1158     *
1159     * @param   string  $table  The table where to look for the column or alias
1160     * @param   string  $name   The column name or alias to check
1161     *
1162     * @return  bool
1163     */
1164    public function hasFilterColumn($table, $name)
1165    {
1166        return ($this->resolveQueryColumnAlias($table, $name) !== null
1167            || $this->reassembleQueryColumnAlias($table, $name) !== null)
1168            && $this->validateQueryColumnAssociation($table, $name);
1169    }
1170
1171    /**
1172     * Validate that the given column is a valid filter target and return it or the actual name if it's an alias
1173     *
1174     * @param   string              $table  The table where to look for the column or alias
1175     * @param   string              $name   The name or alias of the column to validate
1176     * @param   RepositoryQuery     $query  An optional query to pass as context (unused by the base implementation)
1177     * @param   FilterExpression    $filter An optional filter to pass as context (unused by the base implementation)
1178     *
1179     * @return  string                      The given column's name
1180     *
1181     * @throws  QueryException              In case the given column is not a valid filter column
1182     */
1183    public function requireFilterColumn($table, $name, RepositoryQuery $query = null, FilterExpression $filter = null)
1184    {
1185        if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
1186            $alias = $name;
1187        } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
1188            $column = $name;
1189        } else {
1190            throw new QueryException(t('Filter column "%s" not found'), $name);
1191        }
1192
1193        if (! $this->validateQueryColumnAssociation($table, $alias)) {
1194            throw new QueryException(t('Filter column "%s" not found in table "%s"'), $name, $table);
1195        }
1196
1197        return $column;
1198    }
1199
1200    /**
1201     * Return whether the given column name or alias of the given table is a valid statement column
1202     *
1203     * @param   string  $table  The table where to look for the column or alias
1204     * @param   string  $name   The column name or alias to check
1205     *
1206     * @return  bool
1207     */
1208    public function hasStatementColumn($table, $name)
1209    {
1210        return $this->hasQueryColumn($table, $name);
1211    }
1212
1213    /**
1214     * Validate that the given column is a valid statement column and return it or the actual name if it's an alias
1215     *
1216     * @param   string  $table      The table for which to require the column
1217     * @param   string  $name       The name or alias of the column to validate
1218     *
1219     * @return  string              The given column's name
1220     *
1221     * @throws  StatementException  In case the given column is not a statement column
1222     */
1223    public function requireStatementColumn($table, $name)
1224    {
1225        if (($column = $this->resolveQueryColumnAlias($table, $name)) !== null) {
1226            $alias = $name;
1227        } elseif (($alias = $this->reassembleQueryColumnAlias($table, $name)) !== null) {
1228            $column = $name;
1229        } else {
1230            throw new StatementException('Statement column "%s" not found', $name);
1231        }
1232
1233        if (in_array($alias, $this->getBlacklistedQueryColumns($table))) {
1234            throw new StatementException('Column "%s" cannot be referenced in a statement', $name);
1235        }
1236
1237        if (! $this->validateQueryColumnAssociation($table, $alias)) {
1238            throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table);
1239        }
1240
1241        return $column;
1242    }
1243
1244    /**
1245     * Resolve the given aliases or column names of the given table supposed to be persisted and convert their values
1246     *
1247     * @param   string  $table
1248     * @param   array   $data
1249     *
1250     * @return  array
1251     */
1252    public function requireStatementColumns($table, array $data)
1253    {
1254        $resolved = array();
1255        foreach ($data as $alias => $value) {
1256            $resolved[$this->requireStatementColumn($table, $alias)] = $this->persistColumn($table, $alias, $value);
1257        }
1258
1259        return $resolved;
1260    }
1261}
1262