xref: /plugin/struct/meta/Search.php (revision b2ed727a10f5cdbd0218fefd99f28248b6b71672)
1<?php
2
3namespace plugin\struct\meta;
4
5class Search {
6    /**
7     * This separator will be used to concat multi values to flatten them in the result set
8     */
9    const CONCAT_SEPARATOR = "\n!_-_-_-_-_!\n";
10
11    /**
12     * The list of known and allowed comparators
13     */
14    static public $COMPARATORS = array(
15        '<', '>', '<=', '>=', '!=', '!~', '~'
16    );
17
18    /** @var  \helper_plugin_sqlite */
19    protected $sqlite;
20
21    /** @var Schema[] list of schemas to query */
22    protected $schemas = array();
23
24    /** @var Column[] list of columns to select */
25    protected $columns = array();
26
27    /** @var array the sorting of the result */
28    protected $sortby = array();
29
30    /** @var array the filters */
31    protected $filter = array();
32
33    /** @var array list of aliases tables can be referenced by */
34    protected $aliases = array();
35
36    /**
37     * Search constructor.
38     */
39    public function __construct() {
40        /** @var \helper_plugin_struct_db $plugin */
41        $plugin = plugin_load('helper', 'struct_db');
42        $this->sqlite = $plugin->getDB();
43    }
44
45    /**
46     * Add a schema to be searched
47     *
48     * Call multiple times for multiple schemas.
49     *
50     * @param string $table
51     * @param string $alias
52     */
53    public function addSchema($table, $alias = '') {
54        $this->schemas[$table] = new Schema($table);
55        if($alias) $this->aliases[$alias] = $table;
56    }
57
58    /**
59     * Add a column to be returned by the search
60     *
61     * Call multiple times for multiple columns. Be sure the referenced tables have been
62     * added before
63     *
64     * @param string $colname may contain an alias
65     */
66    public function addColumn($colname) {
67        $col = $this->findColumn($colname);
68        if(!$col) return; //FIXME do we really want to ignore missing columns?
69        $this->columns[] = $col;
70    }
71
72    /**
73     * Add sorting options
74     *
75     * Call multiple times for multiple columns. Be sure the referenced tables have been
76     * added before
77     *
78     * @param string $colname may contain an alias
79     * @param bool $asc sort direction (ASC = true, DESC = false)
80     */
81    public function addSort($colname, $asc = true) {
82        $col = $this->findColumn($colname);
83        if(!$col) return; //FIXME do we really want to ignore missing columns?
84
85        $this->sortby[] = array($col, $asc);
86    }
87
88    /**
89     * Adds a filter
90     *
91     * @param string $colname may contain an alias
92     * @param string $value
93     * @param string $comp @see self::COMPARATORS
94     * @param string $type either 'OR' or 'AND'
95     */
96    public function addFilter($colname, $value, $comp, $type = 'OR') {
97        if(!in_array($comp, self::$COMPARATORS)) throw new StructException("Bad comperator. Use " . join(',', self::$COMPARATORS));
98        if($type != 'OR' && $type != 'AND') throw new StructException('Bad filter type . Only AND or OR allowed');
99
100        $col = $this->findColumn($colname);
101        if(!$col) return; //FIXME do we really want to ignore missing columns?
102
103        $this->filter[] = array($col, $value, $comp, $type);
104    }
105
106    /**
107     * Execute this search and return the result
108     *
109     * The result is a two dimensional array of array. Each cell contains an array with
110     * the keys 'col' (containing a Column object) and 'val' containing the value(s)
111     */
112    public function execute() {
113        list($sql, $opts) = $this->getSQL();
114
115        $res = $this->sqlite->query($sql, $opts);
116        $data = $this->sqlite->res2arr($res);
117        $this->sqlite->res_close($res);
118
119        $result = array();
120        foreach($data as $row) {
121            $C = 0;
122            $resrow = array();
123            foreach($this->columns as $col) {
124                $rescol = array();
125                $rescol['col'] = $col;
126                $rescol['val'] = $row["C$C"];
127                if($col->isMulti()) {
128                    $rescol['val'] = explode(self::CONCAT_SEPARATOR,$rescol['val']);
129                }
130                $resrow[] = $rescol;
131                $C++;
132            }
133            $result[] = $resrow;
134        }
135        return $result;
136    }
137
138    /**
139     * Transform the set search parameters into a statement
140     *
141     * @return array ($sql, $opts) The SQL and parameters to execute
142     */
143    public function getSQL() {
144        if(!$this->columns) throw new StructException('nocolname');
145
146        $from = '';
147        $select = '';
148        $order = '';
149        $grouping = array();
150        $opts = array();
151        $where = '1 = 1';
152
153        // basic tables
154        $first = '';
155        foreach($this->schemas as $schema) {
156            if($first) {
157                // follow up tables
158                $from .= "\nLEFT OUTER JOIN data_{$schema->getTable()} ON data_$first.pid = data_{$schema->getTable()}.pid";
159            } else {
160                // first table
161                $select .= "data_{$schema->getTable()}.pid as PID, ";
162                $from .= "data_{$schema->getTable()}";
163                $first = $schema->getTable();
164            }
165
166            $where .= "\nAND data_{$schema->getTable()}.latest = 1";
167        }
168
169        // columns to select, handling multis
170        $sep = self::CONCAT_SEPARATOR;
171        $n = 0;
172        foreach($this->columns as $col) {
173            $CN = 'C' . $n++;
174
175            if($col->isMulti()) {
176                $tn = 'M' . $col->getColref();
177                $select .= "GROUP_CONCAT($tn.value, '$sep') AS $CN, ";
178                $from .= "\nLEFT OUTER JOIN multi_{$col->getTable()} AS $tn";
179                $from .= " ON data_{$col->getTable()}.pid = $tn.pid AND data_{$col->getTable()}.rev = $tn.rev";
180                $from .= " AND $tn.colref = {$col->getColref()}\n";
181            } else {
182                $select .= 'data_' . $col->getTable() . '.col' . $col->getColref() . " AS $CN, ";
183                $grouping[] = $CN;
184            }
185        }
186        $select = rtrim($select, ', ');
187
188        // where clauses
189        foreach($this->filter as $filter) {
190            list($col, $value, $comp, $type) = $filter;
191
192            /** @var $col Column */
193            if($col->isMulti()) {
194                $tn = 'MN' . $col->getColref(); // FIXME this joins a second time if the column was selected before
195                $from .= "\nLEFT OUTER JOIN multi_{$col->getTable()} AS $tn";
196                $from .= " ON data_{$col->getTable()}.pid = $tn.pid AND data_{$col->getTable()}.rev = $tn.rev";
197                $from .= " AND $tn.colref = {$col->getColref()}\n";
198
199                $column = "$tn.value";
200            } else {
201                $column = "data_{$col->getTable()}.col{$col->getColref()}";
202            }
203
204            list($wsql, $wopt) = $col->getType()->compare($column, $comp, $value);
205            $opts = array_merge($opts, $wopt);
206
207            $where .= " $type $wsql";
208        }
209
210        // sorting
211        foreach($this->sortby as $sort) {
212            list($col, $asc) = $sort;
213
214            /** @var $col Column */
215            if($col->isMulti()) {
216                // FIXME how to sort by multival?
217                // FIXME what if sort by non merged multival?
218            } else {
219                $order .= "data_{$col->getTable()}.col{$col->getColref()} ";
220                $order .= ($asc) ? 'ASC' : 'DESC';
221                $order .= ', ';
222            }
223        }
224        $order = rtrim($order, ', ');
225
226        $sql = "SELECT $select\n  FROM $from\nWHERE $where\nGROUP BY " . join(', ', $grouping);
227        if($order) $sql .= "\nORDER BY $order";
228
229        return array($sql, $opts);
230    }
231
232    /**
233     * Find a column to be used in the search
234     *
235     * @param string $colname may contain an alias
236     * @return bool|Column
237     */
238    protected function findColumn($colname) {
239        if(!$this->schemas) throw new StructException('noschemas');
240
241        // resolve the alias or table name
242        list($table, $colname) = explode('.', $colname, 2);
243        if(!$colname) {
244            $colname = $table;
245            $table = '';
246        }
247        if($table && isset($this->aliases[$table])) {
248            $table = $this->aliases[$table];
249        }
250
251        if(!$colname) throw new StructException('nocolname');
252
253        // if table name given search only that, otherwiese try all for matching column name
254        if($table) {
255            $schemas = array($table => $this->schemas[$table]);
256        } else {
257            $schemas = $this->schemas;
258        }
259
260        // find it
261        $col = false;
262        foreach($schemas as $schema) {
263            $col = $schema->findColumn($colname);
264            if($col) break;
265        }
266
267        return $col;
268    }
269
270}
271
272
273