diff --git a/src/applications/search/compiler/PhutilSearchQueryCompiler.php b/src/applications/search/compiler/PhutilSearchQueryCompiler.php --- a/src/applications/search/compiler/PhutilSearchQueryCompiler.php +++ b/src/applications/search/compiler/PhutilSearchQueryCompiler.php @@ -172,8 +172,10 @@ } if ($mode == 'operator') { - if (preg_match('/^\s\z/u', $character)) { - continue; + if (!$current_operator) { + if (preg_match('/^\s\z/u', $character)) { + continue; + } } if (preg_match('/^'.$operator_characters.'\z/', $character)) { @@ -337,6 +339,7 @@ 'operator' => $operator, 'quoted' => $is_quoted, 'value' => $value, + 'raw' => $this->getDisplayToken($token), ); if ($enable_functions) { @@ -355,16 +358,58 @@ $result['function'] = $function; - if ($result['quoted']) { - $last_function = null; - } else { + $is_sticky = !$result['quoted']; + switch ($operator) { + case self::OPERATOR_ABSENT: + case self::OPERATOR_PRESENT: + $is_sticky = false; + break; + } + + if ($is_sticky) { $last_function = $function; + } else { + $last_function = null; } } $results[] = $result; } + if ($enable_functions) { + // If any function is required to be "absent", there must be no other + // terms which make assertions about it. + + $present_tokens = array(); + $absent_tokens = array(); + foreach ($results as $result) { + $function = $result['function']; + + if ($result['operator'] === self::OPERATOR_ABSENT) { + $absent_tokens[$function][] = $result; + } else { + $present_tokens[$function][] = $result; + } + } + + foreach ($absent_tokens as $function => $tokens) { + $absent_token = head($tokens); + + if (empty($present_tokens[$function])) { + continue; + } + + $present_token = head($present_tokens[$function]); + + throw new PhutilSearchQueryCompilerSyntaxException( + pht( + 'Query field must be absent ("%s") and present ("%s"). This '. + 'is impossible, so the query is not valid.', + $absent_token['raw'], + $present_token['raw'])); + } + } + return $results; } diff --git a/src/applications/search/compiler/__tests__/PhutilSearchQueryCompilerTestCase.php b/src/applications/search/compiler/__tests__/PhutilSearchQueryCompilerTestCase.php --- a/src/applications/search/compiler/__tests__/PhutilSearchQueryCompilerTestCase.php +++ b/src/applications/search/compiler/__tests__/PhutilSearchQueryCompilerTestCase.php @@ -10,10 +10,6 @@ 'cat -dog' => '+"cat" -"dog"', 'cat-dog' => '+"cat-dog"', - // If there are spaces after an operator, the operator applies to the - // next search term. - 'cat - dog' => '+"cat" -"dog"', - // Double quotes serve as delimiters even if there is no whitespace // between terms. '"cat"dog' => '+"cat" +"dog"', @@ -40,9 +36,16 @@ // Trailing whitespace should be discarded. 'a b ' => '+"a" +"b"', - // Functions must have search text. + // Tokens must have search text. '""' => false, '-' => false, + + // Previously, we permitted spaces to appear inside or after operators. + + // Now that "title:-" is now a valid construction meaning "title is + // absent", this had to be tightened. We want "title:- duck" to mean + // "title is absent, and any other field matches 'duck'". + 'cat - dog' => false, ); $this->assertCompileQueries($tests); @@ -171,6 +174,21 @@ array('title', $op_and, 'x'), array(null, $op_and, 'y'), ), + + // The "present" and "absent" functions are not sticky. + 'title:~ x' => array( + array('title', $op_present, null), + array(null, $op_and, 'x'), + ), + 'title:- x' => array( + array('title', $op_absent, null), + array(null, $op_and, 'x'), + ), + + // These queries require a field be both present and absent, which is + // impossible. + 'title:- title:x' => false, + 'title:- title:~' => false, ); $this->assertCompileFunctionQueries($function_tests);