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