10b1bbbbbSAndreas Gohr<?php 20b1bbbbbSAndreas Gohr 30b1bbbbbSAndreas Gohrnamespace dokuwiki\Search\Query; 40b1bbbbbSAndreas Gohr 50b1bbbbbSAndreas Gohruse dokuwiki\Search\Tokenizer; 6*9369b4a9SAndreas Gohruse dokuwiki\Utf8\Asian; 7*9369b4a9SAndreas Gohruse dokuwiki\Utf8\PhpString; 80b1bbbbbSAndreas Gohr 90b1bbbbbSAndreas Gohr/** 100b1bbbbbSAndreas Gohr * DokuWuki QueryParser class 110b1bbbbbSAndreas Gohr */ 120b1bbbbbSAndreas Gohrclass QueryParser 130b1bbbbbSAndreas Gohr{ 140b1bbbbbSAndreas Gohr /** 150b1bbbbbSAndreas Gohr * Transforms given search term into intermediate representation 160b1bbbbbSAndreas Gohr * 170b1bbbbbSAndreas Gohr * This function is used in QueryParser::convert() and not for general purpose use. 180b1bbbbbSAndreas Gohr * 190b1bbbbbSAndreas Gohr * @param string $term 200b1bbbbbSAndreas Gohr * @param bool $consider_asian 210b1bbbbbSAndreas Gohr * @param bool $phrase_mode 220b1bbbbbSAndreas Gohr * @return string 23*9369b4a9SAndreas Gohr * @author Kazutaka Miyasaka <kazmiya@gmail.com> 24*9369b4a9SAndreas Gohr * 250b1bbbbbSAndreas Gohr */ 26*9369b4a9SAndreas Gohr public function termParser(string $term, bool $consider_asian = true, bool $phrase_mode = false): string 270b1bbbbbSAndreas Gohr { 280b1bbbbbSAndreas Gohr $parsed = ''; 290b1bbbbbSAndreas Gohr if ($consider_asian) { 300b1bbbbbSAndreas Gohr // successive asian characters need to be searched as a phrase 31*9369b4a9SAndreas Gohr $words = Asian::splitAsianWords($term); 320b1bbbbbSAndreas Gohr foreach ($words as $word) { 33*9369b4a9SAndreas Gohr $phrase_mode = $phrase_mode || Asian::isAsianWords($word); 340b1bbbbbSAndreas Gohr $parsed .= $this->termParser($word, false, $phrase_mode); 350b1bbbbbSAndreas Gohr } 360b1bbbbbSAndreas Gohr } else { 370b1bbbbbSAndreas Gohr $term_noparen = str_replace(['(', ')'], ' ', $term); 380b1bbbbbSAndreas Gohr $words = Tokenizer::getWords($term_noparen, true); 390b1bbbbbSAndreas Gohr 400b1bbbbbSAndreas Gohr // W_: no need to highlight 41*9369b4a9SAndreas Gohr if ($words === []) { 420b1bbbbbSAndreas Gohr $parsed = '()'; // important: do not remove 430b1bbbbbSAndreas Gohr } elseif ($words[0] === $term) { 440b1bbbbbSAndreas Gohr $parsed = '(W+:' . $words[0] . ')'; 450b1bbbbbSAndreas Gohr } elseif ($phrase_mode) { 460b1bbbbbSAndreas Gohr $term_encoded = str_replace(['(', ')'], ['OP', 'CP'], $term); 470b1bbbbbSAndreas Gohr $parsed = '((W_:' . implode(')(W_:', $words) . ')(P+:' . $term_encoded . '))'; 480b1bbbbbSAndreas Gohr } else { 490b1bbbbbSAndreas Gohr $parsed = '((W+:' . implode(')(W+:', $words) . '))'; 500b1bbbbbSAndreas Gohr } 510b1bbbbbSAndreas Gohr } 520b1bbbbbSAndreas Gohr return $parsed; 530b1bbbbbSAndreas Gohr } 540b1bbbbbSAndreas Gohr 550b1bbbbbSAndreas Gohr /** 560b1bbbbbSAndreas Gohr * Parses a search query and builds an array of search formulas 570b1bbbbbSAndreas Gohr * 58*9369b4a9SAndreas Gohr * @param string $query search query 59*9369b4a9SAndreas Gohr * @return array of search formulas 600b1bbbbbSAndreas Gohr * @author Andreas Gohr <andi@splitbrain.org> 610b1bbbbbSAndreas Gohr * @author Kazutaka Miyasaka <kazmiya@gmail.com> 620b1bbbbbSAndreas Gohr * 630b1bbbbbSAndreas Gohr */ 64*9369b4a9SAndreas Gohr public function convert(string $query): array 650b1bbbbbSAndreas Gohr { 660b1bbbbbSAndreas Gohr /** 670b1bbbbbSAndreas Gohr * parse a search query and transform it into intermediate representation 680b1bbbbbSAndreas Gohr * 690b1bbbbbSAndreas Gohr * in a search query, you can use the following expressions: 700b1bbbbbSAndreas Gohr * 710b1bbbbbSAndreas Gohr * words: 720b1bbbbbSAndreas Gohr * include 730b1bbbbbSAndreas Gohr * -exclude 740b1bbbbbSAndreas Gohr * phrases: 750b1bbbbbSAndreas Gohr * "phrase to be included" 760b1bbbbbSAndreas Gohr * -"phrase you want to exclude" 770b1bbbbbSAndreas Gohr * namespaces: 780b1bbbbbSAndreas Gohr * @include:namespace (or ns:include:namespace) 790b1bbbbbSAndreas Gohr * ^exclude:namespace (or -ns:exclude:namespace) 800b1bbbbbSAndreas Gohr * groups: 810b1bbbbbSAndreas Gohr * () 820b1bbbbbSAndreas Gohr * -() 830b1bbbbbSAndreas Gohr * operators: 840b1bbbbbSAndreas Gohr * and ('and' is the default operator: you can always omit this) 850b1bbbbbSAndreas Gohr * or (or pipe symbol '|', lower precedence than 'and') 860b1bbbbbSAndreas Gohr * 870b1bbbbbSAndreas Gohr * e.g. a query [ aa "bb cc" @dd:ee ] means "search pages which contain 880b1bbbbbSAndreas Gohr * a word 'aa', a phrase 'bb cc' and are within a namespace 'dd:ee'". 890b1bbbbbSAndreas Gohr * this query is equivalent to [ -(-aa or -"bb cc" or -ns:dd:ee) ] 900b1bbbbbSAndreas Gohr * as long as you don't mind hit counts. 910b1bbbbbSAndreas Gohr * 920b1bbbbbSAndreas Gohr * intermediate representation consists of the following parts: 930b1bbbbbSAndreas Gohr * 940b1bbbbbSAndreas Gohr * ( ) - group 950b1bbbbbSAndreas Gohr * AND - logical and 960b1bbbbbSAndreas Gohr * OR - logical or 970b1bbbbbSAndreas Gohr * NOT - logical not 980b1bbbbbSAndreas Gohr * W+:, W-:, W_: - word (underscore: no need to highlight) 990b1bbbbbSAndreas Gohr * P+:, P-: - phrase (minus sign: logically in NOT group) 1000b1bbbbbSAndreas Gohr * N+:, N-: - namespace 1010b1bbbbbSAndreas Gohr */ 1020b1bbbbbSAndreas Gohr $parsed_query = ''; 1030b1bbbbbSAndreas Gohr $parens_level = 0; 104*9369b4a9SAndreas Gohr $terms = preg_split( 105*9369b4a9SAndreas Gohr '/(-?".*?")/u', 106*9369b4a9SAndreas Gohr PhpString::strtolower($query), 107*9369b4a9SAndreas Gohr -1, 108*9369b4a9SAndreas Gohr PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY 1090b1bbbbbSAndreas Gohr ); 1100b1bbbbbSAndreas Gohr 1110b1bbbbbSAndreas Gohr foreach ($terms as $term) { 1120b1bbbbbSAndreas Gohr $parsed = ''; 1130b1bbbbbSAndreas Gohr if (preg_match('/^(-?)"(.+)"$/u', $term, $matches)) { 1140b1bbbbbSAndreas Gohr // phrase-include and phrase-exclude 1150b1bbbbbSAndreas Gohr $not = $matches[1] ? 'NOT' : ''; 1160b1bbbbbSAndreas Gohr $parsed = $not . $this->termParser($matches[2], false, true); 1170b1bbbbbSAndreas Gohr } else { 1180b1bbbbbSAndreas Gohr // fix incomplete phrase 1190b1bbbbbSAndreas Gohr $term = str_replace('"', ' ', $term); 1200b1bbbbbSAndreas Gohr 1210b1bbbbbSAndreas Gohr // fix parentheses 1220b1bbbbbSAndreas Gohr $term = str_replace(')', ' ) ', $term); 1230b1bbbbbSAndreas Gohr $term = str_replace('(', ' ( ', $term); 1240b1bbbbbSAndreas Gohr $term = str_replace('- (', ' -(', $term); 1250b1bbbbbSAndreas Gohr 1260b1bbbbbSAndreas Gohr // treat pipe symbols as 'OR' operators 1270b1bbbbbSAndreas Gohr $term = str_replace('|', ' or ', $term); 1280b1bbbbbSAndreas Gohr 1290b1bbbbbSAndreas Gohr // treat ideographic spaces (U+3000) as search term separators 1300b1bbbbbSAndreas Gohr // FIXME: some more separators? 1310b1bbbbbSAndreas Gohr $term = preg_replace('/[ \x{3000}]+/u', ' ', $term); 1320b1bbbbbSAndreas Gohr $term = trim($term); 1330b1bbbbbSAndreas Gohr if ($term === '') continue; 1340b1bbbbbSAndreas Gohr 1350b1bbbbbSAndreas Gohr $tokens = explode(' ', $term); 1360b1bbbbbSAndreas Gohr foreach ($tokens as $token) { 1370b1bbbbbSAndreas Gohr if ($token === '(') { 1380b1bbbbbSAndreas Gohr // parenthesis-include-open 1390b1bbbbbSAndreas Gohr $parsed .= '('; 1400b1bbbbbSAndreas Gohr ++$parens_level; 1410b1bbbbbSAndreas Gohr } elseif ($token === '-(') { 1420b1bbbbbSAndreas Gohr // parenthesis-exclude-open 1430b1bbbbbSAndreas Gohr $parsed .= 'NOT('; 1440b1bbbbbSAndreas Gohr ++$parens_level; 1450b1bbbbbSAndreas Gohr } elseif ($token === ')') { 1460b1bbbbbSAndreas Gohr // parenthesis-any-close 1470b1bbbbbSAndreas Gohr if ($parens_level === 0) continue; 1480b1bbbbbSAndreas Gohr $parsed .= ')'; 1490b1bbbbbSAndreas Gohr $parens_level--; 1500b1bbbbbSAndreas Gohr } elseif ($token === 'and') { 1510b1bbbbbSAndreas Gohr // logical-and (do nothing) 1520b1bbbbbSAndreas Gohr } elseif ($token === 'or') { 1530b1bbbbbSAndreas Gohr // logical-or 1540b1bbbbbSAndreas Gohr $parsed .= 'OR'; 1550b1bbbbbSAndreas Gohr } elseif (preg_match('/^(?:\^|-ns:)(.+)$/u', $token, $matches)) { 1560b1bbbbbSAndreas Gohr // namespace-exclude 1570b1bbbbbSAndreas Gohr $parsed .= 'NOT(N+:' . $matches[1] . ')'; 1580b1bbbbbSAndreas Gohr } elseif (preg_match('/^(?:@|ns:)(.+)$/u', $token, $matches)) { 1590b1bbbbbSAndreas Gohr // namespace-include 1600b1bbbbbSAndreas Gohr $parsed .= '(N+:' . $matches[1] . ')'; 1610b1bbbbbSAndreas Gohr } elseif (preg_match('/^-(.+)$/', $token, $matches)) { 1620b1bbbbbSAndreas Gohr // word-exclude 1630b1bbbbbSAndreas Gohr $parsed .= 'NOT(' . $this->termParser($matches[1]) . ')'; 1640b1bbbbbSAndreas Gohr } else { 1650b1bbbbbSAndreas Gohr // word-include 1660b1bbbbbSAndreas Gohr $parsed .= $this->termParser($token); 1670b1bbbbbSAndreas Gohr } 1680b1bbbbbSAndreas Gohr } 1690b1bbbbbSAndreas Gohr } 1700b1bbbbbSAndreas Gohr $parsed_query .= $parsed; 1710b1bbbbbSAndreas Gohr } 1720b1bbbbbSAndreas Gohr 1730b1bbbbbSAndreas Gohr // cleanup (very sensitive) 1740b1bbbbbSAndreas Gohr $parsed_query .= str_repeat(')', $parens_level); 1750b1bbbbbSAndreas Gohr do { 1760b1bbbbbSAndreas Gohr $parsed_query_old = $parsed_query; 1770b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/(NOT)?\(\)/u', '', $parsed_query); 1780b1bbbbbSAndreas Gohr } while ($parsed_query !== $parsed_query_old); 1790b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/(NOT|OR)+\)/u', ')', $parsed_query); 1800b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/(OR)+/u', 'OR', $parsed_query); 1810b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/\(OR/u', '(', $parsed_query); 1820b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/^OR|OR$/u', '', $parsed_query); 1830b1bbbbbSAndreas Gohr $parsed_query = preg_replace('/\)(NOT)?\(/u', ')AND$1(', $parsed_query); 1840b1bbbbbSAndreas Gohr 1850b1bbbbbSAndreas Gohr // adjustment: make highlightings right 1860b1bbbbbSAndreas Gohr $parens_level = 0; 187*9369b4a9SAndreas Gohr $notgrp_levels = []; 1880b1bbbbbSAndreas Gohr $parsed_query_new = ''; 189*9369b4a9SAndreas Gohr $tokens = preg_split( 190*9369b4a9SAndreas Gohr '/(NOT\(|[()])/u', 191*9369b4a9SAndreas Gohr $parsed_query, 192*9369b4a9SAndreas Gohr -1, 193*9369b4a9SAndreas Gohr PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY 1940b1bbbbbSAndreas Gohr ); 1950b1bbbbbSAndreas Gohr foreach ($tokens as $token) { 1960b1bbbbbSAndreas Gohr if ($token === 'NOT(') { 1970b1bbbbbSAndreas Gohr $notgrp_levels[] = ++$parens_level; 1980b1bbbbbSAndreas Gohr } elseif ($token === '(') { 1990b1bbbbbSAndreas Gohr ++$parens_level; 2000b1bbbbbSAndreas Gohr } elseif ($token === ')') { 2010b1bbbbbSAndreas Gohr if ($parens_level-- === end($notgrp_levels)) array_pop($notgrp_levels); 2020b1bbbbbSAndreas Gohr } elseif (count($notgrp_levels) % 2 === 1) { 2030b1bbbbbSAndreas Gohr // turn highlight-flag off if terms are logically in "NOT" group 2040b1bbbbbSAndreas Gohr $token = preg_replace('/([WPN])\+\:/u', '$1-:', $token); 2050b1bbbbbSAndreas Gohr } 2060b1bbbbbSAndreas Gohr $parsed_query_new .= $token; 2070b1bbbbbSAndreas Gohr } 2080b1bbbbbSAndreas Gohr $parsed_query = $parsed_query_new; 2090b1bbbbbSAndreas Gohr 2100b1bbbbbSAndreas Gohr /** 2110b1bbbbbSAndreas Gohr * convert infix notation string into postfix (Reverse Polish notation) array 2120b1bbbbbSAndreas Gohr * by Shunting-yard algorithm 2130b1bbbbbSAndreas Gohr * 2140b1bbbbbSAndreas Gohr * see: http://en.wikipedia.org/wiki/Reverse_Polish_notation 2150b1bbbbbSAndreas Gohr * see: http://en.wikipedia.org/wiki/Shunting-yard_algorithm 2160b1bbbbbSAndreas Gohr */ 217*9369b4a9SAndreas Gohr $parsed_ary = []; 218*9369b4a9SAndreas Gohr $ope_stack = []; 219*9369b4a9SAndreas Gohr $ope_precedence = [')' => 1, 'OR' => 2, 'AND' => 3, 'NOT' => 4, '(' => 5]; 2200b1bbbbbSAndreas Gohr $ope_regex = '/([()]|OR|AND|NOT)/u'; 2210b1bbbbbSAndreas Gohr 222*9369b4a9SAndreas Gohr $tokens = preg_split( 223*9369b4a9SAndreas Gohr $ope_regex, 224*9369b4a9SAndreas Gohr $parsed_query, 225*9369b4a9SAndreas Gohr -1, 226*9369b4a9SAndreas Gohr PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY 2270b1bbbbbSAndreas Gohr ); 2280b1bbbbbSAndreas Gohr foreach ($tokens as $token) { 2290b1bbbbbSAndreas Gohr if (preg_match($ope_regex, $token)) { 2300b1bbbbbSAndreas Gohr // operator 2310b1bbbbbSAndreas Gohr $last_ope = end($ope_stack); 232*9369b4a9SAndreas Gohr while ( 233*9369b4a9SAndreas Gohr $last_ope !== false 2340b1bbbbbSAndreas Gohr && $ope_precedence[$token] <= $ope_precedence[$last_ope] 2350b1bbbbbSAndreas Gohr && $last_ope != '(' 2360b1bbbbbSAndreas Gohr ) { 2370b1bbbbbSAndreas Gohr $parsed_ary[] = array_pop($ope_stack); 2380b1bbbbbSAndreas Gohr $last_ope = end($ope_stack); 2390b1bbbbbSAndreas Gohr } 2400b1bbbbbSAndreas Gohr if ($token == ')') { 2410b1bbbbbSAndreas Gohr array_pop($ope_stack); // this array_pop always deletes '(' 2420b1bbbbbSAndreas Gohr } else { 2430b1bbbbbSAndreas Gohr $ope_stack[] = $token; 2440b1bbbbbSAndreas Gohr } 2450b1bbbbbSAndreas Gohr } else { 2460b1bbbbbSAndreas Gohr // operand 2470b1bbbbbSAndreas Gohr $token_decoded = str_replace(['OP', 'CP'], ['(', ')'], $token); 2480b1bbbbbSAndreas Gohr $parsed_ary[] = $token_decoded; 2490b1bbbbbSAndreas Gohr } 2500b1bbbbbSAndreas Gohr } 2510b1bbbbbSAndreas Gohr $parsed_ary = array_values(array_merge($parsed_ary, array_reverse($ope_stack))); 2520b1bbbbbSAndreas Gohr 2530b1bbbbbSAndreas Gohr // cleanup: each double "NOT" in RPN array actually does nothing 2540b1bbbbbSAndreas Gohr $parsed_ary_count = count($parsed_ary); 2550b1bbbbbSAndreas Gohr for ($i = 1; $i < $parsed_ary_count; ++$i) { 2560b1bbbbbSAndreas Gohr if ($parsed_ary[$i] === 'NOT' && $parsed_ary[$i - 1] === 'NOT') { 2570b1bbbbbSAndreas Gohr unset($parsed_ary[$i], $parsed_ary[$i - 1]); 2580b1bbbbbSAndreas Gohr } 2590b1bbbbbSAndreas Gohr } 2600b1bbbbbSAndreas Gohr $parsed_ary = array_values($parsed_ary); 2610b1bbbbbSAndreas Gohr 2620b1bbbbbSAndreas Gohr // build return value 263*9369b4a9SAndreas Gohr $q = []; 2640b1bbbbbSAndreas Gohr $q['query'] = $query; 2650b1bbbbbSAndreas Gohr $q['parsed_str'] = $parsed_query; 2660b1bbbbbSAndreas Gohr $q['parsed_ary'] = $parsed_ary; 2670b1bbbbbSAndreas Gohr 2680b1bbbbbSAndreas Gohr foreach ($q['parsed_ary'] as $token) { 2690b1bbbbbSAndreas Gohr if ($token[2] !== ':') continue; 2700b1bbbbbSAndreas Gohr $body = substr($token, 3); 2710b1bbbbbSAndreas Gohr 2720b1bbbbbSAndreas Gohr switch (substr($token, 0, 3)) { 2730b1bbbbbSAndreas Gohr case 'N+:': 2740b1bbbbbSAndreas Gohr $q['ns'][] = $body; // for backward compatibility 2750b1bbbbbSAndreas Gohr break; 2760b1bbbbbSAndreas Gohr case 'N-:': 2770b1bbbbbSAndreas Gohr $q['notns'][] = $body; // for backward compatibility 2780b1bbbbbSAndreas Gohr break; 2790b1bbbbbSAndreas Gohr case 'W_:': 2800b1bbbbbSAndreas Gohr $q['words'][] = $body; 2810b1bbbbbSAndreas Gohr break; 2820b1bbbbbSAndreas Gohr case 'W-:': 2830b1bbbbbSAndreas Gohr $q['words'][] = $body; 2840b1bbbbbSAndreas Gohr $q['not'][] = $body; // for backward compatibility 2850b1bbbbbSAndreas Gohr break; 2860b1bbbbbSAndreas Gohr case 'W+:': 2870b1bbbbbSAndreas Gohr $q['words'][] = $body; 2880b1bbbbbSAndreas Gohr $q['highlight'][] = $body; 2890b1bbbbbSAndreas Gohr $q['and'][] = $body; // for backward compatibility 2900b1bbbbbSAndreas Gohr break; 2910b1bbbbbSAndreas Gohr case 'P-:': 2920b1bbbbbSAndreas Gohr $q['phrases'][] = $body; 2930b1bbbbbSAndreas Gohr break; 2940b1bbbbbSAndreas Gohr case 'P+:': 2950b1bbbbbSAndreas Gohr $q['phrases'][] = $body; 2960b1bbbbbSAndreas Gohr $q['highlight'][] = $body; 2970b1bbbbbSAndreas Gohr break; 2980b1bbbbbSAndreas Gohr } 2990b1bbbbbSAndreas Gohr } 3000b1bbbbbSAndreas Gohr foreach (['words', 'phrases', 'highlight', 'ns', 'notns', 'and', 'not'] as $key) { 301*9369b4a9SAndreas Gohr $q[$key] = empty($q[$key]) ? [] : array_values(array_unique($q[$key])); 3020b1bbbbbSAndreas Gohr } 3030b1bbbbbSAndreas Gohr 3040b1bbbbbSAndreas Gohr return $q; 3050b1bbbbbSAndreas Gohr } 3060b1bbbbbSAndreas Gohr 3070b1bbbbbSAndreas Gohr /** 3080b1bbbbbSAndreas Gohr * Recreate a search query string based on parsed parts, 3090b1bbbbbSAndreas Gohr * doesn't support negated phrases and `OR` searches 3100b1bbbbbSAndreas Gohr * 3110b1bbbbbSAndreas Gohr * @param array $and 3120b1bbbbbSAndreas Gohr * @param array $not 3130b1bbbbbSAndreas Gohr * @param array $phrases 3140b1bbbbbSAndreas Gohr * @param array $ns 3150b1bbbbbSAndreas Gohr * @param array $notns 3160b1bbbbbSAndreas Gohr * 3170b1bbbbbSAndreas Gohr * @return string 3180b1bbbbbSAndreas Gohr */ 3190b1bbbbbSAndreas Gohr public function revert(array $and, array $not, array $phrases, array $ns, array $notns) 3200b1bbbbbSAndreas Gohr { 3210b1bbbbbSAndreas Gohr $query = implode(' ', $and); 3220b1bbbbbSAndreas Gohr 323*9369b4a9SAndreas Gohr if ($not !== []) { 3240b1bbbbbSAndreas Gohr $query .= ' -' . implode(' -', $not); 3250b1bbbbbSAndreas Gohr } 326*9369b4a9SAndreas Gohr if ($phrases !== []) { 3270b1bbbbbSAndreas Gohr $query .= ' "' . implode('" "', $phrases) . '"'; 3280b1bbbbbSAndreas Gohr } 329*9369b4a9SAndreas Gohr if ($ns !== []) { 3300b1bbbbbSAndreas Gohr $query .= ' @' . implode(' @', $ns); 3310b1bbbbbSAndreas Gohr } 332*9369b4a9SAndreas Gohr if ($notns !== []) { 3330b1bbbbbSAndreas Gohr $query .= ' ^' . implode(' ^', $notns); 3340b1bbbbbSAndreas Gohr } 3350b1bbbbbSAndreas Gohr return $query; 3360b1bbbbbSAndreas Gohr } 3370b1bbbbbSAndreas Gohr} 338