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