]> CyberLeo.Net >> Repos - SourceForge/phpwiki.git/blob - lib/TextSearchQuery.php
var --> public
[SourceForge/phpwiki.git] / lib / TextSearchQuery.php
1 <?php
2 /**
3  * A text search query, converting queries to PCRE and SQL matchers.
4  *
5  * This represents an enhanced "Google-like" text search query:
6  * <dl>
7  * <dt> default: case-insensitive glob-style search with special operators OR AND NOT -
8  * <dt> wiki -test
9  *   <dd> Match strings containing the substring 'wiki', and NOT containing the
10  *        substring 'test'.
11  * <dt> wiki word or page
12  *   <dd> Match strings containing the substring 'wiki' AND either the substring
13  *        'word' OR the substring 'page'.
14  * <dt> auto-detect regex hints, glob-style or regex-style, and converts them
15  *      to PCRE and SQL matchers:
16  *   <dd> "^word$" => EXACT(phrase)
17  *   <dd> "^word"  => STARTS_WITH(phrase)
18  *   <dd> "word$"  => ENDS_WITH(phrase)
19  *   <dd> "^word" ... => STARTS_WITH(word)
20  *   <dd> "word$" ... => ENDS_WITH(word)
21  *   <dd> "word*"  => STARTS_WITH(word)
22  *   <dd> "*word"  => ENDS_WITH(word)
23  *   <dd> "/^word.* /" => REGEX(^word.*)
24  *   <dd> "word*word" => REGEX(word.*word)
25  * </dl>
26  *
27  * The full query syntax, in order of precedence, is roughly:
28  *
29  * The unary 'NOT' or '-' operator (they are equivalent) negates the
30  * following search clause.
31  *
32  * Search clauses may be joined with the (left-associative) binary operators
33  * 'AND' and 'OR'. (case-insensitive)
34  *
35  * Two adjoining search clauses are joined with an implicit 'AND'.  This has
36  * lower precedence than either an explicit 'AND' or 'OR', so "a b OR c"
37  * parses as "a AND ( b OR c )", while "a AND b OR c" parses as
38  * "( a AND b ) OR c" (due to the left-associativity of 'AND' and 'OR'.)
39  *
40  * Search clauses can be grouped with parentheses.
41  *
42  * Phrases (or other things which don't look like words) can be forced to
43  * be interpreted as words by quoting them, either with single (') or double (")
44  * quotes.  If you wan't to include the quote character within a quoted string,
45  * double-up on the quote character: 'I''m hungry' is equivalent to
46  * "I'm hungry".
47  *
48  * Force regex on "re:word" => posix-style, "/word/" => pcre-style
49  * or use regex='glob' to use file wildcard-like matching. (not yet)
50  *
51  * The parsed tree is then converted to the needed PCRE (highlight,
52  * simple backends) or SQL functions.
53  *
54  * @author: Jeff Dairiki
55  * @author: Reini Urban (case and regex detection, enhanced sql callbacks)
56  */
57
58 // regex-style: 'auto', 'none', 'glob', 'posix', 'pcre', 'sql'
59 define ('TSQ_REGEX_NONE', 0);
60 define ('TSQ_REGEX_AUTO', 1);
61 define ('TSQ_REGEX_POSIX', 2);
62 define ('TSQ_REGEX_GLOB', 4);
63 define ('TSQ_REGEX_PCRE', 8);
64 define ('TSQ_REGEX_SQL', 16);
65
66 define ('TSQ_TOK_VOID', 0);
67 define ('TSQ_TOK_BINOP', 1);
68 define ('TSQ_TOK_NOT', 2);
69 define ('TSQ_TOK_LPAREN', 4);
70 define ('TSQ_TOK_RPAREN', 8);
71 define ('TSQ_TOK_WORD', 16);
72 define ('TSQ_TOK_STARTS_WITH', 32);
73 define ('TSQ_TOK_ENDS_WITH', 64);
74 define ('TSQ_TOK_EXACT', 128);
75 define ('TSQ_TOK_REGEX', 256);
76 define ('TSQ_TOK_REGEX_GLOB', 512);
77 define ('TSQ_TOK_REGEX_PCRE', 1024);
78 define ('TSQ_TOK_REGEX_SQL', 2048);
79 define ('TSQ_TOK_ALL', 4096);
80 // all bits from word to the last.
81 define ('TSQ_ALLWORDS', (4096 * 2) - 1 - (16 - 1));
82
83 /*
84 define ('TSQ_NODE_ALL',   0);
85 define ('TSQ_NODE_EXACT', 1);
86 define ('TSQ_NODE_NOT',   2);
87 define ('TSQ_NODE_WORD',  3);
88 define ('TSQ_NODE_STARTS_WITH', 4);
89 define ('TSQ_NODE_ENDS_WITH',   5);
90 define ('TSQ_NODE_REGEX',       6);
91 define ('TSQ_NODE_REGEX_GLOB',  7);
92 define ('TSQ_NODE_REGEX_PCRE',  8);
93 define ('TSQ_NODE_REGEX_SQL',   9);
94 define ('TSQ_NODE_AND',        10);
95 define ('TSQ_NODE_OR',         11);
96 */
97
98 class TextSearchQuery
99 {
100     /**
101      * Create a new query.
102      *
103      * @param $search_query string The query.  Syntax is as described above.
104      * Note that an empty $search_query will match anything.
105      * @param $case_exact boolean
106      * @param $regex string one of 'auto', 'none', 'glob', 'posix', 'pcre', 'sql'
107      * @see TextSearchQuery
108      */
109     function TextSearchQuery($search_query, $case_exact = false, $regex = 'auto')
110     {
111         if ($regex == 'none' or !$regex)
112             $this->_regex = 0;
113         elseif (defined("TSQ_REGEX_" . strtoupper($regex)))
114             $this->_regex = constant("TSQ_REGEX_" . strtoupper($regex)); else {
115             trigger_error(fmt("Unsupported argument: %s=%s", 'regex', $regex));
116             $this->_regex = 0;
117         }
118         $this->_regex_modifier = ($case_exact ? '' : 'i') . 'sS';
119         $this->_case_exact = $case_exact;
120         if ($regex != 'pcre') {
121             $parser = new TextSearchQuery_Parser;
122             $this->_tree = $parser->parse($search_query, $case_exact, $this->_regex);
123             $this->_optimize(); // broken under certain circumstances: "word -word -word"
124             if (defined("FULLTEXTSEARCH_STOPLIST"))
125                 $this->_stoplist = FULLTEXTSEARCH_STOPLIST;
126             else // default stoplist, localizable.
127                 $this->_stoplist = _("(A|An|And|But|By|For|From|In|Is|It|Of|On|Or|The|To|With)");
128         } else {
129             $this->_tree = new TextSearchQuery_node_regex_pcre($search_query);
130             if (preg_match("/^\/(.*)\/(\w*)$/", $search_query, $m)) {
131                 $this->_tree->word = $m[1];
132                 $this->_regex_modifier = $m[2]; // overrides case_exact
133             }
134         }
135     }
136
137     function getType()
138     {
139         return 'text';
140     }
141
142     function _optimize()
143     {
144         $this->_tree = $this->_tree->optimize();
145     }
146
147     /**
148      * Get a PCRE regexp which matches the query.
149      */
150     function asRegexp()
151     {
152         if (!isset($this->_regexp)) {
153             if (!isset($this->_regex_modifier))
154                 $this->_regex_modifier = ($this->_case_exact ? '' : 'i') . 'sS';
155             if ($this->_regex)
156                 $this->_regexp = '/' . $this->_tree->regexp() . '/' . $this->_regex_modifier;
157             else
158                 $this->_regexp = '/^' . $this->_tree->regexp() . '/' . $this->_regex_modifier;
159         }
160         return $this->_regexp;
161     }
162
163     /**
164      * Match query against string.
165      * EXACT ("Term") ignores the case_exact setting.
166      *
167      * @param $string string The string to match.
168      * @return boolean True if the string matches the query.
169      */
170     function match($string)
171     {
172         if ($this->_tree->_op == TSQ_TOK_ALL) return true;
173         if ($this->_tree->_op == TSQ_TOK_EXACT) return $this->_tree->word == $string;
174         return preg_match($this->asRegexp(), $string);
175     }
176
177     /* How good does it match? Returns a number */
178     function score($string)
179     {
180         $score = 0.0;
181         $i = 10;
182         foreach (array_unique($this->_tree->highlight_words()) as $word) {
183             if ($nummatch = preg_match_all("/" . preg_quote($word, '/') . "/" .
184                     $this->_regex_modifier,
185                 $string, $out)
186             )
187                 $score += ($i-- * $nummatch);
188         }
189         return min(1.0, $score / 10.0);
190     }
191
192     /**
193      * Get a regular expression suitable for highlighting matched words.
194      *
195      * This returns a PCRE regular expression which matches any non-negated
196      * word in the query.
197      *
198      * @return string The PCRE regexp.
199      */
200     function getHighlightRegexp()
201     {
202         if (!isset($this->_hilight_regexp)) {
203             $words = array_unique($this->_tree->highlight_words());
204             if (!$words) {
205                 $this->_hilight_regexp = false;
206             } else {
207                 foreach ($words as $key => $word)
208                     $words[$key] = preg_quote($word, '/');
209                 $this->_hilight_regexp = '(?' . ($this->_case_exact ? '' : 'i') . ':'
210                     . join('|', $words) . ')';
211             }
212         }
213         return $this->_hilight_regexp;
214     }
215
216     /**
217      * Make an SQL clause which matches the query.
218      * Deprecated, use makeSqlClauseObj instead.
219      *
220      * @param $make_sql_clause_cb WikiCallback
221      * A callback which takes a single word as an argument and
222      * returns an SQL clause which will match exactly those records
223      * containing the word.  The word passed to the callback will always
224      * be in all lower case.
225      *
226      * Support db-specific extensions, like MATCH AGAINST or REGEX
227      * mysql => 4.0.1 can also do Google: MATCH AGAINST IN BOOLEAN MODE
228      * by using makeSqlClauseObj
229      *
230      * Old example usage:
231      * <pre>
232      *     function sql_title_match($word) {
233      *         return sprintf("LOWER(title) like '%s'",
234      *                        addslashes($word));
235      *     }
236      *
237      *     ...
238      *
239      *     $query = new TextSearchQuery("wiki -page");
240      *     $cb = new WikiFunctionCb('sql_title_match');
241      *     $sql_clause = $query->makeSqlClause($cb);
242      * </pre>
243      * This will result in $sql_clause containing something like
244      * "(LOWER(title) like 'wiki') AND NOT (LOWER(title) like 'page')".
245      *
246      * @return string The SQL clause.
247      */
248     function makeSqlClause($sql_clause_cb)
249     {
250         $this->_sql_clause_cb = $sql_clause_cb;
251         return $this->_sql_clause($this->_tree);
252     }
253
254     // deprecated: use _sql_clause_obj now.
255     function _sql_clause($node)
256     {
257         switch ($node->_op) {
258             case TSQ_TOK_WORD: // word => %word%
259                 return $this->_sql_clause_cb->call($node->word);
260             case TSQ_TOK_NOT:
261                 return "NOT (" . $this->_sql_clause($node->leaves[0]) . ")";
262             case TSQ_TOK_BINOP:
263                 $subclauses = array();
264                 foreach ($node->leaves as $leaf)
265                     $subclauses[] = "(" . $this->_sql_clause($leaf) . ")";
266                 return join(" $node->op ", $subclauses);
267             default:
268                 assert($node->_op == TSQ_TOK_VOID);
269                 return '1=1';
270         }
271     }
272
273     /** Get away with the callback and use a db-specific search class instead.
274      * @see WikiDB_backend_PearDB_search
275      */
276     function makeSqlClauseObj(&$sql_search_cb)
277     {
278         $this->_sql_clause_cb = $sql_search_cb;
279         return $this->_sql_clause_obj($this->_tree);
280     }
281
282     function _sql_clause_obj($node)
283     {
284         switch ($node->_op) {
285             case TSQ_TOK_NOT:
286                 return "NOT (" . $this->_sql_clause_cb->call($node->leaves[0]) . ")";
287             case TSQ_TOK_BINOP:
288                 $subclauses = array();
289                 foreach ($node->leaves as $leaf)
290                     $subclauses[] = "(" . $this->_sql_clause_obj($leaf) . ")";
291                 return join(" $node->op ", $subclauses);
292             case TSQ_TOK_VOID:
293                 return '0=1';
294             case TSQ_TOK_ALL:
295                 return '1=1';
296             default:
297                 return $this->_sql_clause_cb->call($node);
298         }
299     }
300
301     /*
302      postgresql tsearch2 uses no WHERE operators, just & | and ! in the searchstring
303      */
304     function makeTsearch2SqlClauseObj(&$sql_search_cb)
305     {
306         $this->_sql_clause_cb = $sql_search_cb;
307         return $this->_Tsearch2Sql_clause_obj($this->_tree);
308     }
309
310     function _Tsearch2Sql_clause_obj($node)
311     {
312         // TODO: "such a phrase"
313         switch ($node->_op) {
314             case TSQ_TOK_NOT:
315                 return "!" . $node->leaves[0];
316             case TSQ_TOK_BINOP:
317                 $subclauses = array();
318                 foreach ($node->leaves as $leaf)
319                     $subclauses[] = $this->_Tsearch2Sql_clause_obj($leaf);
320                 return join($node->_op == 'AND' ? "&" : "|", $subclauses);
321             case TSQ_TOK_VOID:
322                 return '';
323             case TSQ_TOK_ALL:
324                 return '1';
325             default:
326                 return $this->_sql_clause_cb->call($node);
327         }
328     }
329
330     function sql()
331     {
332         return '%' . $this->_sql_quote($this->word) . '%';
333     }
334
335     /**
336      * Get printable representation of the parse tree.
337      *
338      * This is for debugging only.
339      * @return string Printable parse tree.
340      */
341     function asString()
342     {
343         return $this->_as_string($this->_tree);
344     }
345
346     function _as_string($node, $indent = '')
347     {
348         switch ($node->_op) {
349             case TSQ_TOK_WORD:
350                 return $indent . "WORD: $node->word";
351             case TSQ_TOK_VOID:
352                 return $indent . "VOID";
353             case TSQ_TOK_ALL:
354                 return $indent . "ALL";
355             default:
356                 $lines = array($indent . $node->op . ":");
357                 $indent .= "  ";
358                 foreach ($node->leaves as $leaf)
359                     $lines[] = $this->_as_string($leaf, $indent);
360                 return join("\n", $lines);
361         }
362     }
363 }
364
365 /**
366  * This is a TextSearchQuery which matches nothing.
367  */
368 class NullTextSearchQuery extends TextSearchQuery
369 {
370     /**
371      * Create a new query.
372      *
373      * @see TextSearchQuery
374      */
375     function NullTextSearchQuery()
376     {
377     }
378
379     function asRegexp()
380     {
381         return '/^(?!a)a/x';
382     }
383
384     function match($string)
385     {
386         return false;
387     }
388
389     function getHighlightRegexp()
390     {
391         return "";
392     }
393
394     function makeSqlClause($make_sql_clause_cb)
395     {
396         return "(1 = 0)";
397     }
398
399     function asString()
400     {
401         return "NullTextSearchQuery";
402     }
403 }
404
405 /**
406  * A simple algebraic matcher for numeric attributes.
407  *  NumericSearchQuery can do ("population < 20000 and area > 1000000", array("population", "area"))
408  *  ->match(array('population' => 100000, 'area' => 10000000))
409  *
410  * Supports all mathematical PHP comparison operators, plus ':=' for equality.
411  *   "(x < 2000000 and x >= 10000) or (x >= 100 and x < 2000)"
412  *   "x := 100000" is the same as "x == 100000"
413  *
414  * Since this is basic numerics only, we simply try to get away with
415  * replacing the variable values at the right positions and do an eval then.
416  *
417  * @package NumericSearchQuery
418  * @author Reini Urban
419  * @see SemanticAttributeSearchQuery
420  */
421 class NumericSearchQuery
422 {
423     /**
424      * Create a new query.
425      *   NumericSearchQuery("population > 20000 or population < 200", "population")
426      *   NumericSearchQuery("population < 20000 and area > 1000000", array("population", "area"))
427      *
428      * With a single variable it is easy: The valid name must be matched elsewhere, just
429      * replace the given number in match in the query.
430      *   ->match(2000)
431      *
432      * With matching a struct we need strict names, no * as name is allowed.
433      * So always when the placeholder is an array, the names of the target struct must match
434      * and all vars be defined. Use the method can_match($struct) therefore.
435      *
436      * @access public
437      * @param $search_query string   A numerical query with placeholders as variable.
438      * @param $placeholders array or string  All placeholders in the query must be defined
439      *     here, and will be replaced by the matcher.
440      */
441     function NumericSearchQuery($search_query, $placeholders)
442     {
443         // added some basic security checks against user input
444         $this->_query = $search_query;
445         $this->_placeholders = $placeholders;
446
447         // we should also allow the M_ constants
448         $this->_allowed_functions = explode(':', 'abs:acos:acosh:asin:asinh:atan2:atan:atanh:base_convert:bindec:ceil:cos:cosh:decbin:dechex:decoct:deg2rad:exp:expm1:floor:fmod:getrandmax:hexdec:hypot:is_finite:is_infinite:is_nan:lcg_value:log10:log1p:log:max:min:mt_getrandmax:mt_rand:mt_srand:octdec:pi:pow:rad2deg:rand:round:sin:sinh:sqrt:srand:tan:tanh');
449         $this->_allowed_operators = explode(',', '-,<,<=,>,>=,==,!=,*,+,/,(,),%,and,or,xor,<<,>>,===,!==,&,^,|,&&,||');
450         $this->_parser_check = array();
451         // check should be fast, so make a hash
452         foreach ($this->_allowed_functions as $f)
453             $this->_parser_check[$f] = 1;
454         foreach ($this->_allowed_operators as $f)
455             $this->_parser_check[$f] = 1;
456         if (is_array($placeholders))
457             foreach ($placeholders as $f)
458                 $this->_parser_check[$f] = 1;
459         else $this->_parser_check[$placeholders] = 1;
460
461         // This is a speciality: := looks like the attribute definition and is
462         // therefore a dummy check for this definition.
463         // php-4.2.2 has a problem with /\b:=\b/ matching "population := 1223400"
464         $this->_query = preg_replace("/:=/", "==", $this->_query);
465         $this->_query = $this->check_query($this->_query);
466     }
467
468     function getType()
469     {
470         return 'numeric';
471     }
472
473     /**
474      * Check the symbolic definition query against unwanted functions and characters.
475      * "population < 20000 and area > 1000000" vs
476      *   "area > 1000000 and mail($me,file("/etc/passwd"),...)"
477      * http://localhost/wikicvs/SemanticSearch?attribute=*&attr_op=<0 and find(1)>&s=-0.01&start_debug=1
478      */
479     function check_query($query)
480     {
481         $tmp = $query; // check for all function calls, in case the tokenizer is not available.
482         while (preg_match("/([a-z][a-z0-9]+)\s*\((.*)$/i", $tmp, $m)) {
483             if (!in_array($m[1], $this->_allowed_functions)
484                 and !in_array($m[1], $this->_allowed_operators)
485             ) {
486                 trigger_error("Illegal function in query: " . $m[1], E_USER_WARNING);
487                 return '';
488             }
489             $tmp = $m[2];
490         }
491
492         // Strictly check for illegal functions and operators, which are no placeholders.
493         if (function_exists('token_get_all')) {
494             $parsed = token_get_all("<?$query?>");
495             foreach ($parsed as $x) { // flat, non-recursive array
496                 if (is_string($x) and !isset($this->_parser_check[$x])) {
497                     // single char op or name
498                     trigger_error("Illegal string or operator in query: \"$x\"", E_USER_WARNING);
499                     $query = '';
500                 } elseif (is_array($x)) {
501                     $n = token_name($x[0]);
502                     if ($n == 'T_OPEN_TAG' or $n == 'T_WHITESPACE'
503                         or $n == 'T_CLOSE_TAG' or $n == 'T_LNUMBER'
504                         or $n == 'T_CONST' or $n == 'T_DNUMBER'
505                     ) continue;
506                     if ($n == 'T_VARIABLE') { // but we do allow consts
507                         trigger_error("Illegal variable in query: \"$x[1]\"", E_USER_WARNING);
508                         $query = '';
509                     }
510                     if (is_string($x[1]) and !isset($this->_parser_check[$x[1]])) {
511                         // multi-char char op or name
512                         trigger_error("Illegal $n in query: \"$x[1]\"", E_USER_WARNING);
513                         $query = '';
514                     }
515                 }
516             }
517             //echo "$query <br>";
518             //$this->_parse_token($parsed);
519             //echo "<br>\n";
520             //var_dump($parsed);
521             /*
522     "_x > 0" =>
523     { T_OPEN_TAG "<?"} { T_STRING "_x"} { T_WHITESPACE " "} ">" { T_WHITESPACE " "} { T_LNUMBER "0"} { T_CLOSE_TAG "?>"}
524         Interesting: on-char ops, as ">" are not tokenized.
525     "_x <= 0"
526     { T_OPEN_TAG "< ?" } { T_STRING "_x" } { T_WHITESPACE " " } { T_IS_SMALLER_OR_EQUAL "<=" } { T_WHITESPACE " " } { T_LNUMBER "0" } { T_CLOSE_TAG "?>" }
527              */
528         } else {
529             // Detect illegal characters besides nums, words and ops.
530             // So attribute names can not be utf-8
531             $c = "/([^\d\w.,\s" . preg_quote(join("", $this->_allowed_operators), "/") . "])/";
532             if (preg_match($c, $query, $m)) {
533                 trigger_error("Illegal character in query: " . $m[1], E_USER_WARNING);
534                 return '';
535             }
536         }
537         return $query;
538     }
539
540     /**
541      * Check the bound, numeric-only query against unwanted functions and sideeffects.
542      * "4560000 < 20000 and 1456022 > 1000000"
543      */
544     function _live_check()
545     {
546         // TODO: check $this->_workquery again?
547         return !empty($this->_workquery);
548     }
549
550     /**
551      * A numeric query can only operate with predefined variables. "x < 0 and y < 1"
552      *
553      * @return array The names as array of strings. => ('x', 'y') the placeholders.
554      */
555     function getVars()
556     {
557         if (is_array($this->_placeholders)) return $this->_placeholders;
558         else return array($this->_placeholders);
559     }
560
561     /**
562      * Strip non-numeric chars from the variable (as the groupseperator) and replace
563      * it in the symbolic query for evaluation.
564      *
565      * @access private
566      * @param $value number   A numerical value: integer, float or string.
567      * @param $x string       The variable name to be replaced in the query.
568      * @return string
569      */
570     function _bind($value, $x)
571     {
572         // TODO: check is_number, is_float, is_integer and do casting
573         $this->_bound[] = array('linkname' => $x,
574             'linkvalue' => $value);
575         $value = preg_replace("/[^-+0123456789.,]/", "", $value);
576         //$c = "/\b".preg_quote($x,"/")."\b/";
577         $this->_workquery = preg_replace("/\b" . preg_quote($x, "/") . "\b/", $value, $this->_workquery);
578         // FIXME: do again a final check. now only numbers and some operators are allowed.
579         return $this->_workquery;
580     }
581
582     /* array of successfully bound vars, and in case of success, the resulting vars
583      */
584     function _bound()
585     {
586         return $this->_bound;
587     }
588
589     /**
590      * With an array of placeholders we need a hash to check against, if all required names are given.
591      * Purpose: Be silent about missing vars, just return false.
592     `*
593      * @access public
594      * @param $variable string or hash of name => value  The keys must satisfy all placeholders in the definition.
595      * We want the full hash and not just the keys because a hash check is faster than the array of keys check.
596      * @return boolean
597      */
598     function can_match(&$variables)
599     {
600         if (empty($this->_query))
601             return false;
602         $p =& $this->_placeholders;
603         if (!is_array($variables) and !is_array($p))
604             return $variables == $p; // This was easy.
605         // Check if all placeholders have definitions. can be overdefined but not underdefined.
606         if (!is_array($p)) {
607             if (!isset($variables[$p])) return false;
608         } else {
609             foreach ($p as $x) {
610                 if (!isset($variables[$x])) return false;
611             }
612         }
613         return true;
614     }
615
616     /**
617      * We can match against a single variable or against a hash of variables.
618      * With one placeholder we need just a number.
619      * With an array of placeholders we need a hash.
620      *
621      * @access public
622      * @param $variable number or array of name => value  The keys must satisfy all placeholders in the definition.
623      * @return boolean
624      */
625     function match(&$variable)
626     {
627         $p =& $this->_placeholders;
628         $this->_workquery = $this->_query;
629         if (!is_array($p)) {
630             if (is_array($variable)) { // which var to match? we cannot decide this here
631                 if (!isset($variable[$p]))
632                     trigger_error("Required NumericSearchQuery->match variable $x not defined.", E_USER_ERROR);
633                 $this->_bind($variable[$p], $p);
634             } else {
635                 $this->_bind($variable, $p);
636             }
637         } else {
638             foreach ($p as $x) {
639                 if (!isset($variable[$x]))
640                     trigger_error("Required NumericSearchQuery->match variable $x not defined.", E_USER_ERROR);
641                 $this->_bind($variable[$x], $x);
642             }
643         }
644         if (!$this->_live_check()) // check returned an error
645             return false;
646         $search = $this->_workquery;
647         $result = false;
648         //if (DEBUG & _DEBUG_VERBOSE)
649         //    trigger_error("\$result = (boolean)($search);", E_USER_NOTICE);
650         // We might have a numerical problem:
651         // php-4.2.2 eval'ed as module: "9.636e+08 > 1000" false;
652         // php-5.1.2 cgi true, 4.2.2 cgi true
653         eval("\$result = (boolean)($search);");
654         if ($result and is_array($p)) {
655             return $this->_bound();
656         }
657         return $result;
658     }
659 }
660
661 ////////////////////////////////////////////////////////////////
662 //
663 // Remaining classes are private.
664 //
665 ////////////////////////////////////////////////////////////////
666 /**
667  * Virtual base class for nodes in a TextSearchQuery parse tree.
668  *
669  * Also serves as a 'VOID' (contentless) node.
670  */
671 class TextSearchQuery_node
672 {
673     public $op = 'VOID';
674     public $_op = 0;
675
676     /**
677      * Optimize this node.
678      * @return object Optimized node.
679      */
680     function optimize()
681     {
682         return $this;
683     }
684
685     /**
686      * @return regexp matching this node.
687      */
688     function regexp()
689     {
690         return '';
691     }
692
693     /**
694      * @param bool True if this node has been negated (higher in the parse tree.)
695      * @return array A list of all non-negated words contained by this node.
696      */
697     function highlight_words($negated = false)
698     {
699         return array();
700     }
701
702     function sql()
703     {
704         return $this->word;
705     }
706
707     function _sql_quote()
708     {
709         global $request;
710         $word = preg_replace('/(?=[%_\\\\])/', "\\", $this->word);
711         return $request->_dbi->_backend->qstr($word);
712     }
713 }
714
715 /**
716  * A word. Exact or substring?
717  */
718 class TextSearchQuery_node_word
719     extends TextSearchQuery_node
720 {
721     public $op = "WORD";
722     public $_op = TSQ_TOK_WORD;
723
724     function TextSearchQuery_node_word($word)
725     {
726         $this->word = $word;
727     }
728
729     function regexp()
730     {
731         return '(?=.*\b' . preg_quote($this->word, '/') . '\b)';
732     }
733
734     function highlight_words($negated = false)
735     {
736         return $negated ? array() : array($this->word);
737     }
738
739     function sql()
740     {
741         return '%' . $this->_sql_quote($this->word) . '%';
742     }
743 }
744
745 class TextSearchQuery_node_all
746     extends TextSearchQuery_node
747 {
748     public $op = "ALL";
749     public $_op = TSQ_TOK_ALL;
750
751     function regexp()
752     {
753         return '(?=.*)';
754     }
755
756     function sql()
757     {
758         return '%';
759     }
760 }
761
762 class TextSearchQuery_node_starts_with
763     extends TextSearchQuery_node_word
764 {
765     public $op = "STARTS_WITH";
766     public $_op = TSQ_TOK_STARTS_WITH;
767
768     function regexp()
769     {
770         return '(?=.*\b' . preg_quote($this->word, '/') . ')';
771     }
772
773     function sql()
774     {
775         return $this->_sql_quote($this->word) . '%';
776     }
777 }
778
779 // ^word: full phrase starts with
780 class TextSearchQuery_phrase_starts_with
781     extends TextSearchQuery_node_starts_with
782 {
783     function regexp()
784     {
785         return '(?=^' . preg_quote($this->word, '/') . ')';
786     }
787 }
788
789 class TextSearchQuery_node_ends_with
790     extends TextSearchQuery_node_word
791 {
792     public $op = "ENDS_WITH";
793     public $_op = TSQ_TOK_ENDS_WITH;
794
795     function regexp()
796     {
797         return '(?=.*' . preg_quote($this->word, '/') . '\b)';
798     }
799
800     function sql()
801     {
802         return '%' . $this->_sql_quote($this->word);
803     }
804 }
805
806 // word$: full phrase ends with
807 class TextSearchQuery_phrase_ends_with
808     extends TextSearchQuery_node_ends_with
809 {
810     function regexp()
811     {
812         return '(?=' . preg_quote($this->word, '/') . '$)';
813     }
814 }
815
816 class TextSearchQuery_node_exact
817     extends TextSearchQuery_node_word
818 {
819     public $op = "EXACT";
820     public $_op = TSQ_TOK_EXACT;
821
822     function regexp()
823     {
824         return '(?=\b' . preg_quote($this->word, '/') . '\b)';
825     }
826
827     function sql()
828     {
829         return $this->_sql_squote($this->word);
830     }
831 }
832
833 class TextSearchQuery_node_regex // posix regex. FIXME!
834     extends TextSearchQuery_node_word
835 {
836     public $op = "REGEX"; // using REGEXP or ~ extension
837     public $_op = TSQ_TOK_REGEX;
838
839     function regexp()
840     {
841         return '(?=.*\b' . $this->word . '\b)';
842     }
843
844     function sql()
845     {
846         return $this->_sql_quote($this->word);
847     }
848 }
849
850 class TextSearchQuery_node_regex_glob
851     extends TextSearchQuery_node_regex
852 {
853     public $op = "REGEX_GLOB";
854     public $_op = TSQ_TOK_REGEX_GLOB;
855
856     function regexp()
857     {
858         return '(?=.*\b' . glob_to_pcre($this->word) . '\b)';
859     }
860 }
861
862 class TextSearchQuery_node_regex_pcre // how to handle pcre modifiers? /i
863     extends TextSearchQuery_node_regex
864 {
865     public $op = "REGEX_PCRE";
866     public $_op = TSQ_TOK_REGEX_PCRE;
867
868     function regexp()
869     {
870         return $this->word;
871     }
872 }
873
874 class TextSearchQuery_node_regex_sql
875     extends TextSearchQuery_node_regex
876 {
877     public $op = "REGEX_SQL"; // using LIKE
878     public $_op = TSQ_TOK_REGEX_SQL;
879
880     function regexp()
881     {
882         return str_replace(array("/%/", "/_/"), array(".*", "."), $this->word);
883     }
884
885     function sql()
886     {
887         return $this->word;
888     }
889 }
890
891 /**
892  * A negated clause.
893  */
894 class TextSearchQuery_node_not
895     extends TextSearchQuery_node
896 {
897     public $op = "NOT";
898     public $_op = TSQ_TOK_NOT;
899
900     function TextSearchQuery_node_not($leaf)
901     {
902         $this->leaves = array($leaf);
903     }
904
905     function optimize()
906     {
907         $leaf = &$this->leaves[0];
908         $leaf = $leaf->optimize();
909         if ($leaf->_op == TSQ_TOK_NOT)
910             return $leaf->leaves[0]; // ( NOT ( NOT x ) ) -> x
911         return $this;
912     }
913
914     function regexp()
915     {
916         $leaf = &$this->leaves[0];
917         return '(?!' . $leaf->regexp() . ')';
918     }
919
920     function highlight_words($negated = false)
921     {
922         return $this->leaves[0]->highlight_words(!$negated);
923     }
924 }
925
926 /**
927  * Virtual base class for 'AND' and 'OR conjoins.
928  */
929 class TextSearchQuery_node_binop
930     extends TextSearchQuery_node
931 {
932     public $_op = TSQ_TOK_BINOP;
933
934     function TextSearchQuery_node_binop($leaves)
935     {
936         $this->leaves = $leaves;
937     }
938
939     function _flatten()
940     {
941         // This flattens e.g. (AND (AND a b) (OR c d) e)
942         //        to (AND a b e (OR c d))
943         $flat = array();
944         foreach ($this->leaves as $leaf) {
945             $leaf = $leaf->optimize();
946             if ($this->op == $leaf->op)
947                 $flat = array_merge($flat, $leaf->leaves);
948             else
949                 $flat[] = $leaf;
950         }
951         $this->leaves = $flat;
952     }
953
954     function optimize()
955     {
956         $this->_flatten();
957         assert(!empty($this->leaves));
958         if (count($this->leaves) == 1)
959             return $this->leaves[0]; // (AND x) -> x
960         return $this;
961     }
962
963     function highlight_words($negated = false)
964     {
965         $words = array();
966         foreach ($this->leaves as $leaf)
967             array_splice($words, 0, 0,
968                 $leaf->highlight_words($negated));
969         return $words;
970     }
971 }
972
973 /**
974  * A (possibly multi-argument) 'AND' conjoin.
975  */
976 class TextSearchQuery_node_and
977     extends TextSearchQuery_node_binop
978 {
979     public $op = "AND";
980
981     function optimize()
982     {
983         $this->_flatten();
984
985         // Convert (AND (NOT a) (NOT b) c d) into (AND (NOT (OR a b)) c d).
986         // Since OR's are more efficient for regexp matching:
987         //   (?!.*a)(?!.*b)  vs   (?!.*(?:a|b))
988
989         // Suck out the negated leaves.
990         $nots = array();
991         foreach ($this->leaves as $key => $leaf) {
992             if ($leaf->_op == TSQ_TOK_NOT) {
993                 $nots[] = $leaf->leaves[0];
994                 unset($this->leaves[$key]);
995             }
996         }
997
998         // Combine the negated leaves into a single negated or.
999         if ($nots) {
1000             $node = (new TextSearchQuery_node_not
1001             (new TextSearchQuery_node_or($nots)));
1002             array_unshift($this->leaves, $node->optimize());
1003         }
1004
1005         assert(!empty($this->leaves));
1006         if (count($this->leaves) == 1)
1007             return $this->leaves[0]; // (AND x) -> x
1008         return $this;
1009     }
1010
1011     /* FIXME!
1012      * Either we need all combinations of all words to be position independent,
1013      * or we have to use multiple match calls for each AND
1014      * (AND x y) => /(?(:x)(:y))|(?(:y)(:x))/
1015      */
1016     function regexp()
1017     {
1018         $regexp = '';
1019         foreach ($this->leaves as $leaf)
1020             $regexp .= $leaf->regexp();
1021         return $regexp;
1022     }
1023 }
1024
1025 /**
1026  * A (possibly multi-argument) 'OR' conjoin.
1027  */
1028 class TextSearchQuery_node_or
1029     extends TextSearchQuery_node_binop
1030 {
1031     public $op = "OR";
1032
1033     function regexp()
1034     {
1035         // We will combine any of our direct descendents which are WORDs
1036         // into a single (?=.*(?:word1|word2|...)) regexp.
1037
1038         $regexps = array();
1039         $words = array();
1040
1041         foreach ($this->leaves as $leaf) {
1042             if ($leaf->op == TSQ_TOK_WORD)
1043                 $words[] = preg_quote($leaf->word, '/');
1044             else
1045                 $regexps[] = $leaf->regexp();
1046         }
1047
1048         if ($words)
1049             array_unshift($regexps,
1050                 '(?=.*' . $this->_join($words) . ')');
1051
1052         return $this->_join($regexps);
1053     }
1054
1055     function _join($regexps)
1056     {
1057         assert(count($regexps) > 0);
1058
1059         if (count($regexps) > 1)
1060             return '(?:' . join('|', $regexps) . ')';
1061         else
1062             return $regexps[0];
1063     }
1064 }
1065
1066 ////////////////////////////////////////////////////////////////
1067 //
1068 // Parser:
1069 //   op's (and, or, not) are forced to lowercase in the tokenizer.
1070 //
1071 ////////////////////////////////////////////////////////////////
1072 class TextSearchQuery_Parser
1073 {
1074     /*
1075      * This is a simple recursive descent parser, based on the following grammar:
1076      *
1077      * toplist    :
1078      *        | toplist expr
1079      *        ;
1080      *
1081      *
1082      * list    : expr
1083      *        | list expr
1084      *        ;
1085      *
1086      * expr    : atom
1087      *        | expr BINOP atom
1088      *        ;
1089      *
1090      * atom    : '(' list ')'
1091      *        | NOT atom
1092      *        | WORD
1093      *        ;
1094      *
1095      * The terminal tokens are:
1096      *
1097      *
1098      * and|or          BINOP
1099      * -|not          NOT
1100      * (          LPAREN
1101      * )          RPAREN
1102      * /[^-()\s][^()\s]*  WORD
1103      * /"[^"]*"/      WORD
1104      * /'[^']*'/      WORD
1105      *
1106      * ^WORD              TextSearchQuery_phrase_starts_with
1107      * WORD*              STARTS_WITH
1108      * *WORD              ENDS_WITH
1109      * ^WORD$             EXACT
1110      * *                  ALL
1111      */
1112
1113     function parse($search_expr, $case_exact = false, $regex = TSQ_REGEX_AUTO)
1114     {
1115         $this->lexer = new TextSearchQuery_Lexer($search_expr, $case_exact, $regex);
1116         $this->_regex = $regex;
1117         $tree = $this->get_list('toplevel');
1118         // Assert failure when using the following URL in debug mode.
1119         // /TitleSearch?action=FullTextSearch&s=WFXSSProbe'")/>&case_exact=1&regex=sql
1120         //        assert($this->lexer->eof());
1121         unset($this->lexer);
1122         return $tree;
1123     }
1124
1125     function get_list($is_toplevel = false)
1126     {
1127         $list = array();
1128
1129         // token types we'll accept as words (and thus expr's) for the
1130         // purpose of error recovery:
1131         $accept_as_words = TSQ_TOK_NOT | TSQ_TOK_BINOP;
1132         if ($is_toplevel)
1133             $accept_as_words |= TSQ_TOK_LPAREN | TSQ_TOK_RPAREN;
1134
1135         while (($expr = $this->get_expr())
1136             || ($expr = $this->get_word($accept_as_words))) {
1137             $list[] = $expr;
1138         }
1139
1140         if (!$list) {
1141             if ($is_toplevel)
1142                 return new TextSearchQuery_node;
1143             else
1144                 return false;
1145         }
1146         if ($is_toplevel and count($list) == 1) {
1147             if ($this->lexer->query_str[0] == '^')
1148                 return new TextSearchQuery_phrase_starts_with($list[0]->word);
1149             else
1150                 return $list[0];
1151         }
1152         return new TextSearchQuery_node_and($list);
1153     }
1154
1155     function get_expr()
1156     {
1157         if (($expr = $this->get_atom()) === false) // protect against '0'
1158             return false;
1159
1160         $savedpos = $this->lexer->tell();
1161         // Bug#1791564: allow string '0'
1162         while (($op = $this->lexer->get(TSQ_TOK_BINOP)) !== false) {
1163             if (!($right = $this->get_atom())) {
1164                 break;
1165             }
1166
1167             if ($op == 'and')
1168                 $expr = new TextSearchQuery_node_and(array($expr, $right));
1169             else {
1170                 assert($op == 'or');
1171                 $expr = new TextSearchQuery_node_or(array($expr, $right));
1172             }
1173
1174             $savedpos = $this->lexer->tell();
1175         }
1176         $this->lexer->seek($savedpos);
1177
1178         return $expr;
1179     }
1180
1181     function get_atom()
1182     {
1183         if ($atom = $this->get_word(TSQ_ALLWORDS)) // Bug#1791564 not involved: '*'
1184             return $atom;
1185
1186         $savedpos = $this->lexer->tell();
1187         if ($this->lexer->get(TSQ_TOK_LPAREN)) {
1188             if (($list = $this->get_list()) && $this->lexer->get(TSQ_TOK_RPAREN)) {
1189                 return $list;
1190             } else {
1191                 // Fix Bug#1792170
1192                 // Handle " ( " or "(test" without closing ")" as plain word
1193                 $this->lexer->seek($savedpos);
1194                 return new TextSearchQuery_node_word($this->lexer->get(-1));
1195             }
1196         } elseif ($this->lexer->get(TSQ_TOK_NOT)) {
1197             if (($atom = $this->get_atom()))
1198                 return new TextSearchQuery_node_not($atom);
1199         }
1200         $this->lexer->seek($savedpos);
1201         return false;
1202     }
1203
1204     function get_word($accept = TSQ_ALLWORDS)
1205     {
1206         // Performance shortcut for ( and ). This is always false
1207         if (!empty($this->lexer->tokens[$this->lexer->pos])) {
1208             list ($type, $val) = $this->lexer->tokens[$this->lexer->pos];
1209             if ($type == TSQ_TOK_LPAREN or $type == TSQ_TOK_RPAREN)
1210                 return false;
1211         }
1212         foreach (array("WORD", "STARTS_WITH", "ENDS_WITH", "EXACT",
1213                      "REGEX", "REGEX_GLOB", "REGEX_PCRE", "ALL") as $tok) {
1214             $const = constant("TSQ_TOK_" . $tok);
1215             // Bug#1791564: allow word '0'
1216             if ($accept & $const and
1217                 (($word = $this->lexer->get($const)) !== false)
1218             ) {
1219                 // phrase or word level?
1220                 if ($tok == 'STARTS_WITH' and $this->lexer->query_str[0] == '^')
1221                     $classname = "TextSearchQuery_phrase_" . strtolower($tok);
1222                 elseif ($tok == 'ENDS_WITH' and
1223                     string_ends_with($this->lexer->query_str, '$')
1224                 )
1225                     $classname = "TextSearchQuery_phrase_" . strtolower($tok); else
1226                     $classname = "TextSearchQuery_node_" . strtolower($tok);
1227                 return new $classname($word);
1228             }
1229         }
1230         return false;
1231     }
1232 }
1233
1234 class TextSearchQuery_Lexer
1235 {
1236     function TextSearchQuery_Lexer($query_str, $case_exact = false,
1237                                    $regex = TSQ_REGEX_AUTO)
1238     {
1239         $this->tokens = $this->tokenize($query_str, $case_exact, $regex);
1240         $this->query_str = $query_str;
1241         $this->pos = 0;
1242     }
1243
1244     function tell()
1245     {
1246         return $this->pos;
1247     }
1248
1249     function seek($pos)
1250     {
1251         $this->pos = $pos;
1252     }
1253
1254     function eof()
1255     {
1256         return $this->pos == count($this->tokens);
1257     }
1258
1259     /**
1260      * TODO: support more regex styles, esp. prefer the forced ones over auto
1261      * re: and // stuff
1262      */
1263     function tokenize($string, $case_exact = false, $regex = TSQ_REGEX_AUTO)
1264     {
1265         $tokens = array();
1266         $buf = $case_exact ? ltrim($string) : strtolower(ltrim($string));
1267         while (!empty($buf)) {
1268             if (preg_match('/^([()])\s*/', $buf, $m)) {
1269                 $val = $m[1];
1270                 $type = $m[1] == '(' ? TSQ_TOK_LPAREN : TSQ_TOK_RPAREN;
1271             } // * => ALL
1272             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_GLOB)
1273                 and preg_match('/^\*\s*/', $buf, $m)
1274             ) {
1275                 $val = "*";
1276                 $type = TSQ_TOK_ALL;
1277             } // .* => ALL
1278             elseif ($regex & (TSQ_REGEX_PCRE)
1279                 and preg_match('/^\.\*\s*/', $buf, $m)
1280             ) {
1281                 $val = ".*";
1282                 $type = TSQ_TOK_ALL;
1283             } // % => ALL
1284             elseif ($regex & (TSQ_REGEX_SQL)
1285                 and preg_match('/^%\s*/', $buf, $m)
1286             ) {
1287                 $val = "%";
1288                 $type = TSQ_TOK_ALL;
1289             } // ^word
1290             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_PCRE)
1291                 and preg_match('/^\^([^-()][^()\s]*)\s*/', $buf, $m)
1292             ) {
1293                 $val = $m[1];
1294                 $type = TSQ_TOK_STARTS_WITH;
1295             } // word*
1296             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_GLOB)
1297                 and preg_match('/^([^-()][^()\s]*)\*\s*/', $buf, $m)
1298             ) {
1299                 $val = $m[1];
1300                 $type = TSQ_TOK_STARTS_WITH;
1301             } // *word
1302             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_GLOB)
1303                 and preg_match('/^\*([^-()][^()\s]*)\s*/', $buf, $m)
1304             ) {
1305                 $val = $m[1];
1306                 $type = TSQ_TOK_ENDS_WITH;
1307             } // word$
1308             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_PCRE)
1309                 and preg_match('/^([^-()][^()\s]*)\$\s*/', $buf, $m)
1310             ) {
1311                 $val = $m[1];
1312                 $type = TSQ_TOK_ENDS_WITH;
1313             } // ^word$
1314             elseif ($regex & (TSQ_REGEX_AUTO | TSQ_REGEX_POSIX | TSQ_REGEX_PCRE)
1315                 and preg_match('/^\^([^-()][^()\s]*)\$\s*/', $buf, $m)
1316             ) {
1317                 $val = $m[1];
1318                 $type = TSQ_TOK_EXACT;
1319             } elseif (preg_match('/^(and|or)\b\s*/i', $buf, $m)) {
1320                 $val = strtolower($m[1]);
1321                 $type = TSQ_TOK_BINOP;
1322             } elseif (preg_match('/^(-|not\b)\s*/i', $buf, $m)) {
1323                 $val = strtolower($m[1]);
1324                 $type = TSQ_TOK_NOT;
1325             } // "words "
1326             elseif (preg_match('/^ " ( (?: [^"]+ | "" )* ) " \s*/x', $buf, $m)) {
1327                 $val = str_replace('""', '"', $m[1]);
1328                 $type = TSQ_TOK_WORD;
1329             } // 'words '
1330             elseif (preg_match("/^ ' ( (?:[^']+|'')* ) ' \s*/x", $buf, $m)) {
1331                 $val = str_replace("''", "'", $m[1]);
1332                 $type = TSQ_TOK_WORD;
1333             } // word
1334             elseif (preg_match('/^([^-()][^()\s]*)\s*/', $buf, $m)) {
1335                 $val = $m[1];
1336                 $type = TSQ_TOK_WORD;
1337             } else {
1338                 assert(empty($buf));
1339                 break;
1340             }
1341             $buf = substr($buf, strlen($m[0]));
1342
1343             /* refine the simple parsing from above: bla*bla, bla?bla, ...
1344             if ($regex and $type == TSQ_TOK_WORD) {
1345                 if (substr($val,0,1) == "^")
1346                     $type = TSQ_TOK_STARTS_WITH;
1347                 elseif (substr($val,0,1) == "*")
1348                     $type = TSQ_TOK_ENDS_WITH;
1349                 elseif (substr($val,-1,1) == "*")
1350                     $type = TSQ_TOK_STARTS_WITH;
1351             }
1352             */
1353             $tokens[] = array($type, $val);
1354         }
1355         return $tokens;
1356     }
1357
1358     function get($accept)
1359     {
1360         if ($this->pos >= count($this->tokens))
1361             return false;
1362
1363         list ($type, $val) = $this->tokens[$this->pos];
1364         if (($type & $accept) == 0)
1365             return false;
1366
1367         $this->pos++;
1368         return $val;
1369     }
1370 }
1371
1372 // Local Variables:
1373 // mode: php
1374 // tab-width: 8
1375 // c-basic-offset: 4
1376 // c-hanging-comment-ender-p: nil
1377 // indent-tabs-mode: nil
1378 // End: