diff --git a/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md index 3034367f..90b96c5c 100644 --- a/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md +++ b/doc/04_Searching_For_Data_In_Index/09_Pimcore_Query_Language/README.md @@ -15,21 +15,38 @@ FIELDNAME = IDENTIFIER{.IDENTIFIER} RELATION_FIELD_NAME = FIELDNAME:ENTITYNAME.FIELDNAME IDENTIFIER = [a-zA-Z_]\w* ENTITYNAME = [a-zA-Z_]\w* -OPERATOR = "="|"<"|">"|">="|"<="|"LIKE" -VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"' +OPERATOR = "="|"!="|"<"|">"|">="|"<="|"LIKE"|"NOT LIKE" +NULL = "NULL" +EMPTY = "EMPTY" +VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"' | NULL | EMPTY QUERY_STRING_QUERY = "QUERY('" STRING "')" ``` ### Operators -| Operator | Description | Examples | -|----------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------| -| `=` | equal | `field = "value"` | -| `<` | smaller than | `field < 100` | -| `<=` | smaller or equal than | `field <= 100` | -| `=>` | bigger or equal than | `field >= 100` | -| `>` | bigger than | `field > 100` | -| `LIKE` | equal with wildcard support
* matches zero or more characters
? matches any single character | `field like "val*"`
`field like "val?e"` | +| Operator | Description | Examples | +|------------|-------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| +| `=` | equal (case-sensitive) | `field = "value"` | +| `!=` | not equal (case-sensitive) | `field != "value"` | +| `<` | smaller than | `field < 100` | +| `<=` | smaller or equal than | `field <= 100` | +| `=>` | bigger or equal than | `field >= 100` | +| `>` | bigger than | `field > 100` | +| `LIKE` | equal with wildcard support (case-insensitive)
* matches zero or more characters
? matches any single character | `field like "val*"`
`field like "val?e"` | +| `NOT LIKE` | not equal with wildcard support (case-insensitive)
* matches zero or more characters
? matches any single character | `field not like "val*"`
`field not like "val?e"` | + +### Null/Empty Values + +To search for null and empty values use the `NULL`/`EMPTY` keywords. Those can be used together with the `=` and `!=` operators to search for fields without value. Keep in mind that there can be a difference between `NULL` and an empty string. The `EMPTY` keyword is a shortcut for `NULL` or an empty string. + +**Examples:** + +``` +field = NULL +field != NULL +field = EMPTY # same as: field = NULL OR field = '' +field != EMPTY # same as: field != NULL AND field != '' +``` ### AND / OR / Brackets @@ -40,7 +57,7 @@ You can combine multiple conditions using the `AND` and `OR` operators. You can ``` field1 = "value1" AND field2 = "value2" field1 = "value1" AND (field2 = "value2" OR field3 = "value3") -(field1 = "value1" AND (field2 = "value2" OR field3 = "value3")) or field4 = "value4" +(field1 = "value1" AND (field2 = "value2" OR field3 = "value3")) OR field4 = "value4" ``` @@ -98,19 +115,20 @@ The PQL allows passing OpenSearch [query string queries](https://opensearch.org/ All examples are based on the `Car` data object class of the [Pimcore Demo](https://pimcore.com/en/try). -| Query | Description | -|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| -| `series = "E-Type" AND (color = "green" OR productionYear < 1965)` | All E-Type models which are green or produced before 1965. | -| `manufacturer:Manufacturer.name = "Alfa" and productionYear > 1965` | All Alfa cars produced after 1965. | -| `genericImages:Asset.fullPath like "/Car Images/vw/*"` | All cars with a image linked in the `genericImages` image gallery which is contained in the asset folder `/Car Images/vw`. | -| `color = "red" or color = "blue"` | All red or blue cars using standard PQL syntax. | -| `Query("standard_fields.color:(red or blue)")` | All red or blue cars using simple query string syntax. | - +| Query | Description | +|---------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| `series = "E-Type" AND (color = "green" OR productionYear < 1965)` | All E-Type models which are green or produced before 1965. | +| `manufacturer:Manufacturer.name = "Alfa" AND productionYear > 1965` | All Alfa cars produced after 1965. | +| `genericImages:Asset.fullPath LIKE "/Car Images/vw/*"` | All cars with a image linked in the `genericImages` image gallery which is contained in the asset folder `/Car Images/vw`. | +| `color = "red" OR color = "blue"` | All red or blue cars using standard PQL syntax. | +| `series = empty AND color="red"` | All models where the series is empty and the color is red. | +| `Query("standard_fields.color:(red OR blue)")` | All red or blue cars using simple query string syntax. | ## Limitations * When searching for related elements the maximum possible results amount of sub queries is 65.000, see also [terms query documentation](https://opensearch.org/docs/latest/query-dsl/term/terms/). * Filtering for asset metadata fields is only possible if they are defined as predefined asset metadata or via the asset metadata class definitions bundle. Custom asset metadata fields directly defined on single assets are not supported. +* Reserved keywords (`AND`, `OR`, `LIKE`, `NOT LIKE`, `NULL`, `EMPTY`) cannot be used as field names. ## Further Reading diff --git a/src/Enum/QueryLanguage/QueryTokenType.php b/src/Enum/QueryLanguage/QueryTokenType.php index f739de05..a8d41e87 100644 --- a/src/Enum/QueryLanguage/QueryTokenType.php +++ b/src/Enum/QueryLanguage/QueryTokenType.php @@ -22,16 +22,20 @@ enum QueryTokenType: string case T_INTEGER = 'T_INTEGER'; case T_FLOAT = 'T_FLOAT'; case T_STRING = 'T_STRING'; + case T_NULL = 'T_NULL'; + case T_EMPTY = 'T_EMPTY'; case T_FIELDNAME = 'T_FIELDNAME'; case T_RELATION_FIELD = 'T_RELATION_FIELD'; case T_AND = 'T_AND'; case T_OR = 'T_OR'; case T_EQ = 'T_EQ'; + case T_NEQ = 'T_NEQ'; case T_GT = 'T_GT'; case T_GTE = 'T_GTE'; case T_LT = 'T_LT'; case T_LTE = 'T_LTE'; case T_LIKE = 'T_LIKE'; + case T_NOT_LIKE = 'T_NOT_LIKE'; case T_LPAREN = 'T_LPAREN'; case T_RPAREN = 'T_RPAREN'; case T_QUERY_STRING = 'T_QUERY_STRING'; diff --git a/src/QueryLanguage/Pql/Lexer.php b/src/QueryLanguage/Pql/Lexer.php index 973b603e..d6500fee 100644 --- a/src/QueryLanguage/Pql/Lexer.php +++ b/src/QueryLanguage/Pql/Lexer.php @@ -22,23 +22,16 @@ /** * CONDITION = EXPRESSION | EXPRESSION ("AND" | "OR") EXPRESSION - * * EXPRESSION = "(" CONDITION ")" | COMPARISON | QUERY_STRING_QUERY - * * COMPARISON = FIELDNAME OPERATOR VALUE | RELATION_COMPARISON - * * RELATION_COMPARISON = RELATION_FIELD_NAME OPERATOR VALUE - * * FIELDNAME = IDENTIFIER{.IDENTIFIER} - * * RELATION_FIELD_NAME = FIELDNAME:IDENTIFIER{.FIELDNAME} - * * IDENTIFIER = [a-zA-Z_]\w* - * - * OPERATOR = "="|"<"|">"|">="|"<="|"LIKE" - * - * VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"' - * + * OPERATOR = "="|"!="|"<"|">"|">="|"<="|"LIKE"|"NOT LIKE" + * NULL = "NULL" + * EMPTY = "EMPTY" + * VALUE = INTEGER | FLOAT | "'" STRING "'" | '"' STRING '"' | NULL | EMPTY * QUERY_STRING_QUERY = 'QUERY("' STRING '")' */ class Lexer extends AbstractLexer implements LexerInterface @@ -55,10 +48,29 @@ class Lexer extends AbstractLexer implements LexerInterface private const REGEX_STRING_DOUBLE_QUOTE = '"(?:[^"]|"")*"'; - private const REGEX_OPERATOR = '>=|<=|=|>|<|like'; + private const REGEX_EMPTY = 'null|empty'; + + private const REGEX_OPERATOR = '>=|<=|!=|=|>|<|not like|like'; private const REGEX_PARANTHESES = '\(|\)'; + private const TOKEN_MAP = [ + '=' => QueryTokenType::T_EQ, + '!=' => QueryTokenType::T_NEQ, + '>' => QueryTokenType::T_GT, + '<' => QueryTokenType::T_LT, + '>=' => QueryTokenType::T_GTE, + '<=' => QueryTokenType::T_LTE, + 'like' => QueryTokenType::T_LIKE, + 'not like' => QueryTokenType::T_NOT_LIKE, + 'and' => QueryTokenType::T_AND, + 'or' => QueryTokenType::T_OR, + '(' => QueryTokenType::T_LPAREN, + ')' => QueryTokenType::T_RPAREN, + 'null' => QueryTokenType::T_NULL, + 'empty' => QueryTokenType::T_EMPTY, + ]; + /** * Lexical catchable patterns. */ @@ -66,12 +78,13 @@ protected function getCatchablePatterns(): array { return [ self::REGEX_QUERY_STRING, - self::REGEX_RELATION_FIELD, - self::REGEX_FIELD_NAME, - self::REGEX_NUMBERS, self::REGEX_STRING_SINGLE_QUOTE, self::REGEX_STRING_DOUBLE_QUOTE, self::REGEX_OPERATOR, + self::REGEX_EMPTY, + self::REGEX_RELATION_FIELD, + self::REGEX_FIELD_NAME, + self::REGEX_NUMBERS, self::REGEX_PARANTHESES, ]; } @@ -106,44 +119,8 @@ protected function getType(&$value): QueryTokenType $typeToken = QueryTokenType::T_QUERY_STRING; break; - case $value === '(': - $typeToken = QueryTokenType::T_LPAREN; - - break; - case $value === ')': - $typeToken = QueryTokenType::T_RPAREN; - - break; - case strtolower($value) === 'and': - $typeToken = QueryTokenType::T_AND; - - break; - case strtolower($value) === 'or': - $typeToken = QueryTokenType::T_OR; - - break; - case $value === '=': - $typeToken = QueryTokenType::T_EQ; - - break; - case $value === '>': - $typeToken = QueryTokenType::T_GT; - - break; - case $value === '<': - $typeToken = QueryTokenType::T_LT; - - break; - case $value === '>=': - $typeToken = QueryTokenType::T_GTE; - - break; - case $value === '<=': - $typeToken = QueryTokenType::T_LTE; - - break; - case strtolower($value) === 'like': - $typeToken = QueryTokenType::T_LIKE; + case isset(self::TOKEN_MAP[strtolower($value)]): + $typeToken = self::TOKEN_MAP[strtolower($value)]; break; case preg_match('#' . self::REGEX_RELATION_FIELD . '#', $value): diff --git a/src/QueryLanguage/Pql/Parser.php b/src/QueryLanguage/Pql/Parser.php index 85cc915d..34456e1e 100644 --- a/src/QueryLanguage/Pql/Parser.php +++ b/src/QueryLanguage/Pql/Parser.php @@ -38,11 +38,13 @@ final class Parser implements ParserInterface private const OPERATOR_TOKENS = [ QueryTokenType::T_EQ, + QueryTokenType::T_NEQ, QueryTokenType::T_GT, QueryTokenType::T_LT, QueryTokenType::T_GTE, QueryTokenType::T_LTE, QueryTokenType::T_LIKE, + QueryTokenType::T_NOT_LIKE, ]; private const NUMERIC_TOKENS = [ @@ -50,6 +52,13 @@ final class Parser implements ParserInterface QueryTokenType::T_FLOAT, ]; + private const VALUE_TOKENS = [ + QueryTokenType::T_STRING, + QueryTokenType::T_NULL, + QueryTokenType::T_EMPTY, + ...self::NUMERIC_TOKENS, + ]; + private int $index = 0; public function __construct( @@ -166,7 +175,13 @@ private function parseComparison(array &$subQueries): array|ParseResultSubQuery $this->validateCurrentTokenNotEmpty(); if (!$this->currentToken() || !$this->currentToken()->isA(...self::FIELD_NAME_TOKENS)) { - $this->throwParsingException('a field name', '`' . ($this->currentToken()['value'] ?? 'null') . '`'); + $tokenValue = $this->currentToken()['value'] ?? 'null'; + $message = null; + if (in_arrayi($tokenValue, ['and', 'or', 'like', 'not like', 'null', 'empty'])) { + $message = sprintf('Expected %s, found %s.', 'a field name', '`' . $tokenValue . '`') + . ' Reserved keywords cannot be used as field name.'; + } + $this->throwParsingException('a field name', '`' . $tokenValue . '`', $message); } /** @var Token $fieldToken */ @@ -187,8 +202,21 @@ private function parseComparison(array &$subQueries): array|ParseResultSubQuery // Adjusting expectation for the value type to include both strings and numerics $valueToken = $this->currentToken(); - if (!$valueToken || !$valueToken->isA(QueryTokenType::T_STRING, ...self::NUMERIC_TOKENS)) { - $this->throwParsingException('a string or numeric value', '`' . ($valueToken['value'] ?? 'null') . '`'); + if (!$valueToken || !$valueToken->isA(...self::VALUE_TOKENS)) { + $this->throwParsingException( + 'a string, numeric value or a empty/null keyword', + '`' . ($valueToken['value'] ?? 'null') . '`' + ); + } + + if (!$operatorToken->isA(QueryTokenType::T_EQ, QueryTokenType::T_NEQ) + && $valueToken->isA(QueryTokenType::T_NULL, QueryTokenType::T_EMPTY) + ) { + $this->throwParsingException( + 'a valid value', + '`' . ($valueToken['value'] ?? 'null') . '`', + 'Operator `' . $operatorToken->value . '` does not support null/empty values' + ); } $this->advance(); // Prepare for next @@ -209,6 +237,12 @@ private function parseComparison(array &$subQueries): array|ParseResultSubQuery ? $this->stringToNumber($valueToken->value) : $valueToken->value; + $value = $valueToken->isA(QueryTokenType::T_NULL) ? + QueryTokenType::T_NULL : $value; + + $value = $valueToken->isA(QueryTokenType::T_EMPTY) ? + QueryTokenType::T_EMPTY : $value; + return $this->pqlAdapter->translateOperatorToSearchQuery($operatorTokenType, $field, $value); } diff --git a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapter.php b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapter.php index a7510fc4..f00f40c7 100644 --- a/src/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapter.php +++ b/src/SearchIndexAdapter/OpenSearch/QueryLanguage/PqlAdapter.php @@ -44,22 +44,105 @@ public function __construct( public function translateOperatorToSearchQuery(QueryTokenType $operator, string $field, mixed $value): array { - // term query works for keyword fields only - if ($operator === QueryTokenType::T_EQ && !str_ends_with($field, '.keyword')) { - return ['match' => [$field => $value]]; + if ($this->isNullValue($value)) { + return $this->handleNullValue($operator, $field); + } + + if ($this->isEmptyValue($value)) { + return $this->handleEmptyValue($operator, $field); + } + + if ($this->isMatchComparison($operator, $field)) { + return $this->handleMatchComparison($operator, $field, $value); } return match($operator) { QueryTokenType::T_EQ => ['term' => [$field => $value]], + QueryTokenType::T_NEQ => ['bool' => ['must_not' => ['term' => [$field => $value]]]], QueryTokenType::T_GT => ['range' => [$field => ['gt' => $value]]], QueryTokenType::T_LT => ['range' => [$field => ['lt' => $value]]], QueryTokenType::T_GTE => ['range' => [$field => ['gte' => $value]]], QueryTokenType::T_LTE => ['range' => [$field => ['lte' => $value]]], QueryTokenType::T_LIKE => ['wildcard' => [$field => ['value' => $value, 'case_insensitive' => true]]], + QueryTokenType::T_NOT_LIKE => $this->createMustNot( + ['wildcard' => [$field => ['value' => $value, 'case_insensitive' => true]]] + ), default => throw new InvalidArgumentException('Unknown operator: ' . $operator->value) }; } + private function isNullValue(mixed $value): bool + { + return $value === QueryTokenType::T_NULL; + } + + private function isEmptyValue(mixed $value): bool + { + return $value === QueryTokenType::T_EMPTY; + } + + private function isMatchComparison(QueryTokenType $operator, string $field): bool + { + return ($operator === QueryTokenType::T_EQ || $operator === QueryTokenType::T_NEQ) + && !str_ends_with($field, '.keyword'); + } + + private function handleMatchComparison(QueryTokenType $operator, string $field, mixed $value): array + { + if ($operator === QueryTokenType::T_EQ) { + return ['match' => [$field => $value]]; + } + + if ($operator === QueryTokenType::T_NEQ) { + return $this->createMustNot(['match' => [$field => $value]]); + } + + throw new InvalidArgumentException( + 'Invalid match comparison operator ' . $operator->value . ' for field ' . $field + ); + } + + private function handleNullValue(QueryTokenType $operator, string $field): array + { + if ($operator === QueryTokenType::T_EQ) { + return $this->createMustNot(['exists' => ['field' => $field]]); + } + + if ($operator === QueryTokenType::T_NEQ) { + return ['exists' => ['field' => $field]]; + } + + throw new InvalidArgumentException( + 'Operator ' . $operator->value . ' does not support for null values' + ); + } + + private function handleEmptyValue(QueryTokenType $operator, string $field): array + { + + $boolCondition = match ($operator) { + QueryTokenType::T_EQ => 'should', + QueryTokenType::T_NEQ => 'filter', + default => throw new InvalidArgumentException( + 'Operator ' . $operator->value . ' does not support for empty values' + ) + }; + + return [ + 'bool' => [ + $boolCondition => [ + $this->handleNullValue($operator, $field), + $this->translateOperatorToSearchQuery($operator, $field, ''), + ], + ], + ]; + } + + private function createMustNot(array $query): array + { + return ['bool' => ['must_not' => $query]]; + } + public function translateToQueryStringQuery(string $query): array { return ['query_string' => ['query' => $query]]; diff --git a/tests/Functional/Search/Modifier/QueryLanguage/PqlFilterTest.php b/tests/Functional/Search/Modifier/QueryLanguage/PqlFilterTest.php index 47ea961b..05d85a03 100644 --- a/tests/Functional/Search/Modifier/QueryLanguage/PqlFilterTest.php +++ b/tests/Functional/Search/Modifier/QueryLanguage/PqlFilterTest.php @@ -48,6 +48,10 @@ public function testPqlFilter() $object1 = TestHelper::createEmptyObject(); /** @var Unittest $object2 */ $object2 = TestHelper::createEmptyObject(); + /** @var Unittest $object3 */ + $object3= TestHelper::createEmptyObject(); + /** @var Unittest $object4 */ + $object4= TestHelper::createEmptyObject(); $object1 ->setInput('test1') @@ -62,6 +66,16 @@ public function testPqlFilter() ->save() ; + $object3 + ->setInput(null) + ->save() + ; + + $object4 + ->setInput('') + ->save() + ; + /** @var DataObjectSearchServiceInterface $searchService */ $searchService = $this->tester->grabService('generic-data-index.test.service.data-object-search-service'); /** @var SearchProviderInterface $searchProvider */ @@ -76,6 +90,8 @@ public function testPqlFilter() 'input like "test?1"' => [], 'input like "notfound*"' => [], + 'input not like "test*"' => [$object3->getId(), $object4->getId()], + 'number > 15' => [$object2->getId()], 'number >= 10' => [$object1->getId(), $object2->getId()], 'number < 15' => [$object1->getId()], @@ -101,6 +117,18 @@ public function testPqlFilter() 'multihref:Unittest.input = "test1"' => [$object2->getId()], 'multihref:Unittest.input = "test2"' => [], '(multihref:Unittest.input = "test2" or input ="test1")' => [$object1->getId()], + + 'input = null' => [$object3->getId()], + 'input = ""' => [$object4->getId()], + 'input != null' => [$object1->getId(), $object2->getId(), $object4->getId()], + 'input != ""' => [$object1->getId(), $object2->getId(), $object3->getId()], + 'input = "" or input = null' => [$object3->getId(), $object4->getId()], + 'input != "" and input != null' => [$object1->getId(), $object2->getId()], + + 'input = empty' => [$object3->getId(), $object4->getId()], + 'input != empty' => [$object1->getId(), $object2->getId()], + 'input = empty and input != ""' => [$object3->getId()], + 'input = empty or ((number = 10 and input = "test1") or number = 20)' => [$object1->getId(), $object2->getId(), $object3->getId(), $object4->getId()], ]; foreach ($testCases as $query => $expectedIds) { diff --git a/tests/Unit/QueryLanguage/Pql/LexerTest.php b/tests/Unit/QueryLanguage/Pql/LexerTest.php index ccd3f483..c81ddfd1 100644 --- a/tests/Unit/QueryLanguage/Pql/LexerTest.php +++ b/tests/Unit/QueryLanguage/Pql/LexerTest.php @@ -36,11 +36,21 @@ public function testGetTokensOperators(): void ['type' => QueryTokenType::T_EQ, 'value' => '='], ['type' => QueryTokenType::T_STRING, 'value' => 'foo'], ], + 'my_field != "foo"' => [ + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_NEQ, 'value' => '!='], + ['type' => QueryTokenType::T_STRING, 'value' => 'foo'], + ], 'my_field LIKE "foo*"' => [ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'], ['type' => QueryTokenType::T_STRING, 'value' => 'foo*'], ], + 'my_field NOT LIKE "foo*"' => [ + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_NOT_LIKE, 'value' => 'NOT LIKE'], + ['type' => QueryTokenType::T_STRING, 'value' => 'foo*'], + ], 'my_field >= 42' => [ ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], ['type' => QueryTokenType::T_GTE, 'value' => '>='], @@ -90,6 +100,16 @@ public function testGetTokensValueTypes(): void ['type' => QueryTokenType::T_EQ, 'value' => '='], ['type' => QueryTokenType::T_FLOAT, 'value' => '42.42'], ], + 'my_field = null' => [ + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_EQ, 'value' => '='], + ['type' => QueryTokenType::T_NULL, 'value' => 'null'], + ], + 'my_field = empty' => [ + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_EQ, 'value' => '='], + ['type' => QueryTokenType::T_EMPTY, 'value' => 'empty'], + ], ]; foreach ($testCases as $testCase => $expected) { @@ -298,6 +318,17 @@ public function testGetTokensCombined(): void ['type' => QueryTokenType::T_GTE, 'value' => '>='], ['type' => QueryTokenType::T_INTEGER, 'value' => '42'], ], + '(my_field LIKE "LIKE" AND my_field != "!=")' => [ + ['type' => QueryTokenType::T_LPAREN, 'value' => '('], + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_LIKE, 'value' => 'LIKE'], + ['type' => QueryTokenType::T_STRING, 'value' => 'LIKE'], + ['type' => QueryTokenType::T_AND, 'value' => 'AND'], + ['type' => QueryTokenType::T_FIELDNAME, 'value' => 'my_field'], + ['type' => QueryTokenType::T_NEQ, 'value' => '!='], + ['type' => QueryTokenType::T_STRING, 'value' => '!='], + ['type' => QueryTokenType::T_RPAREN, 'value' => ')'], + ], ]; foreach ($testCases as $testCase => $expected) { diff --git a/tests/Unit/QueryLanguage/Pql/ParserTest.php b/tests/Unit/QueryLanguage/Pql/ParserTest.php index 205e59d5..06648b61 100644 --- a/tests/Unit/QueryLanguage/Pql/ParserTest.php +++ b/tests/Unit/QueryLanguage/Pql/ParserTest.php @@ -41,6 +41,12 @@ public function testParseComparison(): void 'match' => ['color' => 'red'], ] ); + $this->assertQueryResult( + 'color != "red"', + [ + 'bool' => ['must_not' => ['match' => ['color' => 'red']]], + ] + ); $this->assertQueryResult( 'price > 27', @@ -96,6 +102,51 @@ public function testParseComparison(): void 'wildcard' => ['name' => ['value' => 'Jaguar', 'case_insensitive' => true]], ] ); + + $this->assertQueryResult( + 'name not like "*Jaguar*"', + [ + 'bool' => ['must_not' => ['wildcard' => ['name' => ['value' => '*Jaguar*', 'case_insensitive' => true]]]], + ] + ); + + $this->assertQueryResult( + 'color = null', + [ + 'bool' => ['must_not' => ['exists' => ['field' => 'color']]], + ] + ); + + $this->assertQueryResult( + 'color != null', + [ + 'exists' => ['field' => 'color'], + ] + ); + + $this->assertQueryResult( + 'color = empty', + [ + 'bool' => [ + 'should' => [ + ['bool' => ['must_not' => ['exists' => ['field' => 'color']]]], + ['match' => ['color' => '']], + ], + ], + ] + ); + + $this->assertQueryResult( + 'color != empty', + [ + 'bool' => [ + 'filter' => [ + ['exists' => ['field' => 'color']], + ['bool' => ['must_not' => ['match' => ['color' => '']]]], + ], + ], + ] + ); } public function testParseCondition(): void @@ -387,7 +438,7 @@ public function testParseError1(): void public function testParseError2(): void { $this->expectException(ParsingException::class); - $this->expectExceptionMessage('Expected a field name, found `or`'); + $this->expectExceptionMessage('Expected a field name, found `or`. Reserved keywords cannot be used as field name.'); $this->parseQuery('color = "red" and or'); } @@ -401,14 +452,14 @@ public function testParseError3(): void public function testParseError4(): void { $this->expectException(ParsingException::class); - $this->expectExceptionMessage('Expected a string or numeric value, found `red`'); + $this->expectExceptionMessage('Expected a string, numeric value or a empty/null keyword, found `red`'); $this->parseQuery('color = red'); } public function testParseError5(): void { $this->expectException(ParsingException::class); - $this->expectExceptionMessage('Expected a string or numeric value, found `(`'); + $this->expectExceptionMessage('Expected a string, numeric value or a empty/null keyword, found `(`'); $this->parseQuery('color = (Color.name = red)'); } @@ -422,10 +473,38 @@ public function testParseError6(): void public function testParseError7(): void { $this->expectException(ParsingException::class); - $this->expectExceptionMessage('Expected a string or numeric value, found `"`'); + $this->expectExceptionMessage('Expected a string, numeric value or a empty/null keyword, found `"`'); $this->parseQuery('manufacturer:Manufactorer.name = "Jaguar'); } + public function testParseError8(): void + { + $this->expectException(ParsingException::class); + $this->expectExceptionMessage('does not support null/empty values'); + $this->parseQuery('color > null'); + } + + public function testParseError9(): void + { + $this->expectException(ParsingException::class); + $this->expectExceptionMessage('does not support null/empty values'); + $this->parseQuery('color like empty'); + } + + public function testParseError10(): void + { + $this->expectException(ParsingException::class); + $this->expectExceptionMessage('Expected a field name, found `null`. Reserved keywords cannot be used as field name.'); + $this->parseQuery('color="red" or null = "foo"'); + } + + public function testParseError11(): void + { + $this->expectException(ParsingException::class); + $this->expectExceptionMessage('Expected a field name, found `like`. Reserved keywords cannot be used as field name.'); + $this->parseQuery('color="red" or like = "foo"'); + } + private function parseQuery(string $query): void { $parser = $this->createParser();