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