/[drupal]/contributions/modules/faceted_search/faceted_search.inc
ViewVC logotype

Contents of /contributions/modules/faceted_search/faceted_search.inc

Parent Directory Parent Directory | Revision Log Revision Log | View Revision Graph Revision Graph


Revision 1.57 - (show annotations) (download) (as text)
Sun Jan 4 19:36:25 2009 UTC (10 months, 3 weeks ago) by davidlesieur
Branch: MAIN
CVS Tags: DRUPAL-6--1-0-BETA2, DRUPAL-6--1-0-BETA1, HEAD
Changes since 1.56: +122 -60 lines
File MIME type: text/x-php
Improved data model for search environment settings.
1 <?php
2 // $Id: faceted_search.inc,v 1.56 2008/09/28 01:04:28 davidlesieur Exp $
3
4 /**
5 * @file
6 * Provides base classes for implementing filters and facets, and classes needed
7 * by other modules.
8 */
9
10 /**
11 * The base class for filters.
12 *
13 * Filters actually impact results only when they have an active category (a
14 * "category" is a filtering value). The filtering is delegated to the active
15 * category.
16 */
17 class faceted_search_filter {
18
19 /**
20 * The key identifying this class of filter. Keys are used in the form of
21 * 'key:text' tokens in the search text.
22 */
23 var $_key = '';
24
25 /**
26 * The status of this filter.
27 */
28 var $_status = FALSE;
29
30 /**
31 * The weight of this filter, for sorting purposes.
32 */
33 var $_weight = 0;
34
35 /**
36 * An array representing the path of categories leading to the active category
37 * of this facet. This path includes the active category itself.
38 */
39 var $_path = array();
40
41 /**
42 * Constructor.
43 *
44 * @param $key
45 * Key corresponding to this class of filter. This should be the same string
46 * as used to construct the filter from the search text in the module's
47 * implementation of hook_faceted_search_parse().
48 * @param $active_path
49 * Array representing the path leading to the active category, including the
50 * active category itself. Defaults to an empty array, meaning no active
51 * category.
52 */
53 function faceted_search_filter($key, $active_path = array()) {
54 $this->_key = $key;
55 $this->_path = $active_path;
56 }
57
58 /**
59 * Return TRUE if this filter offers browsable categories, or FALSE otherwise.
60 */
61 function is_browsable() {
62 return FALSE;
63 }
64
65 /**
66 * Assign settings to this filter.
67 *
68 * @param $settings
69 * Array of settings.
70 */
71 function set($settings) {
72 if (isset($settings['status'])) {
73 $this->_status = $settings['status'];
74 }
75 if (isset($settings['weight'])) {
76 $this->_weight = $settings['weight'];
77 }
78 }
79
80 /**
81 * Return the key for this class of filter.
82 */
83 function get_key() {
84 return $this->_key;
85 }
86
87 /**
88 * Return a help text for site administrators.
89 */
90 function get_help() {
91 return '';
92 }
93
94 /**
95 * Return the status of this filter.
96 *
97 * @return
98 * TRUE when the filter is enabled, FALSE otherwise.
99 */
100 function get_status() {
101 return $this->_status;
102 }
103
104 /**
105 * Change the status of this filter.
106 *
107 * @param $status
108 * TRUE to enable the filter, FALSE to disable it.
109 */
110 function set_status($status) {
111 $this->_status = $status;
112 }
113
114 /**
115 * Return the configured weight of this filter, for sorting purposes.
116 */
117 function get_weight() {
118 return $this->_weight;
119 }
120
121 /**
122 * Assign the weight of this filter.
123 */
124 function set_weight($weight) {
125 $this->_weight = $weight;
126 }
127
128 /**
129 * Return TRUE if this facet has an active category. If a facet is active, it
130 * normally means that it is used in the current search.
131 */
132 function is_active() {
133 return count($this->_path) > 0;
134 }
135
136 /**
137 * Return an array representing the path to the active category, including the
138 * active category itself. Return an empty array if there is no active
139 * category.
140 */
141 function get_active_path() {
142 return $this->_path;
143 }
144
145 /**
146 * Set the path of the active category, including the active category itself.
147 *
148 * @param $path
149 * The path of the category (array of categories). Defaults to no active
150 * path.
151 */
152 function set_active_path($path = array()) {
153 $this->_path = $path;
154 }
155
156 /**
157 * Return the active category, or NULL if there is no active category.
158 */
159 function get_active_category() {
160 return end($this->_path);
161 }
162
163 /**
164 * Append keywords used by this filter into the specified array.
165 */
166 function get_keywords(&$keywords) {
167 // Does nothing by default.
168 }
169 }
170
171 /**
172 * Base class for facet categories.
173 */
174 class faceted_search_category {
175 /**
176 * The number of nodes associated to this category.
177 */
178 var $_count = NULL;
179
180 /**
181 * Constructor.
182 *
183 * @param $count
184 * The number of nodes associated to this category within the current
185 * search.
186 */
187 function faceted_search_category($count = NULL) {
188 $this->_count = $count;
189 }
190
191 /**
192 * Return the number of nodes associated to this category within the current
193 * search.
194 *
195 * @return The number of matching nodes, or NULL is count is unknown.
196 */
197 function get_count() {
198 return $this->_count;
199 }
200
201 /**
202 * Return weight of this category, for sorting purposes.
203 */
204 function get_weight() {
205 return 0;
206 }
207
208 /**
209 * Updates a query for retrieving the subcategories of this category and their
210 * associated nodes within the current search results.
211 *
212 * This only needs to be overridden for hierarchical facets.
213 *
214 * @param $query
215 * The query object to update.
216 * @return
217 * FALSE if this facet can't have subcategories.
218 */
219 function build_subcategories_query(&$query) {
220 return FALSE;
221 }
222 }
223
224 /**
225 * The parent class for facets.
226 *
227 * A facet is a filter with browsable categories.
228 */
229 class faceted_search_facet extends faceted_search_filter {
230
231 /**
232 * The current sort criteria to use for this facet. This determines how to
233 * sort the facet's categories.
234 */
235 var $_sort = 'count';
236
237 /**
238 * The maximum number of categories to show in this facet.
239 */
240 var $_max_categories = 10;
241
242 /**
243 * Constructor.
244 *
245 * @param $key
246 * Key corresponding to this class of facet. This should be the same string
247 * as used to construct the facet from the search text in the module's
248 * implementation of hook_faceted_search_parse().
249 */
250 function faceted_search_facet($key, $active_path = array()) {
251 parent::faceted_search_filter($key, $active_path);
252 }
253
254 /**
255 * Return TRUE if this filter offers browsable categories, or FALSE otherwise.
256 *
257 * A browsable filter implies that categories retrieval and sorting methods
258 * are available.
259 */
260 function is_browsable() {
261 return TRUE;
262 }
263
264 /**
265 * Assign settings to this facet.
266 *
267 * @param $settings
268 * Array of settings.
269 */
270 function set($settings) {
271 parent::set($settings);
272 if (isset($settings['sort'])) {
273 $this->_sort = $settings['sort'];
274 }
275 if (isset($settings['max_categories'])) {
276 $this->_max_categories = $settings['max_categories'];
277 }
278 }
279
280 /**
281 * Return the available sort options for this facet. Each option is a key =>
282 * label pair.
283 *
284 * Each key must have a corresponding handler method in the form
285 * 'build_sort_query_key'.
286 */
287 function get_sort_options() {
288 return array('count' => t('Count'));
289 }
290
291 /**
292 * Return the current sort criteria for this facet.
293 */
294 function get_sort() {
295 return $this->_sort;
296 }
297
298 /**
299 * Assigns the current sort criteria for this facet.
300 */
301 function set_sort($sort) {
302 // Assign value only if a corresponding handler exists.
303 if (method_exists($this, 'build_sort_query_'. $sort)) {
304 $this->_sort = $sort;
305 }
306 }
307
308 /**
309 * Handler for the 'count' sort criteria.
310 */
311 function build_sort_query_count(&$query) {
312 $query->add_orderby('count', 'DESC');
313 }
314
315 /**
316 * Applies the facet's current sort option to the given query.
317 */
318 function build_sort_query(&$query) {
319 $method = 'build_sort_query_'. $this->_sort;
320 if (method_exists($this, $method)) {
321 $this->$method($query);
322 }
323 }
324
325 /**
326 * Return the configured maximum number of categories to show in this facet.
327 *
328 * @return
329 * The maximum number of categories, or 0 for no limit.
330 */
331 function get_max_categories() {
332 return $this->_max_categories;
333 }
334
335 /**
336 * Assign the maximum number of categories to show in this facet.
337 *
338 * @param $max_categories
339 * The maximum number of categories, or 0 for no limit.
340 */
341 function set_max_categories($max_categories) {
342 $this->_max_categories = $max_categories;
343 }
344
345 /**
346 * Updates a query for retrieving the root categories of this filter and their
347 * associated nodes within the current search results.
348 *
349 * @param $query
350 * The query object to update.
351 * @return
352 * FALSE if this filter can't have root categories.
353 */
354 function build_root_categories_query() {
355 return FALSE;
356 }
357
358 /**
359 * This factory method creates categories given query results that include the
360 * fields selected in get_root_categories_query() or get_subcategories_query().
361 *
362 * @param $results
363 * $results A database query result resource.
364 * @return
365 * Array of categories.
366 */
367 function build_categories($results) {
368 return array();
369 }
370
371 /**
372 * Inject components into the query for selecting nodes matching this facet's
373 * active category.
374 *
375 * @param $query
376 * Query to inject the components into.
377 * @param $words
378 * Array keyed by search index type, each element being an array of positive
379 * words to lookup for that index type. This method should insert any words
380 * it cares about.
381 * @param $matches
382 * Minimum number of words that should match in query results for each index type.
383 */
384 function build_results_query(&$query, &$words, &$matches) {
385 // Note: Facets ignore $words and $matches.
386 if ($category = $this->get_active_category()) {
387 $category->build_results_query($query);
388 }
389 }
390 }
391
392 /**
393 * The base class of keyword categories.
394 */
395 class faceted_search_keyword_category {
396
397 /**
398 * Append keywords used by this category into the specified array.
399 */
400 function get_keywords(&$keywords) {
401 // Does nothing by default.
402 }
403
404 /**
405 * Check whether a given word is allowed for searching.
406 *
407 * @return
408 * The allowed word, or NULL if it is not allowed.
409 */
410 function check_word($word) {
411 if (is_numeric($word)) {
412 return (int)ltrim($word, '-0');
413 }
414 return $word;
415 }
416
417 /**
418 * Prepare a label for output.
419 */
420 function check_label($label, $html = FALSE) {
421 if (!$html) {
422 return strip_tags($label);
423 }
424 return $label;
425 }
426 }
427
428 /**
429 * The keyword AND category.
430 */
431 class faceted_search_keyword_and_category extends faceted_search_keyword_category {
432 var $_word = '';
433
434 /**
435 * Constructor.
436 *
437 * @param $phrase
438 * String containing the word to search.
439 */
440 function faceted_search_keyword_and_category($word) {
441 $this->_word = $word;
442 }
443
444 /**
445 * Return the label for this category.
446 *
447 * @param $html
448 * TRUE when HTML is allowed in the label, FALSE otherwise.
449 */
450 function get_label($html = FALSE) {
451 return $this->check_label(theme('faceted_search_keyword_and_label', $this->_word), $html);
452 }
453
454 /**
455 * Return the search text for this category.
456 */
457 function get_text() {
458 return $this->_word;
459 }
460
461 /**
462 * Append keywords used by this category into the specified array.
463 */
464 function get_keywords(&$keywords) {
465 $keywords[] = $this->_word;
466 }
467
468 /**
469 * Return the weight of this category, for sorting purposes.
470 */
471 function get_weight() {
472 return 0;
473 }
474
475 /**
476 * Inject components into the query for selecting nodes matching this category.
477 *
478 * @param $query
479 * Query to inject the components into.
480 * @param $words
481 * Array keyed by search index type, each element being an array of positive
482 * words to lookup for that index type. This method should insert any words
483 * it cares about.
484 * @param $matches
485 * Minimum number of words that should match in query results for each index type.
486 * @param $type
487 * Type of search index entry to be searched.
488 */
489 function build_results_query(&$query, &$words, &$matches, $type) {
490 if (($word = $this->check_word($this->_word)) && !isset($words[$type][$word])) {
491 if (strlen($word) >= variable_get('minimum_word_size', 3)) {
492 $words[$type][$word] = $word;
493 $matches[$type]++;
494 }
495 else {
496 // Short words are only searched against the dataset.
497 $query->enable_part("{$type}_search_dataset");
498 // Ensure this type will be searched even though it has no "long" word.
499 if (!isset($words[$type])) {
500 $words[$type] = array();
501 }
502 }
503
504 // The dataset will have to be looked up as well if the query becomes more
505 // complex because of other keyword search operators.
506 $query->set_current_part("{$type}_search_dataset");
507 $query->add_where("{$type}_search_dataset.data LIKE '%% %s %%'", $word);
508 $query->set_current_part(); // Back to default part.
509 }
510 }
511 }
512
513 /**
514 * The keyword phrase category.
515 */
516 class faceted_search_keyword_phrase_category extends faceted_search_keyword_category {
517 var $_phrase = '';
518
519 /**
520 * Constructor.
521 *
522 * @param $phrase
523 * String containing the phrase to search.
524 */
525 function faceted_search_keyword_phrase_category($phrase) {
526 $this->_phrase = $phrase;
527 }
528
529 /**
530 * Return the label for this category.
531 *
532 * @param $html
533 * TRUE when HTML is allowed in the label, FALSE otherwise.
534 */
535 function get_label($html = FALSE) {
536 return $this->check_label(theme('faceted_search_keyword_phrase_label', $this->_phrase), $html);
537 }
538
539 /**
540 * Return the search text for this operator.
541 */
542 function get_text() {
543 return '"'. $this->_phrase .'"';
544 }
545
546 /**
547 * Append keywords used by this category into the specified array.
548 */
549 function get_keywords(&$keywords) {
550 $keywords[] = $this->_phrase;
551 }
552
553 /**
554 * Return the weight of this category, for sorting purposes.
555 */
556 function get_weight() {
557 return 1;
558 }
559
560 /**
561 * Inject components into the query for selecting nodes matching this category.
562 *
563 * @param $query
564 * Query to inject the components into.
565 * @param $words
566 * Array keyed by search index type, each element being an array of positive
567 * words to lookup for that index type. This method should insert any words
568 * it cares about.
569 * @param $matches
570 * Minimum number of words that should match in query results for each index type.
571 * @param $type
572 * Type of search index entry to be searched.
573 */
574 function build_results_query(&$query, &$words, &$matches, $type) {
575 $split = explode(' ', $this->_phrase);
576 foreach ($split as $word) {
577 if ($word = $this->check_word($word)) {
578 $words[$type][$word] = $word;
579 }
580 }
581 if (count($split) > 0) {
582 $matches[$type]++; // A phrase counts as one match.
583
584 if (count($split) > 1) {
585 // Real phrase. We'll have to verify it against the dataset.
586 $query->enable_part("{$type}_search_dataset");
587 }
588
589 // Add phrase match conditions.
590 $query->set_current_part("{$type}_search_dataset");
591 $query->add_where("{$type}_search_dataset.data LIKE '%% %s %%'", $this->_phrase);
592 $query->set_current_part(); // Back to default part.
593 }
594 }
595 }
596
597 /**
598 * The keyword OR category.
599 */
600 class faceted_search_keyword_or_category extends faceted_search_keyword_category {
601 var $_words = array();
602
603 /**
604 * Constructor.
605 *
606 * @param $words
607 * Array containing the words to search.
608 */
609 function faceted_search_keyword_or_category($words) {
610 $this->_words = $words;
611 }
612
613 /**
614 * Return the label for this category.
615 *
616 * @param $html
617 * TRUE when HTML is allowed in the label, FALSE otherwise.
618 */
619 function get_label($html = FALSE) {
620 return $this->check_label(theme('faceted_search_keyword_or_label', $this->_words), $html);
621 }
622
623 /**
624 * Return the search text for this category.
625 */
626 function get_text() {
627 return implode(' OR ', $this->_words);
628 }
629
630 /**
631 * Append keywords used by this category into the specified array.
632 */
633 function get_keywords(&$keywords) {
634 $keywords = array_merge($keywords, $this->_words);
635 }
636
637 /**
638 * Return the weight of this category, for sorting purposes.
639 */
640 function get_weight() {
641 return 2;
642 }
643
644 /**
645 * Inject components into the query for selecting nodes matching this category.
646 *
647 * @param $query
648 * Query to inject the components into.
649 * @param $words
650 * Array keyed by search index type, each element being an array of positive
651 * words to lookup for that index type. This method should insert any words
652 * it cares about.
653 * @param $matches
654 * Minimum number of words that should match in query results for each index type.
655 * @param $type
656 * Type of search index entry to be searched.
657 */
658 function build_results_query(&$query, &$words, &$matches, $type) {
659 $where = '';
660 $where_args = array();
661 foreach ($this->_words as $word) {
662 if (($word = $this->check_word($word)) && !isset($words[$type][$word])) {
663 $words[$type][$word] = $word;
664 if (!empty($where)) {
665 $where .= ' OR ';
666 }
667 $where .= "{$type}_search_dataset.data LIKE '%% %s %%'";
668 $where_args[] = $word;
669 }
670 }
671 if (!empty($where)) {
672 $matches[$type]++;
673
674 // Matches will have to be checked against the dataset.
675 $query->enable_part("{$type}_search_dataset");
676 $query->set_current_part("{$type}_search_dataset");
677 array_unshift($where_args, $where);
678 call_user_func_array(array(&$query, 'add_where'), $where_args);
679 $query->set_current_part(); // Back to default part.
680 }
681 }
682 }
683
684 /**
685 * The keyword NOT category.
686 */
687 class faceted_search_keyword_not_category extends faceted_search_keyword_category {
688 var $_word = '';
689
690 /**
691 * Constructor.
692 *
693 * @param $word
694 * String containing the word to exclude from the search.
695 */
696 function faceted_search_keyword_not_category($word) {
697 $this->_word = $word;
698 }
699
700 /**
701 * Return the label for this category.
702 *
703 * @param $html
704 * TRUE when HTML is allowed in the label, FALSE otherwise.
705 */
706 function get_label($html = FALSE) {
707 return $this->check_label(theme('faceted_search_keyword_not_label', $this->_word), $html);
708 }
709
710 /**
711 * Return the search text for this operator.
712 */
713 function get_text() {
714 return '-'. $this->_word;
715 }
716
717 /**
718 * Return the weight of this category, for sorting purposes.
719 */
720 function get_weight() {
721 return 3;
722 }
723
724 /**
725 * Inject components into the query for selecting nodes matching this category.
726 *
727 * @param $query
728 * Query to inject the components into.
729 * @param $words
730 * Array keyed by search index type, each element being an array of positive
731 * words to lookup for that index type. This method should insert any words
732 * it cares about.
733 * @param $matches
734 * Minimum number of words that should match in query results for each index type.
735 * @param $type
736 * Type of search index entry to be searched.
737 */
738 function build_results_query(&$query, &$words, &$matches, $type) {
739 if ($word = $this->check_word($this->_word)) {
740 // This is a negative word; do not insert it, but mark the type as used.
741 if (!isset($words[$type])) {
742 $words[$type] = array();
743 }
744
745 // Negative words are checked against the dataset.
746 $query->enable_part("{$type}_search_dataset");
747 $query->set_current_part("{$type}_search_dataset");
748 $query->add_where("{$type}_search_dataset.data NOT LIKE '%% %s %%'", $word);
749 $query->set_current_part(); // Back to default part.
750 }
751 }
752 }
753
754 /**
755 * The filter for keyword search.
756 *
757 * Note: For keyword filters, the key corresponds to the type of search index
758 * entry, and the id is always 'keyword'.
759 */
760 class faceted_search_keyword_filter extends faceted_search_filter {
761 var $_label = ''; // Label of the field.
762
763 /**
764 * Constructor.
765 *
766 * @param $type
767 * Type of the search index entries corresponding to the field.
768 * @param $label
769 * Label of the field.
770 * @param $category
771 * Active category of the field.
772 */
773 function faceted_search_keyword_filter($type, $label, $category = NULL) {
774 parent::faceted_search_filter($type, isset($category) ? array($category) : array());
775 $this->_label = $label;
776 }
777
778 /**
779 * Returns the id of this filter.
780 */
781 function get_id() {
782 return 'keyword';
783 }
784
785 /**
786 * Return the search text corresponding to this filter.
787 */
788 function get_text() {
789 if ($category = $this->get_active_category()) {
790 return $category->get_text();
791 }
792 return '';
793 }
794
795 /**
796 * Return the label of this filter. This method is responsible for ensuring
797 * adequate security filtering.
798 */
799 function get_label() {
800 return check_plain($this->_label);
801 }
802
803 /**
804 * Append keywords used by this filter into the specified array.
805 */
806 function get_keywords(&$keywords) {
807 if ($category = $this->get_active_category()) {
808 $category->get_keywords($keywords);
809 }
810 }
811
812 /**
813 * Inject components into the query for selecting nodes matching this filter.
814 *
815 * @param $query
816 * Query to inject the components into.
817 * @param $words
818 * Array keyed by search index type, each element being an array of positive
819 * words to lookup for that index type. This method should insert any words
820 * it cares about.
821 * @param $matches
822 * Minimum number of words that should match in query results for each index type.
823 */
824 function build_results_query(&$query, &$words, &$matches) {
825 if ($category = $this->get_active_category()) {
826 $category->build_results_query($query, $words, $matches, $this->get_key());
827 }
828 }
829 }
830
831 /**
832 * This class stores and processes data related to a search.
833 */
834 class faceted_search {
835 // TODO: Remove the '_' prefix from data members. These are not so convenient
836 // for working with the schema.
837
838 /**
839 * The environment id for this search. Each search environment has its own
840 * settings which make it possible to use multiple distinct search
841 * interfaces. It is this id that allows to select the proper settings.
842 */
843 var $env_id = 0;
844
845 /**
846 * The full, unprocessed search text.
847 */
848 var $_text = '';
849
850 /**
851 * An array with all keywords found in the search text.
852 */
853 var $_keywords = array();
854
855 /**
856 * Name of the temporary results table. While it exists, this table can be
857 * queried for various purposes, such as building the search interface.
858 */
859 var $_results_table = '';
860
861 /**
862 * Number of results in the results table. May be used only after a call to
863 * execute().
864 */
865 var $_results_count = 0;
866
867 /**
868 * Flag to indicate whether the search has been executed.
869 */
870 var $_ready = FALSE;
871
872 /**
873 * Collection of filters currently used by this search.
874 */
875 var $_filters = array();
876
877 /**
878 * Constructor. Initialize the search environment.
879 *
880 * @param $record
881 * Optional for this environment, as fetched from the database. Defaults to
882 * NULL (for new environment).
883 */
884 function faceted_search($record = NULL) {
885 // Assign default settings, ensuring that all "blanks" are properly filled.
886 $this->init();
887
888 if (isset($record)) {
889 $this->init_from_record($record);
890 }
891 }
892
893 /**
894 * Initialize this search environment with default settings.
895 */
896 function init() {
897 $this->name = '';
898 $this->description = '';
899 $this->settings['title'] = t('Search');
900 $this->settings['ignore_status'] = FALSE;
901 $this->settings['types'] = array();
902
903 // Provide other modules an opportunity to add their own default settings.
904 module_invoke_all('faceted_search_init', $this);
905 }
906
907 /**
908 * Assign this search environment's settings from a record fetched from the
909 * database. Existing settings will be overwritten only if they are present in
910 * the record.
911 *
912 * @param $record
913 * Optional for this environment, as fetched from the database.
914 */
915 function init_from_record($record) {
916 if (isset($record->settings)) {
917 // The schema has this field serialized.
918 $settings = unserialize($record->settings);
919
920 if (is_array($settings)) {
921 // Load the settings from the record while preserving any default
922 // settings that are not present in the record.
923 $this->settings = $settings + $this->settings;
924 }
925
926 unset($record->settings);
927 }
928
929 // Load the remaining data from the record.
930 foreach ($record as $key => $value) {
931 $this->$key = $value;
932 }
933 }
934
935 /**
936 * Return the original search text of this search (i.e. the text that was
937 * passed to the constructor).
938 */
939 function get_text() {
940 return $this->_text;
941 }
942
943 /**
944 * Return an array with keywords used in the search.
945 */
946 function get_keywords() {
947 return $this->_keywords;
948 }
949
950 /**
951 * Return the filters used by this search.
952 */
953 function get_filters() {
954 return $this->_filters;
955 }
956
957 /**
958 * Return the specified filter.
959 */
960 function get_filter($index) {
961 return $this->_filters[$index];
962 }
963
964 /**
965 * Return the index of a filter given its key and id.
966 */
967 function get_filter_by_id($key, $id) {
968 foreach ($this->_filters as $index => $filter) {
969 if ($filter->get_key() == $key && $filter->get_id() == $id) {
970 return array($index, $filter);
971 }
972 }
973 }
974
975 /**
976 * Prepare the complete search environment (with its filters), parsing the
977 * given search text. Requires that an env_id has been assigned previously.
978 *
979 * @param $text
980 * Optional search text. Defaults to the empty string.
981 * @return
982 * TRUE is the search environment could be successfully built.
983 */
984 function prepare($text = '') {
985 if (!$this->env_id) {
986 return FALSE;
987 }
988
989 $this->_text = $text;
990 $this->_results_table = 'temp_faceted_search_results_'. $this->env_id;
991
992 // Load settings for all enabled filters in this search environment.
993 $all_filter_settings = faceted_search_load_filter_settings($this);
994
995 // Make a selection with all enabled filters.
996 $selection = faceted_search_get_filter_selection($all_filter_settings);
997
998 // Collect all filters relevant to this search.
999 foreach (module_implements('faceted_search_collect') as $module) {
1000 $module_filters = array();
1001 $hook = $module .'_faceted_search_collect';
1002
1003 // Parse the search text and obtain corresponding filters. Text is eaten as
1004 // it gets parsed.
1005 $text = $hook($module_filters, 'text', $this, $selection, $text);
1006
1007 // Disallow filters that already have been collected from the search text.
1008 foreach ($module_filters as $filter) {
1009 unset($selection[$filter->get_key()][$filter->get_id()]);
1010 }
1011
1012 // Collect any remaining allowed facets.
1013 if (!empty($selection)) {
1014 $hook($module_filters, 'facets', $this, $selection);
1015 }
1016
1017 // Merge the filters listed by the current module.
1018 $this->_filters = array_merge($this->_filters, $module_filters);
1019
1020 if (empty($selection)) {
1021 break; // No more filters allowed.
1022 }
1023 }
1024
1025 // After filters have been collected, any remaining text is passed to the
1026 // node filters.
1027 faceted_search_collect_node_keyword_filters($this->_filters, 'text', $this, $text);
1028
1029 // Prepare filters for use, assigning them their settings are sorting them.
1030 faceted_search_prepare_filters($this->_filters, $all_filter_settings);
1031
1032 // Assign the keywords found.
1033 foreach ($this->_filters as $filter) {
1034 $filter->get_keywords($this->_keywords);
1035 }
1036
1037 return TRUE;
1038 }
1039
1040 /**
1041 * Return TRUE when the search has been executed.
1042 */
1043 function ready() {
1044 return $this->_ready;
1045 }
1046
1047 /**
1048 * Perform the search and store the results in a temporary table.
1049 *
1050 * The prepare() method must have been called previously.
1051 *
1052 * Results are retrieved in two logical "passes". However, the two passes are
1053 * joined together into a single query. And in the case of most simple
1054 * queries the second pass is not even used.
1055 *
1056 * The first pass selects a set of all possible matches (individual words
1057 * looked up in the search_index table), which has the benefit of also
1058 * providing the exact result set for simple "AND" or "OR" searches.
1059 *
1060 * The second portion of the query further refines this set by verifying
1061 * advanced text conditions, such negative or phrase matches (search text
1062 * checked against the search_dataset table).
1063 */
1064 function execute() {
1065 if (!$this->_filters) {
1066 return; // Nothing to search
1067 }
1068
1069 $query = new faceted_search_query;
1070 if (!$this->settings['ignore_status'] || !user_access('administer nodes')) {
1071 // Restrict the search to published nodes only.
1072 $query->add_where('n.status = 1');
1073 }
1074 $query->add_groupby('n.nid');
1075
1076 // Apply node type filter
1077 $types = faceted_search_types($this);
1078 if (!empty($types)) {
1079 $query->add_where("n.type IN ('". implode("','", $types) ."')");
1080 }
1081
1082 // Inject keyword search conditions if applicable.
1083 $words = array(); // Positive words to include in the query.
1084 $matches = array();
1085 $word_score_expr = '';
1086 $word_score_arg = 0;
1087 foreach ($this->_filters as $filter) { // TODO: All filters are iterated; We should avoid iterating through those that are disabled.
1088 $filter->build_results_query($query, $words, $matches);
1089 }
1090
1091 if (count($matches) > 0) {
1092 $query->add_having('COUNT(*) >= %d', max($matches));
1093 }
1094
1095 // Some positive words were specified (and maybe some negatives as well).
1096 $words_where = array();
1097 $words_args = array();
1098 $words_scores = array();
1099 foreach ($words as $type => $type_words) {
1100 if (empty($type_words)) {
1101 // Negative words and/or short words were specified, but no positive
1102 // "long" words. Negative words and short words are looked up in
1103 // search_dataset, but since there are no positive "long" words, in this
1104 // particular case it is joined directly with the node table and we can
1105 // avoid joining search_index.
1106 $query->set_current_part("{$type}_search_dataset");
1107 $query->add_table('search_dataset', 'sid', 'n', 'nid', "{$type}_search_dataset");
1108 $query->add_where("{$type}_search_dataset.type = '%s'", $type);
1109 $query->set_current_part(); // Back to default part.
1110 }
1111 else {
1112 // Join the search index for the current index type.
1113 $query->add_table('search_index', 'sid', 'n', 'nid', "{$type}_search_index");
1114
1115 // Join the search dataset for the current index type, in case we're
1116 // dealing with a complex query.
1117 $query->set_current_part("{$type}_search_dataset");
1118 $query->add_table('search_dataset', array('sid', 'type'), "{$type}_search_index", array('sid', 'type'), "{$type}_search_dataset");
1119 $query->set_current_part(); // Back to default part.
1120
1121 $words_where[] = '('. substr(str_repeat("{$type}_search_index.word = '%s' OR ", count($type_words)), 0, -4) .") AND {$type}_search_index.type = '%s'";
1122 $words_args = array_merge($words_args, array_values($type_words));
1123 $words_args[] = $type;
1124
1125 $query->add_table('search_total', 'word', "{$type}_search_index", 'word', "{$type}_search_total");
1126
1127 $words_scores[] = "{$type}_search_index.score * {$type}_search_total.count";
1128 }
1129 }
1130
1131 if (!empty($words_where)) {
1132 array_unshift($words_args, implode(' AND ', $words_where));
1133 call_user_func_array(array(&$query, 'add_where'), $words_args);
1134 }
1135
1136 if (!empty($words_scores)) {
1137 // Add word score expression to the query.
1138 $score = 'SUM('. implode(' + ', $words_scores) .')';
1139 $query->set_current_part('normalize');
1140 $query->add_field(NULL, $score, 'score');
1141 $query->set_current_part();
1142
1143 // Perform the word score normalization query.
1144 $query->enable_part('normalize');
1145 $normalize = db_result(db_query_range($query->query(), $query->args(), 0, 1));
1146 $query->disable_part('normalize');
1147
1148 if (!$normalize) {
1149 $this->_ready = TRUE;
1150 return; // Return with no results.
1151 }
1152
1153 $word_score_expr = '(%f * '. $score .')';
1154 $word_score_arg = 1.0 / $normalize;
1155 }
1156
1157 // Add field needed for results.
1158 $query->add_field('n', 'nid', 'nid');
1159
1160 // Add scoring expression to the query.
1161 $this->_add_scoring($query, $word_score_expr, $word_score_arg);
1162
1163 // Give other modules an opportunity at altering the final query (e.g. for
1164 // additional filtering).
1165 module_invoke_all('faceted_search_query_alter', $this, $query);
1166
1167 // Perform the search results query and store results in a temporary table.
1168 //
1169 // This is MySQL-specific. db_query_temporary() is not used because of the
1170 // need to specify the primary key. The index provides a huge performance
1171 // improvement.
1172 //
1173 // See http://drupal.org/node/109513 regarding the use of HEAP engine.
1174 db_query('CREATE TEMPORARY TABLE '. $this->_results_table .' (nid int unsigned NOT NULL, PRIMARY KEY (nid)) Engine=HEAP '. $query->query(), $query->args(), $this->_results_table);
1175 $this->_results_count = db_result(db_query('SELECT COUNT(*) FROM '. $this->_results_table));
1176 $this->_ready = TRUE;
1177 }
1178
1179 /**
1180 * Fetch the items from the current search results, or from all available
1181 * nodes if no search text has been given.
1182 *
1183 * execute() must have been called beforehand.
1184 *
1185 * @return
1186 * Array of objects with nid and score members.
1187 */
1188 function load_results($limit = 10) {
1189 $found_items = array();
1190 if ($this->_results_count) {
1191 $result = pager_query("SELECT * FROM ". $this->_results_table, $limit, 0, 'SELECT '. $this->_results_count);
1192 while ($item = db_fetch_object($result)) {
1193 $found_items[] = $item;
1194 }
1195 }
1196 return $found_items;
1197 }
1198
1199 /**
1200 * Return the number of results for this search.
1201 *
1202 * execute() must have been called beforehand.
1203 */
1204 function get_results_count() {
1205 return $this->_results_count;
1206 }
1207
1208 /**
1209 * Return the name of this search's (temporary) results table.
1210 */
1211 function get_results_table() {
1212 return $this->_results_table;
1213 }
1214
1215 /**
1216 * Return the categories for the given facet and count matching nodes within
1217 * results.
1218 *
1219 * @param $facet
1220 * The facet whose categories are to be loaded.
1221 * @param $from
1222 * Ordinal number of the first category to load. Numbering starts at 0.
1223 * @param $max_count
1224 * Number of categories to load.
1225 * @return
1226 * Array of categories (objects having the faceted_search_category
1227 * interface).
1228 */
1229 function load_categories($facet, $from = NULL, $max_count = NULL) {
1230 // Prepare the base query components to include the current search results
1231 // and to count nodes.
1232 $query = new faceted_search_query;
1233 $query->add_field(NULL, 'COUNT(DISTINCT(n.nid))', 'count');
1234 if (!$this->_ready) {
1235 // No temporary table available, search within all nodes.
1236 if (!$this->settings['ignore_status'] || !user_access('administer nodes')) {
1237 // Restrict the search to published nodes only.
1238 $query->add_where('n.status = 1');
1239 }
1240
1241 // There is no results table at this point, so we can't rely on the
1242 // results table having been filtered already. Therefore, we ask modules
1243 // to alter the categories query instead.
1244 module_invoke_all('faceted_search_query_alter', $this, $query);
1245 }
1246 elseif ($this->_results_count > 0) {
1247 // Search within results.
1248 $query->add_table($this->_results_table, 'nid', 'n', 'nid', 'results', 'INNER', FALSE);
1249 }
1250 else {
1251 // Current search yields no results, thus no categories are possible.
1252 return array();
1253 }
1254
1255 // Gather the query components that will retrieve the categories.
1256 if ($active_category = $facet->get_active_category()) {
1257 $has_categories = $active_category->build_subcategories_query($query);
1258 }
1259 else {
1260 $has_categories = $facet->build_root_categories_query($query);
1261 }
1262 if (!$has_categories) {
1263 return array();
1264 }
1265
1266 // Apply sort criteria.
1267 $facet->build_sort_query($query);
1268
1269 // Apply node type filter.
1270 $types = faceted_search_types($this);
1271 if (!empty($types)) {
1272 $query->add_where("n.type IN ('". implode("','", $types) ."')");
1273 }
1274
1275 // Run the query and return the categories.
1276 if (isset($from) && isset($max_count)) {
1277 $results = db_query_range($query->query(), $query->args(), $from, $max_count);
1278 }
1279 else {
1280 $results = db_query($query->query(), $query->args());
1281 }
1282 return $facet->build_categories($results);
1283 }
1284
1285 /**
1286 * Add scoring expression to the search query.
1287 */
1288 function _add_scoring(&$query, $word_score_expr = '', $word_score_arg = 0) {
1289 // Based on node_search() -- START
1290
1291 $score_field = array();
1292 $score_arguments = array();
1293 if (!empty($word_score_expr) && $weight = (int)variable_get('node_rank_relevance', 5)) {
1294 $score_field[] = "%d * $word_score_expr";
1295 $score_arguments[] = $weight;
1296 $score_arguments[] = $word_score_arg;
1297 }
1298 if ($weight = (int)variable_get('node_rank_recent', 5)) {
1299 // Exponential decay with half-life of 6 months, starting at last indexed node
1300 $score_field[] = '%d * POW(2, (GREATEST(MAX(n.created), MAX(n.changed), MAX(c.last_comment_timestamp)) - %d) * 6.43e-8)';
1301 $score_arguments[] = $weight;
1302 $score_arguments[] = (int)variable_get('node_cron_last', 0);
1303 $query->add_table('node_comment_statistics', 'nid', 'n', 'nid', 'c', 'LEFT');
1304 }
1305 if (module_exists('comment') && $weight = (int)variable_get('node_rank_comments', 5)) {
1306 // Inverse law that maps the highest reply count on the site to 1 and 0 to 0.
1307 $scale = variable_get('node_cron_comments_scale', 0.0);
1308 $score_field[] = '%d * (2.0 - 2.0 / (1.0 + MAX(c.comment_count) * %f))';
1309 $score_arguments[] = $weight;
1310 $score_arguments[] = $scale;
1311 if (!$query->has_table('c')) {
1312 $query->add_table('node_comment_statistics', 'nid', 'n', 'nid', 'c', 'LEFT');
1313 }
1314 }
1315 // Based on node_search() -- END
1316
1317 // Add the formulas and their arguments into the query.
1318 if (count($score_field)) {
1319 // Prepend the first three arguments for add_field().
1320 $score_arguments = array_merge(array(NULL, implode(' + ', $score_field), 'score'), $score_arguments);
1321 // Call $query->add_field() with all arguments.
1322 call_user_func_array(array(&$query, 'add_field'), $score_arguments);
1323
1324 $query->add_orderby('score', 'DESC');
1325 }
1326 }
1327 }
1328
1329 /**
1330 * This class allows to build SQL queries piece by piece.
1331 *
1332 * Query elements are assigned to parts. These parts may selectively enabled or
1333 * disabled to control the final assembled the SQL statements. This is useful
1334 * when some context is still unknown at the time the elements are gathered -
1335 * those elements can still be injected to the query object and later filtered
1336 * in or out depending on context.
1337 */
1338 class faceted_search_query {
1339 var $primary_table_alias = '';
1340 var $table_queue = array(); // Ordered array of tables aliases to join.
1341 var $tables = array(); // Tables to join, keyed by their alias.
1342 var $fields = array(); // Fields, keyed by their alias.
1343 var $field_args = array();
1344 var $groupby = array();
1345 var $having = array();
1346 var $having_args = array();
1347 var $orderby = array();
1348 var $where = array();
1349 var $where_args = array();
1350 var $subqueries = array();
1351 var $subqueries_args = array();
1352 // Part to which query elements will be added to.
1353 var $current_part = 'default';
1354 // Parts enabled for use in the final assembled the query.
1355 var $parts = array('default' => 'default');
1356
1357 /**
1358 * Constructor. Specifies the primary table and field for this query.
1359 *
1360 * The primary table and field are always assigned to the default part.
1361 */
1362 function faceted_search_query($primary_table = 'node', $primary_table_alias = 'n', $prefixing = TRUE) {
1363 $this->primary_table_alias = $primary_table_alias;
1364 $this->tables['default'][$primary_table_alias] = array(
1365 'table' => $primary_table,
1366 'field' => NULL,
1367 'left_table_alias' => NULL,
1368 'left_field' => NULL,
1369 'join' => NULL,
1370 'prefixing' => $prefixing,
1371 );
1372 }
1373
1374 /**
1375 * Set the current part. This determines the part to which any query element
1376 * will be added to, until this method is called to select another part as
1377 * the current part.
1378 *
1379 * The current part cannot be "unset", but it can be reset back to the
1380 * default part.
1381 *
1382 * @param $part
1383 * Name of the part. Defaults to 'default'.
1384 */
1385 function set_current_part($part = 'default') {
1386 $this->current_part = $part;
1387 }
1388
1389 /**
1390 * Return the current part.
1391 */
1392 function get_current_part() {
1393 return $this->current_part;
1394 }
1395
1396 /**
1397 * Mark a part as enabled for use in query assembling. The query() and args()
1398 * methods will only return query elements that belong to parts that have
1399 * been enabled.
1400 *
1401 * The default part is always enabled.
1402 *
1403 * @see query()
1404 * @see args()
1405 * @see disable_part()
1406 */
1407 function enable_part($part) {
1408 $this->parts[$part] = $part;
1409 }
1410
1411 /**
1412 * Disallow a part for use in query assembling.
1413 *
1414 * The default part cannot be disabled.
1415 *
1416 * @see enable_part()
1417 */
1418 function disable_part($part) {
1419 if ($part !=