1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5use dokuwiki\plugin\struct\types\AutoSummary; 6use dokuwiki\plugin\struct\types\DateTime; 7use dokuwiki\plugin\struct\types\Decimal; 8use dokuwiki\plugin\struct\types\Page; 9use dokuwiki\plugin\struct\types\User; 10 11class Search 12{ 13 /** 14 * This separator will be used to concat multi values to flatten them in the result set 15 */ 16 public const CONCAT_SEPARATOR = "\n!_-_-_-_-_!\n"; 17 18 /** 19 * The list of known and allowed comparators 20 * (order matters) 21 */ 22 public static $COMPARATORS = array( 23 '<=', '>=', '=*', '=', '<', '>', '!=', '!~', '~', ' IN ' 24 ); 25 26 /** @var \helper_plugin_struct_db */ 27 protected $dbHelper; 28 29 /** @var \helper_plugin_sqlite */ 30 protected $sqlite; 31 32 /** @var Schema[] list of schemas to query */ 33 protected $schemas = array(); 34 35 /** @var Column[] list of columns to select */ 36 protected $columns = array(); 37 38 /** @var array the sorting of the result */ 39 protected $sortby = array(); 40 41 /** @var array the filters */ 42 protected $filter = array(); 43 44 /** @var array the filters */ 45 protected $dynamicFilter = array(); 46 47 /** @var array list of aliases tables can be referenced by */ 48 protected $aliases = array(); 49 50 /** @var int begin results from here */ 51 protected $range_begin = 0; 52 53 /** @var int end results here */ 54 protected $range_end = 0; 55 56 /** @var int the number of results */ 57 protected $count = -1; 58 /** @var string[] the PIDs of the result rows */ 59 protected $result_pids = null; 60 /** @var array the row ids of the result rows */ 61 protected $result_rids = []; 62 /** @var array the revisions of the result rows */ 63 protected $result_revs = []; 64 65 /** @var bool Include latest = 1 in select query */ 66 protected $selectLatest = true; 67 68 /** 69 * Search constructor. 70 */ 71 public function __construct() 72 { 73 /** @var $dbHelper */ 74 $this->dbHelper = plugin_load('helper', 'struct_db'); 75 $this->sqlite = $this->dbHelper->getDB(); 76 } 77 78 public function getDb() 79 { 80 return $this->sqlite; 81 } 82 83 /** 84 * Add a schema to be searched 85 * 86 * Call multiple times for multiple schemas. 87 * 88 * @param string $table 89 * @param string $alias 90 */ 91 public function addSchema($table, $alias = '') 92 { 93 $schema = new Schema($table); 94 if (!$schema->getId()) { 95 throw new StructException('schema missing', $table); 96 } 97 98 $this->schemas[$schema->getTable()] = $schema; 99 if ($alias) $this->aliases[$alias] = $schema->getTable(); 100 } 101 102 /** 103 * Add a column to be returned by the search 104 * 105 * Call multiple times for multiple columns. Be sure the referenced tables have been 106 * added before 107 * 108 * @param string $colname may contain an alias 109 */ 110 public function addColumn($colname) 111 { 112 if ($this->processWildcard($colname)) return; // wildcard? 113 if ($colname[0] == '-') { // remove column from previous wildcard lookup 114 $colname = substr($colname, 1); 115 foreach ($this->columns as $key => $col) { 116 if ($col->getLabel() == $colname) unset($this->columns[$key]); 117 } 118 return; 119 } 120 121 $col = $this->findColumn($colname); 122 if (!$col) return; //FIXME do we really want to ignore missing columns? 123 $this->columns[] = $col; 124 } 125 126 /** 127 * Add sorting options 128 * 129 * Call multiple times for multiple columns. Be sure the referenced tables have been 130 * added before 131 * 132 * @param string $colname may contain an alias 133 * @param bool $asc sort direction (ASC = true, DESC = false) 134 * @param bool $nc set true for caseinsensitivity 135 */ 136 public function addSort($colname, $asc = true, $nc = true) 137 { 138 $col = $this->findColumn($colname); 139 if (!$col) return; //FIXME do we really want to ignore missing columns? 140 141 $this->sortby[$col->getFullQualifiedLabel()] = array($col, $asc, $nc); 142 } 143 144 /** 145 * Clear all sorting options 146 * 147 * @return void 148 */ 149 public function clearSort() 150 { 151 $this->sortby = []; 152 } 153 154 /** 155 * Returns all set sort columns 156 * 157 * @return array 158 */ 159 public function getSorts() 160 { 161 return $this->sortby; 162 } 163 164 /** 165 * Adds a filter 166 * 167 * @param string $colname may contain an alias 168 * @param string|string[] $value 169 * @param string $comp @see self::COMPARATORS 170 * @param string $op either 'OR' or 'AND' 171 */ 172 public function addFilter($colname, $value, $comp, $op = 'OR') 173 { 174 $filter = $this->createFilter($colname, $value, $comp, $op); 175 if ($filter) $this->filter[] = $filter; 176 } 177 178 /** 179 * Adds a dynamic filter 180 * 181 * @param string $colname may contain an alias 182 * @param string|string[] $value 183 * @param string $comp @see self::COMPARATORS 184 * @param string $op either 'OR' or 'AND' 185 */ 186 public function addDynamicFilter($colname, $value, $comp, $op = 'OR') 187 { 188 $filter = $this->createFilter($colname, $value, $comp, $op); 189 if ($filter) $this->dynamicFilter[] = $filter; 190 } 191 192 /** 193 * Create a filter definition 194 * 195 * @param string $colname may contain an alias 196 * @param string|string[] $value 197 * @param string $comp @see self::COMPARATORS 198 * @param string $op either 'OR' or 'AND' 199 * @return array|null [Column col, string|string[] value, string comp, string op] 200 */ 201 protected function createFilter($colname, $value, $comp, $op = 'OR') 202 { 203 /* Convert certain filters into others 204 * this reduces the number of supported filters to implement in types */ 205 if ($comp == '*~') { 206 $value = $this->filterWrapAsterisks($value); 207 $comp = '~'; 208 } elseif ($comp == '<>') { 209 $comp = '!='; 210 } 211 212 if (!in_array($comp, self::$COMPARATORS)) 213 throw new StructException("Bad comperator. Use " . join(',', self::$COMPARATORS)); 214 if ($op != 'OR' && $op != 'AND') 215 throw new StructException('Bad filter type . Only AND or OR allowed'); 216 217 $col = $this->findColumn($colname); 218 if (!$col) return null; // ignore missing columns, filter might have been for different schema 219 220 // map filter operators to SQL syntax 221 switch ($comp) { 222 case '~': 223 $comp = 'LIKE'; 224 break; 225 case '!~': 226 $comp = 'NOT LIKE'; 227 break; 228 case '=*': 229 $comp = 'REGEXP'; 230 break; 231 } 232 233 // we use asterisks, but SQL wants percents 234 if ($comp == 'LIKE' || $comp == 'NOT LIKE') { 235 $value = $this->filterChangeToLike($value); 236 } 237 238 if ($comp == ' IN ' && !is_array($value)) { 239 $value = $this->parseFilterValueList($value); 240 //col IN ('a', 'b', 'c') is equal to col = 'a' OR 'col = 'b' OR col = 'c' 241 $comp = '='; 242 } 243 244 // add the filter 245 return array($col, $value, $comp, $op); 246 } 247 248 /** 249 * Parse SQLite row value into array 250 * 251 * @param string $value 252 * @return string[] 253 */ 254 protected function parseFilterValueList($value) 255 { 256 $Handler = new FilterValueListHandler(); 257 $LexerClass = class_exists('\Doku_Lexer') ? '\Doku_Lexer' : '\dokuwiki\Parsing\Lexer\Lexer'; 258 $isLegacy = $LexerClass === '\Doku_Lexer'; 259 /** @var \Doku_Lexer|\dokuwiki\Parsing\Lexer\Lexer $Lexer */ 260 $Lexer = new $LexerClass($Handler, 'base', true); 261 262 263 $Lexer->addEntryPattern('\(', 'base', 'row'); 264 $Lexer->addPattern('\s*,\s*', 'row'); 265 $Lexer->addExitPattern('\)', 'row'); 266 267 $Lexer->addEntryPattern('"', 'row', 'double_quote_string'); 268 $Lexer->addSpecialPattern('\\\\"', 'double_quote_string', 'escapeSequence'); 269 $Lexer->addExitPattern('"', 'double_quote_string'); 270 271 $Lexer->addEntryPattern("'", 'row', 'singleQuoteString'); 272 $Lexer->addSpecialPattern("\\\\'", 'singleQuoteString', 'escapeSequence'); 273 $Lexer->addExitPattern("'", 'singleQuoteString'); 274 275 $Lexer->mapHandler('double_quote_string', 'singleQuoteString'); 276 277 $Lexer->addSpecialPattern('[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?', 'row', 'number'); 278 279 $res = $Lexer->parse($value); 280 281 $currentMode = $isLegacy ? $Lexer->_mode->getCurrent() : $Lexer->getModeStack()->getCurrent(); 282 if (!$res || $currentMode != 'base') { 283 throw new StructException('invalid row value syntax'); 284 } 285 286 return $Handler->getRow(); 287 } 288 289 /** 290 * Wrap given value in asterisks 291 * 292 * @param string|string[] $value 293 * @return string|string[] 294 */ 295 protected function filterWrapAsterisks($value) 296 { 297 $map = function ($input) { 298 return "*$input*"; 299 }; 300 301 if (is_array($value)) { 302 $value = array_map($map, $value); 303 } else { 304 $value = $map($value); 305 } 306 return $value; 307 } 308 309 /** 310 * Change given string to use % instead of * 311 * 312 * @param string|string[] $value 313 * @return string|string[] 314 */ 315 protected function filterChangeToLike($value) 316 { 317 $map = function ($input) { 318 return str_replace('*', '%', $input); 319 }; 320 321 if (is_array($value)) { 322 $value = array_map($map, $value); 323 } else { 324 $value = $map($value); 325 } 326 return $value; 327 } 328 329 /** 330 * Set offset for the results 331 * 332 * @param int $offset 333 */ 334 public function setOffset($offset) 335 { 336 $limit = 0; 337 if ($this->range_end) { 338 // if there was a limit set previously, the range_end needs to be recalculated 339 $limit = $this->range_end - $this->range_begin; 340 } 341 $this->range_begin = $offset; 342 if ($limit) $this->setLimit($limit); 343 } 344 345 /** 346 * Get the current offset for the results 347 * 348 * @return int 349 */ 350 public function getOffset() 351 { 352 return $this->range_begin; 353 } 354 355 /** 356 * Limit results to this number 357 * 358 * @param int $limit Set to 0 to disable limit again 359 */ 360 public function setLimit($limit) 361 { 362 if ($limit) { 363 $this->range_end = $this->range_begin + $limit; 364 } else { 365 $this->range_end = 0; 366 } 367 } 368 369 /** 370 * Get the current limit for the results 371 * 372 * @return int 373 */ 374 public function getLimit() 375 { 376 if ($this->range_end) { 377 return $this->range_end - $this->range_begin; 378 } 379 return 0; 380 } 381 382 /** 383 * Allows disabling default 'latest = 1' clause in select statement 384 * 385 * @param bool $selectLatest 386 */ 387 public function setSelectLatest($selectLatest): void 388 { 389 $this->selectLatest = $selectLatest; 390 } 391 392 /** 393 * Return the number of results (regardless of limit and offset settings) 394 * 395 * Use this to implement paging. Important: this may only be called after running @return int 396 * @see execute() 397 * 398 */ 399 public function getCount() 400 { 401 if ($this->count < 0) throw new StructException('Count is only accessible after executing the search'); 402 return $this->count; 403 } 404 405 /** 406 * Returns the PID associated with each result row 407 * 408 * Important: this may only be called after running @return \string[] 409 * @see execute() 410 * 411 */ 412 public function getPids() 413 { 414 if ($this->result_pids === null) 415 throw new StructException('PIDs are only accessible after executing the search'); 416 return $this->result_pids; 417 } 418 419 /** 420 * Returns the rid associated with each result row 421 * 422 * Important: this may only be called after running @return array 423 * @see execute() 424 * 425 */ 426 public function getRids() 427 { 428 if ($this->result_rids === null) 429 throw new StructException('rids are only accessible after executing the search'); 430 return $this->result_rids; 431 } 432 433 /** 434 * Returns the rid associated with each result row 435 * 436 * Important: this may only be called after running @return array 437 * @see execute() 438 * 439 */ 440 public function getRevs() 441 { 442 if ($this->result_revs === null) 443 throw new StructException('revs are only accessible after executing the search'); 444 return $this->result_revs; 445 } 446 447 /** 448 * Execute this search and return the result 449 * 450 * The result is a two dimensional array of Value()s. 451 * 452 * This will always query for the full result (not using offset and limit) and then 453 * return the wanted range, setting the count (@return Value[][] 454 * @see getCount) to the whole result number 455 * 456 */ 457 public function execute() 458 { 459 list($sql, $opts) = $this->getSQL(); 460 461 /** @var \PDOStatement $res */ 462 $res = $this->sqlite->query($sql, $opts); 463 if ($res === false) throw new StructException("SQL execution failed for\n\n$sql"); 464 465 $this->result_pids = array(); 466 $result = array(); 467 $cursor = -1; 468 $pageidAndRevOnly = array_reduce($this->columns, function ($pageidAndRevOnly, Column $col) { 469 return $pageidAndRevOnly && ($col->getTid() == 0); 470 }, true); 471 while ($row = $res->fetch(\PDO::FETCH_ASSOC)) { 472 $cursor++; 473 if ($cursor < $this->range_begin) continue; 474 if ($this->range_end && $cursor >= $this->range_end) continue; 475 476 $C = 0; 477 $resrow = array(); 478 $isempty = true; 479 foreach ($this->columns as $col) { 480 $val = $row["C$C"]; 481 if ($col->isMulti()) { 482 $val = explode(self::CONCAT_SEPARATOR, $val); 483 } 484 $value = new Value($col, $val); 485 $isempty &= $this->isEmptyValue($value); 486 $resrow[] = $value; 487 $C++; 488 } 489 490 // skip empty rows 491 if ($isempty && !$pageidAndRevOnly) { 492 $cursor--; 493 continue; 494 } 495 496 $this->result_pids[] = $row['PID']; 497 $this->result_rids[] = $row['rid']; 498 $this->result_revs[] = $row['rev']; 499 $result[] = $resrow; 500 } 501 502 $res->closeCursor(); 503 $this->count = $cursor + 1; 504 return $result; 505 } 506 507 /** 508 * Transform the set search parameters into a statement 509 * 510 * Calls runSQLBuilder() 511 * 512 * @return array ($sql, $opts) The SQL and parameters to execute 513 */ 514 public function getSQL() 515 { 516 if (!$this->columns) throw new StructException('nocolname'); 517 return $this->runSQLBuilder()->getSQL(); 518 } 519 520 /** 521 * Initialize and execute the SQLBuilder 522 * 523 * Called from getSQL(). Can be overwritten to extend the query using the query builder 524 * 525 * @return SearchSQLBuilder 526 */ 527 protected function runSQLBuilder() 528 { 529 $sqlBuilder = new SearchSQLBuilder(); 530 $sqlBuilder->setSelectLatest($this->selectLatest); 531 $sqlBuilder->addSchemas($this->schemas); 532 $sqlBuilder->addColumns($this->columns); 533 $sqlBuilder->addFilters($this->filter); 534 $sqlBuilder->addFilters($this->dynamicFilter); 535 $sqlBuilder->addSorts($this->sortby); 536 return $sqlBuilder; 537 } 538 539 /** 540 * Returns all the columns that where added to the search 541 * 542 * @return Column[] 543 */ 544 public function getColumns() 545 { 546 return $this->columns; 547 } 548 549 /** 550 * All the schemas currently added 551 * 552 * @return Schema[] 553 */ 554 public function getSchemas() 555 { 556 return array_values($this->schemas); 557 } 558 559 /** 560 * Checks if the given column is a * wildcard 561 * 562 * If it's a wildcard all matching columns are added to the column list, otherwise 563 * nothing happens 564 * 565 * @param string $colname 566 * @return bool was wildcard? 567 */ 568 protected function processWildcard($colname) 569 { 570 list($colname, $table) = $this->resolveColumn($colname); 571 if ($colname !== '*') return false; 572 573 // no table given? assume the first is meant 574 if ($table === null) { 575 $schema_list = array_keys($this->schemas); 576 $table = $schema_list[0]; 577 } 578 579 $schema = $this->schemas[$table] ?? null; 580 if (!$schema) return false; 581 $this->columns = array_merge($this->columns, $schema->getColumns(false)); 582 return true; 583 } 584 585 /** 586 * Split a given column name into table and column 587 * 588 * Handles Aliases. Table might be null if none given. 589 * 590 * @param $colname 591 * @return array (colname, table) 592 */ 593 protected function resolveColumn($colname) 594 { 595 if (!$this->schemas) throw new StructException('noschemas'); 596 597 // resolve the alias or table name 598 @list($table, $colname) = array_pad(explode('.', $colname, 2), 2, ''); 599 if (!$colname) { 600 $colname = $table; 601 $table = null; 602 } 603 if ($table && isset($this->aliases[$table])) { 604 $table = $this->aliases[$table]; 605 } 606 607 if (!$colname) throw new StructException('nocolname'); 608 609 return array($colname, $table); 610 } 611 612 /** 613 * Find a column to be used in the search 614 * 615 * @param string $colname may contain an alias 616 * @return bool|Column 617 */ 618 public function findColumn($colname, $strict = false) 619 { 620 if (!$this->schemas) throw new StructException('noschemas'); 621 $schema_list = array_keys($this->schemas); 622 623 // add "fake" column for special col 624 if ($colname == '%pageid%') { 625 return new PageColumn(0, new Page(), $schema_list[0]); 626 } 627 if ($colname == '%title%') { 628 return new PageColumn(0, new Page(array('usetitles' => true)), $schema_list[0]); 629 } 630 if ($colname == '%lastupdate%') { 631 return new RevisionColumn(0, new DateTime(), $schema_list[0]); 632 } 633 if ($colname == '%lasteditor%') { 634 return new UserColumn(0, new User(), $schema_list[0]); 635 } 636 if ($colname == '%lastsummary%') { 637 return new SummaryColumn(0, new AutoSummary(), $schema_list[0]); 638 } 639 if ($colname == '%rowid%') { 640 return new RowColumn(0, new Decimal(), $schema_list[0]); 641 } 642 if ($colname == '%published%') { 643 return new PublishedColumn(0, new Decimal(), $schema_list[0]); 644 } 645 646 list($colname, $table) = $this->resolveColumn($colname); 647 648 /* 649 * If table name is given search only that, otherwise if no strict behavior 650 * is requested by the caller, try all assigned schemas for matching the 651 * column name. 652 */ 653 if ($table !== null && isset($this->schemas[$table])) { 654 $schemas = array($table => $this->schemas[$table]); 655 } elseif ($table === null || !$strict) { 656 $schemas = $this->schemas; 657 } else { 658 return false; 659 } 660 661 // find it 662 $col = false; 663 foreach ($schemas as $schema) { 664 if (empty($schema)) { 665 continue; 666 } 667 $col = $schema->findColumn($colname); 668 if ($col) break; 669 } 670 671 return $col; 672 } 673 674 /** 675 * Check if the given row is empty or references our own row 676 * 677 * @param Value $value 678 * @return bool 679 */ 680 protected function isEmptyValue(Value $value) 681 { 682 if ($value->isEmpty()) return true; 683 if ($value->getColumn()->getTid() == 0) return true; 684 return false; 685 } 686} 687