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 Value()s. 162 * 163 * This will always query for the full result (not using offset and limit) and then 164 * return the wanted range, setting the count (@see getCount) to the whole result number 165 * 166 * @return Value[][] 167 */ 168 public function execute() { 169 list($sql, $opts) = $this->getSQL(); 170 171 /** @var \PDOStatement $res */ 172 $res = $this->sqlite->query($sql, $opts); 173 174 $result = array(); 175 $cursor = -1; 176 while($row = $res->fetch(\PDO::FETCH_ASSOC)) { 177 $cursor++; 178 if($cursor < $this->range_begin) continue; 179 if($this->range_end && $cursor >= $this->range_end) continue; 180 181 $C = 0; 182 $resrow = array(); 183 foreach($this->columns as $col) { 184 $val = $row["C$C"]; 185 if($col->isMulti()) { 186 $val = explode(self::CONCAT_SEPARATOR, $val); 187 } 188 $resrow[] = new Value($col, $val); 189 $C++; 190 } 191 $result[] = $resrow; 192 } 193 194 $this->sqlite->res_close($res); 195 $this->count = $cursor + 1; 196 return $result; 197 } 198 199 /** 200 * Transform the set search parameters into a statement 201 * 202 * @return array ($sql, $opts) The SQL and parameters to execute 203 */ 204 public function getSQL() { 205 if(!$this->columns) throw new StructException('nocolname'); 206 207 $from = ''; 208 $select = ''; 209 $order = ''; 210 $grouping = array(); 211 $opts = array(); 212 $where = '1 = 1'; 213 214 // basic tables 215 $first = ''; 216 foreach($this->schemas as $schema) { 217 if($first) { 218 // follow up tables 219 $from .= "\nLEFT OUTER JOIN data_{$schema->getTable()} ON data_$first.pid = data_{$schema->getTable()}.pid"; 220 } else { 221 // first table 222 $select .= "data_{$schema->getTable()}.pid as PID, "; 223 $from .= "data_{$schema->getTable()}"; 224 $first = $schema->getTable(); 225 } 226 227 $where .= "\nAND data_{$schema->getTable()}.latest = 1"; 228 } 229 230 // columns to select, handling multis 231 $sep = self::CONCAT_SEPARATOR; 232 $n = 0; 233 foreach($this->columns as $col) { 234 $CN = 'C' . $n++; 235 236 if($col->isMulti()) { 237 $tn = 'M' . $col->getColref(); 238 $select .= "GROUP_CONCAT($tn.value, '$sep') AS $CN, "; 239 $from .= "\nLEFT OUTER JOIN multi_{$col->getTable()} AS $tn"; 240 $from .= " ON data_{$col->getTable()}.pid = $tn.pid AND data_{$col->getTable()}.rev = $tn.rev"; 241 $from .= " AND $tn.colref = {$col->getColref()}\n"; 242 } else { 243 $select .= "{$col->getColName()} AS $CN, "; 244 $grouping[] = $CN; 245 } 246 } 247 $select = rtrim($select, ', '); 248 249 // where clauses 250 foreach($this->filter as $filter) { 251 list($col, $value, $comp, $type) = $filter; 252 253 /** @var $col Column */ 254 if($col->isMulti()) { 255 $tn = 'MN' . $col->getColref(); // FIXME this joins a second time if the column was selected before 256 $from .= "\nLEFT OUTER JOIN multi_{$col->getTable()} AS $tn"; 257 $from .= " ON data_{$col->getTable()}.pid = $tn.pid AND data_{$col->getTable()}.rev = $tn.rev"; 258 $from .= " AND $tn.colref = {$col->getColref()}\n"; 259 260 $column = "$tn.value"; 261 } else { 262 $column = $col->getColName(); 263 } 264 265 list($wsql, $wopt) = $col->getType()->compare($column, $comp, $value); 266 $opts = array_merge($opts, $wopt); 267 268 $where .= "\n$type $wsql"; 269 } 270 271 // sorting 272 foreach($this->sortby as $sort) { 273 list($col, $asc) = $sort; 274 275 /** @var $col Column */ 276 if($col->isMulti()) { 277 // FIXME how to sort by multival? 278 // FIXME what if sort by non merged multival? 279 } else { 280 $order .= $col->getColName() . ' '; 281 $order .= ($asc) ? 'ASC' : 'DESC'; 282 $order .= ', '; 283 } 284 } 285 $order = rtrim($order, ', '); 286 287 $sql = "SELECT $select\n FROM $from\nWHERE $where\nGROUP BY " . join(', ', $grouping); 288 if($order) $sql .= "\nORDER BY $order"; 289 290 return array($sql, $opts); 291 } 292 293 /** 294 * Find a column to be used in the search 295 * 296 * @param string $colname may contain an alias 297 * @return bool|Column 298 */ 299 protected function findColumn($colname) { 300 if(!$this->schemas) throw new StructException('noschemas'); 301 302 // handling of page column is special 303 if($colname == '%pageid%') { 304 return new PageColumn(0, new Page(), array_shift(array_keys($this->schemas))); 305 } 306 // FIXME %title% needs to be handled here, too (later) 307 308 // resolve the alias or table name 309 list($table, $colname) = explode('.', $colname, 2); 310 if(!$colname) { 311 $colname = $table; 312 $table = ''; 313 } 314 if($table && isset($this->aliases[$table])) { 315 $table = $this->aliases[$table]; 316 } 317 318 if(!$colname) throw new StructException('nocolname'); 319 320 // if table name given search only that, otherwiese try all for matching column name 321 if($table) { 322 $schemas = array($table => $this->schemas[$table]); 323 } else { 324 $schemas = $this->schemas; 325 } 326 327 // find it 328 $col = false; 329 foreach($schemas as $schema) { 330 $col = $schema->findColumn($colname); 331 if($col) break; 332 } 333 334 return $col; 335 } 336 337} 338 339 340