]> CyberLeo.Net >> Repos - SourceForge/phpwiki.git/blob - lib/TextSearchQuery.php
better support for case_exact search (not caseexact for consistency),
[SourceForge/phpwiki.git] / lib / TextSearchQuery.php
1 <?php rcs_id('$Id: TextSearchQuery.php,v 1.10 2004-11-23 15:17:16 rurban Exp $');
2 /**
3  * A text search query.
4  *
5  * This represents "Google-like" text search queries like:
6  * <dl>
7  * <dt> wiki -test
8  *   <dd> Match strings containing the substring 'wiki',  and not containing the
9  *        substring 'test'.
10  * <dt> wiki word or page
11  *   <dd> Match strings containing the substring 'wiki' and either the substring
12  *        'word' or the substring 'page'.
13  * </dl>
14  *
15  * The full query syntax, in order of precedence, is roughly:
16  *
17  * The unary 'NOT' or '-' operator (they are equivalent) negates the
18  * following search clause.
19  *
20  * Search clauses may be joined with the (left-associative) binary operators
21  * 'AND' and 'OR'.
22  *
23  * Two adjoining search clauses are joined with an implicit 'AND'.  This has
24  * lower precedence than either an explicit 'AND' or 'OR', so "a b OR c"
25  * parses as "a AND ( b OR c )", while "a AND b OR c" parses as
26  * "( a AND b ) OR c" (due to the left-associativity of 'AND' and 'OR'.)
27  *
28  * Search clauses can be grouped with parentheses.
29  *
30  * Phrases (or other things which don't look like words) can be forced to
31  * be interpreted as words by quoting them, either with single (') or double (")
32  * quotes.  If you wan't to include the quote character within a quoted string,
33  * double-up on the quote character: 'I''m hungry' is equivalent to
34  * "I'm hungry".
35  *
36  * FIXME: Clarify wildcards usage. none, glob-style, SQL-style, pcre-style, regex-style
37  * This only converts to PCRE, not to ANSI-SQL or other SQL functions.
38  * But see makeSqlClause().
39  *
40  * @author: Jeff Dairiki
41  */
42 class TextSearchQuery {
43     /**
44      * Create a new query.
45      *
46      * @param $search_query string The query.  Syntax is as described above.
47      * Note that an empty $search_query will match anything.
48      * @see TextSearchQuery
49      * TODO: support $regex arg
50      */
51     function TextSearchQuery($search_query, $case_exact=false, $regex=false) {
52         $parser = new TextSearchQuery_Parser;
53         $this->_tree = $parser->parse($search_query, $case_exact);
54         $this->_case_exact = $case_exact;
55         $this->_isregex = $regex;
56         $this->_optimize();
57     }
58
59     function _optimize() {
60         $this->_tree = $this->_tree->optimize();
61     }
62
63     /**
64      * Get a regexp which matches the query.
65      */
66     function asRegexp() {
67         if (!isset($this->_regexp)) {
68             if ($this->_isregex) // already regex? glob or pcre
69                 $this->_regexp =  '/' . $this->_tree->regexp() . '/'.($this->_case_exact?'':'i').'sS';
70             else
71                 $this->_regexp =  '/^' . $this->_tree->regexp() . '/'.($this->_case_exact?'':'i').'sS';
72         }
73         return $this->_regexp;
74     }
75
76     /**
77      * Match query against string.
78      *
79      * @param $string string The string to match. 
80      * @return boolean True if the string matches the query.
81      */
82     function match($string) {
83         return preg_match($this->asRegexp(), $string);
84     }
85
86     
87     /**
88      * Get a regular expression suitable for highlighting matched words.
89      *
90      * This returns a PCRE regular expression which matches any non-negated
91      * word in the query.
92      *
93      * @return string The PCRE regexp.
94      */
95     function getHighlightRegexp() {
96         if (!isset($this->_hilight_regexp)) {
97             $words = array_unique($this->_tree->highlight_words());
98             if (!$words) {
99                 $this->_hilight_regexp = false;
100             }
101             else {
102                 foreach ($words as $key => $word)
103                     $words[$key] = preg_quote($word, '/');
104                 $this->_hilight_regexp = '(?:' . join('|', $words) . ')';
105             }
106         }
107         return $this->_hilight_regexp;
108     }
109
110     /**
111      * Make an SQL clause which matches the query.
112      *
113      * @param $make_sql_clause_cb WikiCallback
114      * A callback which takes a single word as an argument and
115      * returns an SQL clause which will match exactly those records
116      * containing the word.  The word passed to the callback will always
117      * be in all lower case.
118      *
119      * TODO: support db-specific extensions, like MATCH AGAINST or REGEX
120      *       mysql => 4.0.1 can also do Google: MATCH AGAINST IN BOOLEAN MODE
121      *       How? WikiDB backend method?
122      *       Case-sensitivity option.
123      *
124      * Example usage:
125      * <pre>
126      *     function sql_title_match($word) {
127      *         return sprintf("LOWER(title) like '%s'",
128      *                        addslashes($word));
129      *     }
130      *
131      *     ...
132      *
133      *     $query = new TextSearchQuery("wiki -page");
134      *     $cb = new WikiFunctionCb('sql_title_match');
135      *     $sql_clause = $query->makeSqlClause($cb);
136      * </pre>
137      * This will result in $sql_clause containing something like
138      * "(LOWER(title) like 'wiki') AND NOT (LOWER(title) like 'page')".
139      *
140      * @return string The PCRE regexp.
141      */
142     function makeSqlClause($make_sql_clause_cb) {
143         $this->_sql_clause_cb = $make_sql_clause_cb;
144         return $this->_sql_clause($this->_tree);
145     }
146
147     function _sql_clause($node) {
148         switch ($node->op) {
149         case 'WORD':
150             return $this->_sql_clause_cb->call($node->word);
151         case 'NOT':
152             return "NOT (" . $this->_sql_clause($node->leaves[0]) . ")";
153         case 'AND':
154         case 'OR':
155             $subclauses = array();
156             foreach ($node->leaves as $leaf)
157                 $subclauses[] = "(" . $this->_sql_clause($leaf) . ")";
158             return join(" $node->op ", $subclauses);
159         default:
160             assert($node->op == 'VOID');
161             return '1=1';
162         }
163     }
164
165     /**
166      * Get printable representation of the parse tree.
167      *
168      * This is for debugging only.
169      * @return string Printable parse tree.
170      */
171     function asString() {
172         return $this->_as_string($this->_tree);
173     }
174
175     function _as_string($node, $indent = '') {
176         switch ($node->op) {
177         case 'WORD':
178             return $indent . "WORD: $node->word";
179         case 'VOID':
180             return $indent . "VOID";
181         default:
182             $lines = array($indent . $node->op . ":");
183             $indent .= "  ";
184             foreach ($node->leaves as $leaf)
185                 $lines[] = $this->_as_string($leaf, $indent);
186             return join("\n", $lines);
187         }
188     }
189 }
190
191 /**
192  * This is a TextSearchQuery which matches nothing.
193  */
194 class NullTextSearchQuery extends TextSearchQuery {
195     /**
196      * Create a new query.
197      *
198      * @see TextSearchQuery
199      */
200     function NullTextSearchQuery() {}
201     function asRegexp()         { return '/^(?!a)a/x'; }
202     function match($string)     { return false; }
203     function getHighlightRegexp() { return ""; }
204     function makeSqlClause($make_sql_clause_cb) { return "(1 = 0)"; }
205     function asString() { return "NullTextSearchQuery"; }
206 };
207
208
209 ////////////////////////////////////////////////////////////////
210 //
211 // Remaining classes are private.
212 //
213 ////////////////////////////////////////////////////////////////
214 /**
215  * Virtual base class for nodes in a TextSearchQuery parse tree.
216  *
217  * Also servers as a 'VOID' (contentless) node.
218  */
219 class TextSearchQuery_node
220 {
221     var $op = 'VOID';
222
223     /**
224      * Optimize this node.
225      * @return object Optimized node.
226      */
227     function optimize() {
228         return $this;
229     }
230
231     /**
232      * @return regexp matching this node.
233      */
234     function regexp() {
235         return '';
236     }
237
238     /**
239      * @param bool True if this node has been negated (higher in the parse tree.)
240      * @return array A list of all non-negated words contained by this node.
241      */
242     function highlight_words($negated = false) {
243         return array();
244     }
245 }
246
247 /**
248  * A word.
249  */
250 class TextSearchQuery_node_word
251 extends TextSearchQuery_node
252 {
253     var $op = "WORD";
254     
255     function TextSearchQuery_node_word($word) {
256         $this->word = $word;
257     }
258
259     function regexp() {
260         return '(?=.*' . preg_quote($this->word, '/') . ')';
261     }
262
263     function highlight_words($negated = false) {
264         return $negated ? array() : array($this->word);
265     }
266 }
267
268
269 /**
270  * A negated clause.
271  */
272 class TextSearchQuery_node_not
273 extends TextSearchQuery_node
274 {
275     var $op = "NOT";
276     
277     function TextSearchQuery_node_not($leaf) {
278         $this->leaves = array($leaf);
279     }
280
281     function optimize() {
282         $leaf = &$this->leaves[0];
283         $leaf = $leaf->optimize();
284         if ($leaf->op == 'NOT')
285             return $leaf->leaves[0]; // ( NOT ( NOT x ) ) -> x
286         return $this;
287     }
288     
289     function regexp() {
290         $leaf = &$this->leaves[0];
291         return '(?!' . $leaf->regexp() . ')';
292     }
293
294     function highlight_words($negated = false) {
295         return $this->leaves[0]->highlight_words(!$negated);
296     }
297 }
298
299 /**
300  * Virtual base class for 'AND' and 'OR conjoins.
301  */
302 class TextSearchQuery_node_binop
303 extends TextSearchQuery_node
304 {
305     function TextSearchQuery_node_binop($leaves) {
306         $this->leaves = $leaves;
307     }
308
309     function _flatten() {
310         // This flattens e.g. (AND (AND a b) (OR c d) e)
311         //        to (AND a b e (OR c d))
312         $flat = array();
313         foreach ($this->leaves as $leaf) {
314             $leaf = $leaf->optimize();
315             if ($this->op == $leaf->op)
316                 $flat = array_merge($flat, $leaf->leaves);
317             else
318                 $flat[] = $leaf;
319         }
320         $this->leaves = $flat;
321     }
322
323     function optimize() {
324         $this->_flatten();
325         assert(!empty($this->leaves));
326         if (count($this->leaves) == 1)
327             return $this->leaves[0]; // (AND x) -> x
328         return $this;
329     }
330
331     function highlight_words($negated = false) {
332         $words = array();
333         foreach ($this->leaves as $leaf)
334             array_splice($words,0,0,
335                          $leaf->highlight_words($negated));
336         return $words;
337     }
338 }
339
340 /**
341  * A (possibly multi-argument) 'AND' conjoin.
342  */
343 class TextSearchQuery_node_and
344 extends TextSearchQuery_node_binop
345 {
346     var $op = "AND";
347     
348     function optimize() {
349         $this->_flatten();
350
351         // Convert (AND (NOT a) (NOT b) c d) into (AND (NOT (OR a b)) c d).
352         // Since OR's are more efficient for regexp matching:
353         //   (?!.*a)(?!.*b)  vs   (?!.*(?:a|b))
354
355         // Suck out the negated leaves.
356         $nots = array();
357         foreach ($this->leaves as $key => $leaf) {
358             if ($leaf->op == 'NOT') {
359                 $nots[] = $leaf->leaves[0];
360                 unset($this->leaves[$key]);
361             }
362         }
363
364         // Combine the negated leaves into a single negated or.
365         if ($nots) {
366             $node = ( new TextSearchQuery_node_not
367                       (new TextSearchQuery_node_or($nots)) );
368             array_unshift($this->leaves, $node->optimize());
369         }
370         
371         assert(!empty($this->leaves));
372         if (count($this->leaves) == 1)
373             return $this->leaves[0];  // (AND x) -> x
374         return $this;
375     }
376
377     function regexp() {
378         $regexp = '';
379         foreach ($this->leaves as $leaf)
380             $regexp .= $leaf->regexp();
381         return $regexp;
382     }
383 }
384
385 /**
386  * A (possibly multi-argument) 'OR' conjoin.
387  */
388 class TextSearchQuery_node_or
389 extends TextSearchQuery_node_binop
390 {
391     var $op = "OR";
392
393     function regexp() {
394         // We will combine any of our direct descendents which are WORDs
395         // into a single (?=.*(?:word1|word2|...)) regexp.
396         
397         $regexps = array();
398         $words = array();
399
400         foreach ($this->leaves as $leaf) {
401             if ($leaf->op == 'WORD')
402                 $words[] = preg_quote($leaf->word, '/');
403             else
404                 $regexps[] = $leaf->regexp();
405         }
406
407         if ($words)
408             array_unshift($regexps,
409                           '(?=.*' . $this->_join($words) . ')');
410
411         return $this->_join($regexps);
412     }
413
414     function _join($regexps) {
415         assert(count($regexps) > 0);
416
417         if (count($regexps) > 1)
418             return '(?:' . join('|', $regexps) . ')';
419         else
420             return $regexps[0];
421     }
422 }
423
424
425 ////////////////////////////////////////////////////////////////
426 //
427 // Parser:
428 //
429 ////////////////////////////////////////////////////////////////
430 define ('TSQ_TOK_WORD',   1);
431 define ('TSQ_TOK_BINOP',  2);
432 define ('TSQ_TOK_NOT',    4);
433 define ('TSQ_TOK_LPAREN', 8);
434 define ('TSQ_TOK_RPAREN', 16);
435
436 class TextSearchQuery_Parser 
437 {
438     /*
439      * This is a simple recursive descent parser, based on the following grammar:
440      *
441      * toplist  :
442      *          | toplist expr
443      *          ;
444      *
445      *
446      * list     : expr
447      *          | list expr
448      *          ;
449      *
450      * expr     : atom
451      *          | expr BINOP atom
452      *          ;
453      *
454      * atom     : '(' list ')'
455      *          | NOT atom
456      *          | WORD
457      *          ;
458      *
459      * The terminal tokens are:
460      *
461      *
462      * and|or           BINOP
463      * -|not            NOT
464      * (                LPAREN
465      * )                RPAREN
466      * [^-()\s][^()\s]* WORD
467      * "[^"]*"          WORD
468      * '[^']*'          WORD
469      */
470
471     function parse ($search_expr, $case_exact=false) {
472         $this->lexer = new TextSearchQuery_Lexer($search_expr, $case_exact);
473         $tree = $this->get_list('toplevel');
474         assert($this->lexer->eof());
475         unset($this->lexer);
476         return $tree;
477     }
478     
479     function get_list ($is_toplevel = false) {
480         $list = array();
481
482         // token types we'll accept as words (and thus expr's) for the
483         // purpose of error recovery:
484         $accept_as_words = TSQ_TOK_NOT | TSQ_TOK_BINOP;
485         if ($is_toplevel)
486             $accept_as_words |= TSQ_TOK_LPAREN | TSQ_TOK_RPAREN;
487         
488         while ( ($expr = $this->get_expr())
489                 || ($expr = $this->get_word($accept_as_words)) ) {
490             
491             $list[] = $expr;
492         }
493
494         if (!$list) {
495             if ($is_toplevel)
496                 return new TextSearchQuery_node;
497             else
498                 return false;
499         }
500         return new TextSearchQuery_node_and($list);
501     }
502
503     function get_expr () {
504         if ( !($expr = $this->get_atom()) )
505             return false;
506         
507         $savedpos = $this->lexer->tell();
508         while ( ($op = $this->lexer->get(TSQ_TOK_BINOP)) ) {
509             if ( ! ($right = $this->get_atom()) ) {
510                 break;
511             }
512             
513             if ($op == 'and')
514                 $expr = new TextSearchQuery_node_and(array($expr, $right));
515             else {
516                 assert($op == 'or');
517                 $expr = new TextSearchQuery_node_or(array($expr, $right));
518             }
519
520             $savedpos = $this->lexer->tell();
521         }
522         $this->lexer->seek($savedpos);
523
524         return $expr;
525     }
526     
527
528     function get_atom() {
529         if ($word = $this->get_word())
530             return $word;
531
532         $savedpos = $this->lexer->tell();
533         if ( $this->lexer->get(TSQ_TOK_LPAREN) ) {
534             if ( ($list = $this->get_list()) && $this->lexer->get(TSQ_TOK_RPAREN) )
535                 return $list;
536         }
537         elseif ( $this->lexer->get(TSQ_TOK_NOT) ) {
538             if ( ($atom = $this->get_atom()) )
539                 return new TextSearchQuery_node_not($atom);
540         }
541         $this->lexer->seek($savedpos);
542         return false;
543     }
544
545     function get_word($accept = TSQ_TOK_WORD) {
546         if ( ($word = $this->lexer->get($accept)) )
547             return new TextSearchQuery_node_word($word);
548         return false;
549     }
550 }
551
552 class TextSearchQuery_Lexer {
553     function TextSearchQuery_Lexer ($query_str, $case_exact=false) {
554         $this->tokens = $this->tokenize($query_str, $case_exact);
555         $this->pos = 0;
556     }
557
558     function tell() {
559         return $this->pos;
560     }
561
562     function seek($pos) {
563         $this->pos = $pos;
564     }
565
566     function eof() {
567         return $this->pos == count($this->tokens);
568     }
569
570     function tokenize($string, $case_exact=false) {
571         $tokens = array();
572         $buf = $case_exact ? ltrim($string) : strtolower(ltrim($string));
573         while (!empty($buf)) {
574             if (preg_match('/^(and|or)\b\s*/i', $buf, $m)) {
575                 $val = strtolower($m[1]);
576                 $type = TSQ_TOK_BINOP;
577             }
578             elseif (preg_match('/^(-|not\b)\s*/i', $buf, $m)) {
579                 $val = strtolower($m[1]);
580                 $type = TSQ_TOK_NOT;
581             }
582             elseif (preg_match('/^([()])\s*/', $buf, $m)) {
583                 $val = $m[1];
584                 $type = $m[1] == '(' ? TSQ_TOK_LPAREN : TSQ_TOK_RPAREN;
585             }
586             elseif (preg_match('/^ " ( (?: [^"]+ | "" )* ) " \s*/x', $buf, $m)) {
587                 $val = str_replace('""', '"', $m[1]);
588                 $type = TSQ_TOK_WORD;
589             }
590             elseif (preg_match("/^ ' ( (?:[^']+|'')* ) ' \s*/x", $buf, $m)) {
591                 $val = str_replace("''", "'", $m[1]);
592                 $type = TSQ_TOK_WORD;
593             }
594             elseif (preg_match('/^([^-()][^()\s]*)\s*/', $buf, $m)) {
595                 $val = $m[1];
596                 $type = TSQ_TOK_WORD;
597             }
598             else {
599                 assert(empty($buf));
600                 break;
601             }
602             $buf = substr($buf, strlen($m[0]));
603             $tokens[] = array($type, $val);
604         }
605         return $tokens;
606     }
607     
608     function get($accept) {
609         if ($this->pos >= count($this->tokens))
610             return false;
611         
612         list ($type, $val) = $this->tokens[$this->pos];
613         if (($type & $accept) == 0)
614             return false;
615         
616         $this->pos++;
617         return $val;
618     }
619 }
620
621 // Local Variables:
622 // mode: php
623 // tab-width: 8
624 // c-basic-offset: 4
625 // c-hanging-comment-ender-p: nil
626 // indent-tabs-mode: nil
627 // End:   
628 ?>