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