3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.7 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2016 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
30 * @copyright CiviCRM LLC (c) 2004-2016
34 * Class CRM_Utils_QueryFormatter
36 * This class is a bad idea. It exists for the unholy reason that a single installation
37 * may have up to three query engines (MySQL LIKE, MySQL FTS, Solr) processing the same
38 * query-text. It labors* to take the user's search expression and provide similar search
39 * semantics in different contexts. It is unknown whether this labor will be fruitful
42 class CRM_Utils_QueryFormatter
{
44 * Generate queries using SQL LIKE expressions.
46 const LANG_SQL_LIKE
= 'like';
49 * Generate queries using MySQL FTS expressions.
51 const LANG_SQL_FTS
= 'fts';
54 * Generate queries using MySQL's boolean FTS expressions.
56 const LANG_SQL_FTSBOOL
= 'ftsbool';
59 * Generate queries using Solr expressions.
61 const LANG_SOLR
= 'solr';
64 * Attempt to leave the text as-is.
66 const MODE_NONE
= 'simple';
69 * Attempt to treat the input text as a phrase
71 const MODE_PHRASE
= 'phrase';
74 * Attempt to treat the input text as a phrase with
75 * wildcards on each end.
77 const MODE_WILDPHRASE
= 'wildphrase';
80 * Attempt to treat individual word as if it
81 * had wildcards at the start and end.
83 const MODE_WILDWORDS
= 'wildwords';
86 * Attempt to treat individual word as if it
87 * had a wildcard at the end.
89 const MODE_WILDWORDS_SUFFIX
= 'wildwords-suffix';
91 static protected $singleton;
95 * @return CRM_Utils_QueryFormatter
97 public static function singleton($fresh = FALSE) {
98 if ($fresh || self
::$singleton === NULL) {
99 $mode = Civi
::settings()->get('fts_query_mode');
100 self
::$singleton = new CRM_Utils_QueryFormatter($mode);
102 return self
::$singleton;
112 * @param string $mode
115 public function __construct($mode) {
122 public function setMode($mode) {
129 public function getMode() {
134 * @param string $text
135 * @param string $language
136 * Eg LANG_SQL_LIKE, LANG_SQL_FTS, LANG_SOLR.
137 * @throws CRM_Core_Exception
140 public function format($text, $language) {
144 case self
::LANG_SOLR
:
145 case self
::LANG_SQL_FTS
:
146 $text = $this->_formatFts($text, $this->mode
);
149 case self
::LANG_SQL_FTSBOOL
:
150 $text = $this->_formatFtsBool($text, $this->mode
);
153 case self
::LANG_SQL_LIKE
:
154 $text = $this->_formatLike($text, $this->mode
);
161 if ($text === NULL) {
162 throw new CRM_Core_Exception("Unrecognized combination: language=[{$language}] mode=[{$this->mode}]");
169 * Create a SQL WHERE expression for matching against a list of
172 * @param string $table
173 * Eg "civicrm_note" or "civicrm_note mynote".
174 * @param array|string $columns
175 * List of columns to search against.
176 * Eg "first_name" or "activity_details".
177 * @param string $queryText
179 * SQL, eg "MATCH (col1) AGAINST (queryText)" or "col1 LIKE '%queryText%'"
181 public function formatSql($table, $columns, $queryText) {
182 if ($queryText === '*' ||
$queryText === '%' ||
empty($queryText)) {
186 $strtolower = function_exists('mb_strtolower') ?
'mb_strtolower' : 'strtolower';
188 if (strpos($table, ' ') === FALSE) {
189 $tableName = $tableAlias = $table;
192 list ($tableName, $tableAlias) = explode(' ', $table);
194 if (is_scalar($columns)) {
195 $columns = array($columns);
199 if (CRM_Core_InnoDBIndexer
::singleton()
200 ->hasDeclaredIndex($tableName, $columns)
202 $formattedQuery = $this->format($queryText, CRM_Utils_QueryFormatter
::LANG_SQL_FTSBOOL
);
204 $prefixedFieldNames = array();
205 foreach ($columns as $fieldName) {
206 $prefixedFieldNames[] = "$tableAlias.$fieldName";
209 $clauses[] = sprintf("MATCH (%s) AGAINST ('%s' IN BOOLEAN MODE)",
210 implode(',', $prefixedFieldNames),
211 $strtolower(CRM_Core_DAO
::escapeString($formattedQuery))
215 //CRM_Core_Session::setStatus(ts('Cannot use FTS for %1 (%2)', array(
217 // 2 => implode(', ', $fullTextFields),
220 $formattedQuery = $this->format($queryText, CRM_Utils_QueryFormatter
::LANG_SQL_LIKE
);
221 $escapedText = $strtolower(CRM_Core_DAO
::escapeString($formattedQuery));
222 foreach ($columns as $fieldName) {
223 $clauses[] = "$tableAlias.$fieldName LIKE '{$escapedText}'";
226 return implode(' OR ', $clauses);
232 * @param string $text
237 protected function _formatFts($text, $mode) {
240 // normalize user-inputted wildcards
241 $text = str_replace('%', '*', $text);
246 elseif (strpos($text, '*') !== FALSE) {
247 // if user supplies their own wildcards, then don't do any sophisticated changes
252 case self
::MODE_NONE
:
256 case self
::MODE_PHRASE
:
257 $result = '"' . $text . '"';
260 case self
::MODE_WILDPHRASE
:
261 $result = '"*' . $text . '*"';
264 case self
::MODE_WILDWORDS
:
265 $result = $this->mapWords($text, '*word*');
268 case self
::MODE_WILDWORDS_SUFFIX
:
269 $result = $this->mapWords($text, 'word*');
277 return $this->dedupeWildcards($result, '%');
283 * @param string $text
288 protected function _formatFtsBool($text, $mode) {
290 $operators = array('+', '-', '~', '(', ')');
292 //Return if searched string ends with an unsupported operator.
293 foreach ($operators as $val) {
294 if ($text == '@' || CRM_Utils_String
::endsWith($text, $val)) {
295 $csid = CRM_Core_DAO
::getFieldValue('CRM_Core_DAO_OptionValue', 'CRM_Contact_Form_Search_Custom_FullText', 'value', 'name');
296 $url = CRM_Utils_System
::url("civicrm/contact/search/custom", "csid={$csid}&reset=1");
297 $operators = implode("', '", $operators);
298 CRM_Core_Error
::statusBounce("Full-Text Search does not support the use of a search string ending with any of these operators ('{$operators}' or a single '@'). Please adjust your search term and try again.", $url);
302 // normalize user-inputted wildcards
303 $text = str_replace('%', '*', $text);
308 elseif (strpos($text, '+') !== FALSE ||
strpos($text, '-') !== FALSE) {
309 // if user supplies their own include/exclude operators, use text as is (with trailing wildcard)
310 $result = $this->mapWords($text, 'word*');
312 elseif (strpos($text, '*') !== FALSE) {
313 // if user supplies their own wildcards, then don't do any sophisticated changes
314 $result = $this->mapWords($text, '+word');
316 elseif (preg_match('/^(["\']).*\1$/m', $text)) {
317 // if surrounded by quotes, use term as is
322 case self
::MODE_NONE
:
323 $result = $this->mapWords($text, '+word', TRUE);
326 case self
::MODE_PHRASE
:
327 $result = '+"' . $text . '"';
330 case self
::MODE_WILDPHRASE
:
331 $result = '+"*' . $text . '*"';
334 case self
::MODE_WILDWORDS
:
335 $result = $this->mapWords($text, '+*word*');
338 case self
::MODE_WILDWORDS_SUFFIX
:
339 $result = $this->mapWords($text, '+word*');
347 return $this->dedupeWildcards($result, '%');
358 protected function _formatLike($text, $mode) {
364 elseif (strpos($text, '%') !== FALSE) {
365 // if user supplies their own wildcards, then don't do any sophisticated changes
370 case self
::MODE_NONE
:
371 case self
::MODE_PHRASE
:
372 case self
::MODE_WILDPHRASE
:
373 $result = "%" . $text . "%";
376 case self
::MODE_WILDWORDS
:
377 case self
::MODE_WILDWORDS_SUFFIX
:
378 $result = "%" . preg_replace('/[ \r\n]+/', '%', $text) . '%';
386 return $this->dedupeWildcards($result, '%');
390 * @param string $text
391 * User-supplied query string.
392 * @param string $template
393 * A prototypical description of each word, eg "word%" or "word*" or "*word*".
394 * @param bool $quotes
395 * True if each searched keyword need to be surrounded with quotes.
398 protected function mapWords($text, $template, $quotes = FALSE) {
400 foreach ($this->parseWords($text, $quotes) as $word) {
401 $result[] = str_replace('word', $word, $template);
403 return implode(' ', $result);
411 protected function parseWords($text, $quotes) {
412 //NYSS 9692 special handling for emails
413 if (preg_match('/^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$/', $text)) {
414 $parts = explode('@', $text);
415 $parts[1] = stristr($parts[1], '.', TRUE);
416 $text = implode(' ', $parts);
419 //NYSS also replace other occurrences of @
420 $replacedText = preg_replace('/[ \r\n\t\@]+/', ' ', trim($text));
421 //filter empty values if any
422 $keywords = array_filter(explode(' ', $replacedText));
424 //Ensure each searched keywords are wrapped in double quotes.
426 foreach ($keywords as &$val) {
427 if (!is_numeric($val)) {
440 protected function dedupeWildcards($text, $wildcard) {
441 if ($text === NULL) {
445 // don't use preg_replace because $wildcard might be special char
446 while (strpos($text, "{$wildcard}{$wildcard}") !== FALSE) {
447 $text = str_replace("{$wildcard}{$wildcard}", "{$wildcard}", $text);
457 public static function getModes() {
461 self
::MODE_WILDPHRASE
,
462 self
::MODE_WILDWORDS
,
463 self
::MODE_WILDWORDS_SUFFIX
,
472 public static function getLanguages() {
476 self
::LANG_SQL_FTSBOOL
,
484 * Ex: drush eval 'civicrm_initialize(); CRM_Utils_QueryFormatter::dumpExampleTable("firstword secondword");'
486 public static function dumpExampleTable($text) {
487 $width = strlen($text) +
8;
490 $buf .= sprintf("%-{$width}s", 'mode');
491 foreach (self
::getLanguages() as $lang) {
492 $buf .= sprintf("%-{$width}s", $lang);
496 foreach (self
::getModes() as $mode) {
497 $formatter = new CRM_Utils_QueryFormatter($mode);
498 $buf .= sprintf("%-{$width}s", $mode);
499 foreach (self
::getLanguages() as $lang) {
500 $buf .= sprintf("%-{$width}s", $formatter->format($text, $lang));