diff --git a/src/search/PhutilSearchQueryCompiler.php b/src/search/PhutilSearchQueryCompiler.php --- a/src/search/PhutilSearchQueryCompiler.php +++ b/src/search/PhutilSearchQueryCompiler.php @@ -6,9 +6,12 @@ private $operators = '+ -><()~*:""&|'; private $query; private $stemmer; + private $enableFunctions = false; const OPERATOR_NOT = 'not'; const OPERATOR_AND = 'and'; + const OPERATOR_SUBSTRING = 'sub'; + const OPERATOR_EXACT = 'exact'; public function setOperators($operators) { $this->operators = $operators; @@ -28,6 +31,15 @@ return $this->stemmer; } + public function setEnableFunctions($enable_functions) { + $this->enableFunctions = $enable_functions; + return $this; + } + + public function getEnableFunctions() { + return $this->enableFunctions; + } + public function compileQuery(array $tokens) { assert_instances_of($tokens, 'PhutilSearchQueryToken'); @@ -102,11 +114,21 @@ $query = phutil_utf8v($query); $length = count($query); + $enable_functions = $this->getEnableFunctions(); + $mode = 'scan'; $current_operator = array(); $current_token = array(); + $current_function = null; $is_quoted = false; $tokens = array(); + + if ($enable_functions) { + $operator_characters = '[~=+-]'; + } else { + $operator_characters = '[+-]'; + } + for ($ii = 0; $ii < $length; $ii++) { $character = $query[$ii]; @@ -115,7 +137,36 @@ continue; } + $mode = 'function'; + } + + if ($mode == 'function') { $mode = 'operator'; + + if ($enable_functions) { + $found = false; + for ($jj = $ii; $jj < $length; $jj++) { + if (preg_match('/^[a-zA-Z]\z/u', $query[$jj])) { + continue; + } + if ($query[$jj] == ':') { + $found = $jj; + } + break; + } + + if ($found !== false) { + $function = array_slice($query, $ii, ($jj - $ii)); + $current_function = implode('', $function); + + if (!strlen($current_function)) { + $current_function = null; + } + + $ii = $jj; + continue; + } + } } if ($mode == 'operator') { @@ -123,7 +174,7 @@ continue; } - if (preg_match('/^[+-]\z/', $character)) { + if (preg_match('/^'.$operator_characters.'\z/', $character)) { $current_operator[] = $character; continue; } @@ -164,13 +215,21 @@ } if ($capture) { - $tokens[] = array( + $token = array( 'operator' => $current_operator, 'quoted' => $was_quoted, 'value' => $current_token, ); + + if ($enable_functions) { + $token['function'] = $current_function; + } + + $tokens[] = $token; + $current_operator = array(); $current_token = array(); + $current_function = null; continue; } else { $current_token[] = $character; @@ -191,12 +250,18 @@ implode('', $current_operator))); } - $tokens[] = array( + $token = array( 'operator' => $current_operator, 'quoted' => false, 'value' => $current_token, ); + if ($enable_functions) { + $token['function'] = $current_function; + } + + $tokens[] = $token; + $results = array(); foreach ($tokens as $token) { $value = implode('', $token['value']); @@ -210,6 +275,12 @@ case '-': $operator = self::OPERATOR_NOT; break; + case '~': + $operator = self::OPERATOR_SUBSTRING; + break; + case '=': + $operator = self::OPERATOR_EXACT; + break; case '': case '+': $operator = self::OPERATOR_AND; @@ -221,11 +292,17 @@ $operator_string)); } - $results[] = array( + $result = array( 'operator' => $operator, 'quoted' => $token['quoted'], 'value' => $value, ); + + if ($enable_functions) { + $result['function'] = $token['function']; + } + + $results[] = $result; } return $results; diff --git a/src/search/PhutilSearchQueryToken.php b/src/search/PhutilSearchQueryToken.php --- a/src/search/PhutilSearchQueryToken.php +++ b/src/search/PhutilSearchQueryToken.php @@ -5,6 +5,7 @@ private $isQuoted; private $value; private $operator; + private $function; public static function newFromDictionary(array $dictionary) { $token = new self(); @@ -12,6 +13,7 @@ $token->isQuoted = $dictionary['quoted']; $token->operator = $dictionary['operator']; $token->value = $dictionary['value']; + $token->function = idx($dictionary, 'function'); return $token; } @@ -28,4 +30,8 @@ return $this->operator; } + public function getFunction() { + return $this->function; + } + } diff --git a/src/search/__tests__/PhutilSearchQueryCompilerTestCase.php b/src/search/__tests__/PhutilSearchQueryCompilerTestCase.php --- a/src/search/__tests__/PhutilSearchQueryCompilerTestCase.php +++ b/src/search/__tests__/PhutilSearchQueryCompilerTestCase.php @@ -87,6 +87,43 @@ $this->assertCompileQueries($stemming_tests, null, $stemmer); } + public function testCompileQueriesWithFunctions() { + $op_and = PhutilSearchQueryCompiler::OPERATOR_AND; + $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING; + $op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT; + + $function_tests = array( + 'cat' => array( + array(null, $op_and, 'cat'), + ), + ':cat' => array( + array(null, $op_and, 'cat'), + ), + 'title:cat' => array( + array('title', $op_and, 'cat'), + ), + 'title:cat:dog' => array( + array('title', $op_and, 'cat:dog'), + ), + 'title:~cat' => array( + array('title', $op_sub, 'cat'), + ), + 'cat title:="Meow Meow"' => array( + array(null, $op_and, 'cat'), + array('title', $op_exact, 'Meow Meow'), + ), + 'title:cat title:dog' => array( + array('title', $op_and, 'cat'), + array('title', $op_and, 'dog'), + ), + '~"core and seven years ag"' => array( + array(null, $op_sub, 'core and seven years ag'), + ), + ); + + $this->assertCompileFunctionQueries($function_tests); + } + private function assertCompileQueries( array $tests, $operators = null, @@ -143,4 +180,27 @@ } } + private function assertCompileFunctionQueries(array $tests) { + foreach ($tests as $input => $expect) { + $compiler = id(new PhutilSearchQueryCompiler()) + ->setEnableFunctions(true); + + $tokens = $compiler->newTokens($input); + + $result = array(); + foreach ($tokens as $token) { + $result[] = array( + $token->getFunction(), + $token->getOperator(), + $token->getValue(), + ); + } + + $this->assertEqual( + $expect, + $result, + pht('Function compilation of query: %s', $input)); + } + } + }