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