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 = static fn($input) => "*$input*"; 297 298 if (is_array($value)) { 299 $value = array_map($map, $value); 300 } else { 301 $value = $map($value); 302 } 303 return $value; 304 } 305 306 /** 307 * Change given string to use % instead of * 308 * 309 * @param string|string[] $value 310 * @return string|string[] 311 */ 312 protected function filterChangeToLike($value) 313 { 314 $map = static fn($input) => str_replace('*', '%', $input); 315 316 if (is_array($value)) { 317 $value = array_map($map, $value); 318 } else { 319 $value = $map($value); 320 } 321 return $value; 322 } 323 324 /** 325 * Set offset for the results 326 * 327 * @param int $offset 328 */ 329 public function setOffset($offset) 330 { 331 $limit = 0; 332 if ($this->range_end) { 333 // if there was a limit set previously, the range_end needs to be recalculated 334 $limit = $this->range_end - $this->range_begin; 335 } 336 $this->range_begin = $offset; 337 if ($limit) $this->setLimit($limit); 338 } 339 340 /** 341 * Get the current offset for the results 342 * 343 * @return int 344 */ 345 public function getOffset() 346 { 347 return $this->range_begin; 348 } 349 350 /** 351 * Limit results to this number 352 * 353 * @param int $limit Set to 0 to disable limit again 354 */ 355 public function setLimit($limit) 356 { 357 if ($limit) { 358 $this->range_end = $this->range_begin + $limit; 359 } else { 360 $this->range_end = 0; 361 } 362 } 363 364 /** 365 * Get the current limit for the results 366 * 367 * @return int 368 */ 369 public function getLimit() 370 { 371 if ($this->range_end) { 372 return $this->range_end - $this->range_begin; 373 } 374 return 0; 375 } 376 377 /** 378 * Allows disabling default 'latest = 1' clause in select statement 379 * 380 * @param bool $selectLatest 381 */ 382 public function setSelectLatest($selectLatest): void 383 { 384 $this->selectLatest = $selectLatest; 385 } 386 387 /** 388 * Return the number of results (regardless of limit and offset settings) 389 * 390 * Use this to implement paging. Important: this may only be called after running @return int 391 * @see execute() 392 * 393 */ 394 public function getCount() 395 { 396 if ($this->count < 0) throw new StructException('Count is only accessible after executing the search'); 397 return $this->count; 398 } 399 400 /** 401 * Returns the PID associated with each result row 402 * 403 * Important: this may only be called after running @return \string[] 404 * @see execute() 405 * 406 */ 407 public function getPids() 408 { 409 if ($this->result_pids === null) 410 throw new StructException('PIDs are only accessible after executing the search'); 411 return $this->result_pids; 412 } 413 414 /** 415 * Returns the rid associated with each result row 416 * 417 * Important: this may only be called after running @return array 418 * @see execute() 419 * 420 */ 421 public function getRids() 422 { 423 if ($this->result_rids === null) 424 throw new StructException('rids are only accessible after executing the search'); 425 return $this->result_rids; 426 } 427 428 /** 429 * Returns the rid associated with each result row 430 * 431 * Important: this may only be called after running @return array 432 * @see execute() 433 * 434 */ 435 public function getRevs() 436 { 437 if ($this->result_revs === null) 438 throw new StructException('revs are only accessible after executing the search'); 439 return $this->result_revs; 440 } 441 442 /** 443 * Execute this search and return the result 444 * 445 * The result is a two dimensional array of Value()s. 446 * 447 * This will always query for the full result (not using offset and limit) and then 448 * return the wanted range, setting the count (@return Value[][] 449 * @see getCount) to the whole result number 450 * 451 */ 452 public function execute() 453 { 454 [$sql, $opts] = $this->getSQL(); 455 456 /** @var \PDOStatement $res */ 457 $res = $this->sqlite->query($sql, $opts); 458 if ($res === false) throw new StructException("SQL execution failed for\n\n$sql"); 459 460 $this->result_pids = []; 461 $result = []; 462 $cursor = -1; 463 $pageidAndRevOnly = array_reduce($this->columns, static fn($pageidAndRevOnly, Column $col) => $pageidAndRevOnly && ($col->getTid() == 0), true); 464 while ($row = $res->fetch(\PDO::FETCH_ASSOC)) { 465 $cursor++; 466 if ($cursor < $this->range_begin) continue; 467 if ($this->range_end && $cursor >= $this->range_end) continue; 468 469 $C = 0; 470 $resrow = []; 471 $isempty = true; 472 foreach ($this->columns as $col) { 473 $val = $row["C$C"]; 474 if ($col->isMulti()) { 475 $val = explode(self::CONCAT_SEPARATOR, $val); 476 } 477 $value = new Value($col, $val); 478 $isempty &= $this->isEmptyValue($value); 479 $resrow[] = $value; 480 $C++; 481 } 482 483 // skip empty rows 484 if ($isempty && !$pageidAndRevOnly) { 485 $cursor--; 486 continue; 487 } 488 489 $this->result_pids[] = $row['PID']; 490 $this->result_rids[] = $row['rid']; 491 $this->result_revs[] = $row['rev']; 492 $result[] = $resrow; 493 } 494 495 $res->closeCursor(); 496 $this->count = $cursor + 1; 497 return $result; 498 } 499 500 /** 501 * Transform the set search parameters into a statement 502 * 503 * Calls runSQLBuilder() 504 * 505 * @return array ($sql, $opts) The SQL and parameters to execute 506 */ 507 public function getSQL() 508 { 509 if (!$this->columns) throw new StructException('nocolname'); 510 return $this->runSQLBuilder()->getSQL(); 511 } 512 513 /** 514 * Initialize and execute the SQLBuilder 515 * 516 * Called from getSQL(). Can be overwritten to extend the query using the query builder 517 * 518 * @return SearchSQLBuilder 519 */ 520 protected function runSQLBuilder() 521 { 522 $sqlBuilder = new SearchSQLBuilder(); 523 $sqlBuilder->setSelectLatest($this->selectLatest); 524 $sqlBuilder->addSchemas($this->schemas); 525 $sqlBuilder->addColumns($this->columns); 526 $sqlBuilder->addFilters($this->filter); 527 $sqlBuilder->addFilters($this->dynamicFilter); 528 $sqlBuilder->addSorts($this->sortby); 529 return $sqlBuilder; 530 } 531 532 /** 533 * Returns all the columns that where added to the search 534 * 535 * @return Column[] 536 */ 537 public function getColumns() 538 { 539 return $this->columns; 540 } 541 542 /** 543 * All the schemas currently added 544 * 545 * @return Schema[] 546 */ 547 public function getSchemas() 548 { 549 return array_values($this->schemas); 550 } 551 552 /** 553 * Checks if the given column is a * wildcard 554 * 555 * If it's a wildcard all matching columns are added to the column list, otherwise 556 * nothing happens 557 * 558 * @param string $colname 559 * @return bool was wildcard? 560 */ 561 protected function processWildcard($colname) 562 { 563 [$colname, $table] = $this->resolveColumn($colname); 564 if ($colname !== '*') return false; 565 566 // no table given? assume the first is meant 567 if ($table === null) { 568 $schema_list = array_keys($this->schemas); 569 $table = $schema_list[0]; 570 } 571 572 $schema = $this->schemas[$table] ?? null; 573 if (!$schema instanceof Schema) return false; 574 $this->columns = array_merge($this->columns, $schema->getColumns(false)); 575 return true; 576 } 577 578 /** 579 * Split a given column name into table and column 580 * 581 * Handles Aliases. Table might be null if none given. 582 * 583 * @param $colname 584 * @return array (colname, table) 585 */ 586 protected function resolveColumn($colname) 587 { 588 if (!$this->schemas) throw new StructException('noschemas'); 589 590 // resolve the alias or table name 591 [$table, $colname] = sexplode('.', $colname, 2, ''); 592 if (!$colname) { 593 $colname = $table; 594 $table = null; 595 } 596 if ($table && isset($this->aliases[$table])) { 597 $table = $this->aliases[$table]; 598 } 599 600 if (!$colname) throw new StructException('nocolname'); 601 602 return [$colname, $table]; 603 } 604 605 /** 606 * Find a column to be used in the search 607 * 608 * @param string $colname may contain an alias 609 * @return bool|Column 610 */ 611 public function findColumn($colname, $strict = false) 612 { 613 if (!$this->schemas) throw new StructException('noschemas'); 614 $schema_list = array_keys($this->schemas); 615 616 // add "fake" column for special col 617 if ($colname == '%pageid%') { 618 return new PageColumn(0, new Page(), $schema_list[0]); 619 } 620 if ($colname == '%title%') { 621 return new PageColumn(0, new Page(['usetitles' => true]), $schema_list[0]); 622 } 623 if ($colname == '%lastupdate%') { 624 return new RevisionColumn(0, new DateTime(), $schema_list[0]); 625 } 626 if ($colname == '%lasteditor%') { 627 return new UserColumn(0, new User(), $schema_list[0]); 628 } 629 if ($colname == '%lastsummary%') { 630 return new SummaryColumn(0, new AutoSummary(), $schema_list[0]); 631 } 632 if ($colname == '%rowid%') { 633 return new RowColumn(0, new Decimal(), $schema_list[0]); 634 } 635 if ($colname == '%published%') { 636 return new PublishedColumn(0, new Decimal(), $schema_list[0]); 637 } 638 639 [$colname, $table] = $this->resolveColumn($colname); 640 641 /* 642 * If table name is given search only that, otherwise if no strict behavior 643 * is requested by the caller, try all assigned schemas for matching the 644 * column name. 645 */ 646 if ($table !== null && isset($this->schemas[$table])) { 647 $schemas = [$table => $this->schemas[$table]]; 648 } elseif ($table === null || !$strict) { 649 $schemas = $this->schemas; 650 } else { 651 return false; 652 } 653 654 // find it 655 $col = false; 656 foreach ($schemas as $schema) { 657 if (empty($schema)) { 658 continue; 659 } 660 $col = $schema->findColumn($colname); 661 if ($col) break; 662 } 663 664 return $col; 665 } 666 667 /** 668 * Check if the given row is empty or references our own row 669 * 670 * @param Value $value 671 * @return bool 672 */ 673 protected function isEmptyValue(Value $value) 674 { 675 if ($value->isEmpty()) return true; 676 if ($value->getColumn()->getTid() == 0) return true; 677 return false; 678 } 679} 680