1<?php 2 3namespace dokuwiki\Search\Query; 4 5use dokuwiki\Utf8\PhpString; 6use dokuwiki\Extension\Event; 7use dokuwiki\Search\Collection\Term; 8use dokuwiki\Search\Indexer; 9use dokuwiki\Utf8; 10 11/** 12 * Evaluates a parsed search query against word lookup results 13 * 14 * Uses typed stack entries (PageSet, NamespacePredicate, NegatedEntry) 15 * to avoid materializing the full page index unless absolutely necessary 16 * (standalone NOT or namespace-only queries). 17 */ 18class QueryEvaluator 19{ 20 /** @var string[] RPN token array from QueryParser */ 21 protected array $rpn; 22 23 /** @var Term[] word => Term mapping from CollectionSearch */ 24 protected array $terms; 25 26 /** @var PageSet|null lazy-loaded universe of all indexed pages */ 27 protected ?PageSet $allPages = null; 28 29 /** 30 * @param string[] $rpn RPN token array from QueryParser::convert()['parsed_ary'] 31 * @param Term[] $terms word => Term mapping from CollectionSearch::execute() 32 */ 33 public function __construct(array $rpn, array $terms) 34 { 35 $this->rpn = $rpn; 36 $this->terms = $terms; 37 } 38 39 /** 40 * Evaluate the RPN query and return matching pages with scores 41 * 42 * The query is represented in Reverse Polish Notation (RPN), also known as postfix 43 * notation. In RPN, operands come first and operators follow. For example, the infix 44 * expression "A AND B" becomes "A B AND" in RPN. This eliminates the need for 45 * parentheses — the expression "(A OR B) AND C" is simply "A B OR C AND". 46 * 47 * Evaluation uses a stack. Each token is processed left to right: operand tokens 48 * (words, phrases, namespaces) push an entry onto the stack; operator tokens (AND, 49 * OR, NOT) pop their operands from the stack, compute a result, and push it back. 50 * After all tokens are processed, the single remaining stack entry is the final result. 51 * 52 * The stack entries are typed: word lookups produce PageSet entries (concrete page 53 * results with scores), namespace tokens produce NamespacePredicate entries (a filter 54 * to be applied later), and NOT wraps its operand in a NegatedEntry. Binary operators 55 * inspect the types of their operands to choose the most efficient operation — for 56 * example, AND with a NegatedEntry performs set subtraction rather than requiring 57 * the full page universe. 58 * 59 * @return array<string, int> page ID => score 60 */ 61 public function evaluate(): array 62 { 63 /** @var StackEntry[] $stack */ 64 $stack = []; 65 66 foreach ($this->rpn as $token) { 67 switch (substr($token, 0, 3)) { 68 case 'W+:': 69 case 'W-:': 70 case 'W_:': 71 $word = substr($token, 3); 72 if (isset($this->terms[$word])) { 73 $stack[] = new PageSet($this->terms[$word]->getEntityFrequencies()); 74 } 75 break; 76 77 case 'P+:': 78 case 'P-:': 79 $phrase = substr($token, 3); 80 // Phrases are always preceded by their component words AND'd together, 81 // so the top of stack contains pages matching all words in the phrase. 82 // We verify the actual phrase exists in those candidate pages. 83 $candidates = end($stack) ?: new PageSet(); 84 $stack[] = $this->matchPhrase($phrase, $this->materialize($candidates)); 85 break; 86 87 case 'N+:': 88 case 'N-:': 89 $ns = cleanID(substr($token, 3)) . ':'; 90 $stack[] = new NamespacePredicate($ns); 91 break; 92 93 case 'AND': 94 $right = array_pop($stack); 95 $left = array_pop($stack); 96 if (!$left && !$right) break; 97 if (!$left) { 98 $stack[] = $right; 99 break; 100 } 101 if (!$right) { 102 $stack[] = $left; 103 break; 104 } 105 $stack[] = $this->opAnd($left, $right); 106 break; 107 108 case 'OR': 109 $right = array_pop($stack); 110 $left = array_pop($stack); 111 if (!$left && !$right) break; 112 if (!$left) { 113 $stack[] = $right; 114 break; 115 } 116 if (!$right) { 117 $stack[] = $left; 118 break; 119 } 120 $stack[] = $this->opOr($left, $right); 121 break; 122 123 case 'NOT': 124 $operand = array_pop($stack); 125 if (!$operand) break; 126 $stack[] = new NegatedEntry($operand); 127 break; 128 } 129 } 130 131 $result = array_pop($stack) ?? new PageSet(); 132 return $this->materialize($result)->getPages(); 133 } 134 135 // region Operators 136 137 /** 138 * AND: combine two operands based on their types 139 * 140 * PageSet AND PageSet produces an intersection with summed scores. When one 141 * operand is a NegatedEntry, the operation becomes set subtraction. When one 142 * operand is a NamespacePredicate, the other is filtered by namespace prefix. 143 * 144 * @param StackEntry $left 145 * @param StackEntry $right 146 * @return StackEntry 147 */ 148 protected function opAnd(StackEntry $left, StackEntry $right): StackEntry 149 { 150 // page set AND negated → subtract 151 if ($left instanceof PageSet && $right instanceof NegatedEntry) { 152 return $this->subtractNegated($left, $right); 153 } 154 if ($left instanceof NegatedEntry && $right instanceof PageSet) { 155 return $this->subtractNegated($right, $left); 156 } 157 158 // page set AND namespace → filter by prefix 159 if ($left instanceof PageSet && $right instanceof NamespacePredicate) { 160 return $right->filter($left); 161 } 162 if ($left instanceof NamespacePredicate && $right instanceof PageSet) { 163 return $left->filter($right); 164 } 165 166 // page set AND page set → intersect, sum scores 167 if ($left instanceof PageSet && $right instanceof PageSet) { 168 return $left->intersect($right); 169 } 170 171 // rare cases (negated AND negated, namespace AND namespace, etc.) 172 return $this->materialize($left)->intersect($this->materialize($right)); 173 } 174 175 /** 176 * OR: unite two operands 177 * 178 * PageSet OR PageSet produces a union with summed scores. Other combinations 179 * require materializing operands into concrete page sets first. 180 * 181 * @param StackEntry $left 182 * @param StackEntry $right 183 * @return StackEntry 184 */ 185 protected function opOr(StackEntry $left, StackEntry $right): StackEntry 186 { 187 if ($left instanceof PageSet && $right instanceof PageSet) { 188 return $left->unite($right); 189 } 190 191 return $this->materialize($left)->unite($this->materialize($right)); 192 } 193 194 /** 195 * Subtract a NegatedEntry from a PageSet 196 * 197 * The inner entry of the NegatedEntry determines the operation: 198 * - NegatedEntry(PageSet): set subtraction 199 * - NegatedEntry(NamespacePredicate): exclude pages matching the namespace 200 * 201 * @param PageSet $pages the positive page set 202 * @param NegatedEntry $negated the negated operand 203 * @return PageSet 204 */ 205 protected function subtractNegated(PageSet $pages, NegatedEntry $negated): PageSet 206 { 207 $inner = $negated->getInner(); 208 209 if ($inner instanceof NamespacePredicate) { 210 return $inner->exclude($pages); 211 } 212 213 return $pages->subtract($this->materialize($inner)); 214 } 215 216 // endregion 217 218 // region Phrase matching 219 220 /** 221 * Check which pages from the candidate set contain the given phrase 222 * 223 * Verifies phrase presence by reading each page's raw wiki text. 224 * Plugins can override phrase matching via the FULLTEXT_PHRASE_MATCH event. 225 * Pages that match retain their original scores from the candidate set. 226 * 227 * @param string $phrase the phrase to search for 228 * @param PageSet $candidates pages to check (typically the AND'd word results) 229 * @return PageSet pages where the phrase was found, with original scores preserved 230 */ 231 protected function matchPhrase(string $phrase, PageSet $candidates): PageSet 232 { 233 $matched = []; 234 foreach ($candidates->getPages() as $id => $score) { 235 $evdata = [ 236 'id' => $id, 237 'phrase' => $phrase, 238 'text' => rawWiki($id), 239 ]; 240 $event = new Event('FULLTEXT_PHRASE_MATCH', $evdata); 241 if ($event->advise_before() && $event->result !== true) { 242 $text = PhpString::strtolower($evdata['text']); 243 if (str_contains($text, $phrase)) { 244 $event->result = true; 245 } 246 } 247 $event->advise_after(); 248 if ($event->result === true) { 249 $matched[$id] = $score; 250 } 251 } 252 return new PageSet($matched); 253 } 254 255 // endregion 256 257 // region Materialization 258 259 /** 260 * Convert any StackEntry into a concrete PageSet 261 * 262 * For PageSet entries, returns as-is. NamespacePredicate and NegatedEntry 263 * cannot be resolved without knowing all existing pages — a namespace 264 * predicate needs the full page list to find matching pages, and a negated 265 * entry needs it to compute the complement. This is why materialization 266 * triggers a lazy-load of the full page index from disk. 267 * 268 * This is only needed for standalone namespace or negative-only queries 269 * (e.g., just "@wiki:" or just "-foo"). In combined queries, the typed 270 * operators handle these entries without the universe. 271 * 272 * @param StackEntry $entry 273 * @return PageSet 274 */ 275 protected function materialize(StackEntry $entry): PageSet 276 { 277 if ($entry instanceof PageSet) { 278 return $entry; 279 } 280 281 if ($entry instanceof NegatedEntry) { 282 return $this->getAllPages()->subtract($this->materialize($entry->getInner())); 283 } 284 285 if ($entry instanceof NamespacePredicate) { 286 return $entry->filter($this->getAllPages()); 287 } 288 289 return new PageSet(); 290 } 291 292 /** 293 * Lazy-load the universe of all indexed pages 294 * 295 * @return PageSet all pages with score 0 296 */ 297 protected function getAllPages(): PageSet 298 { 299 if (!$this->allPages instanceof PageSet) { 300 $pages = (new Indexer())->getAllPages(); 301 $this->allPages = new PageSet(array_fill_keys($pages, 0)); 302 } 303 return $this->allPages; 304 } 305 306 // endregion 307} 308