diff --git a/grammar/query.pp b/grammar/query.pp index 9511c417fe..7bf7341d2a 100644 --- a/grammar/query.pp +++ b/grammar/query.pp @@ -32,7 +32,7 @@ %token id id|recordid %token field_prefix field\. %token flag_prefix flag\. -%token meta_prefix meta\. +%token meta_prefix (?:meta|exif)\. %token true true|1 %token false false|0 %token word [^\s\(\)\[\]:<>≤≥=]+ @@ -73,10 +73,10 @@ key_value_pair: | ::flag_prefix:: flag() ::colon:: ::space::? boolean() #flag_statement | ::field_prefix:: field() ::colon:: ::space::? term() #field_statement | field() ::colon:: ::space::? term() #field_statement - | field() ::space::? ::lt:: ::space::? value() #less_than - | field() ::space::? ::gt:: ::space::? value() #greater_than - | field() ::space::? ::lte:: ::space::? value() #less_than_or_equal_to - | field() ::space::? ::gte:: ::space::? value() #greater_than_or_equal_to + | key() ::space::? ::lt:: ::space::? value() #less_than + | key() ::space::? ::gt:: ::space::? value() #greater_than + | key() ::space::? ::lte:: ::space::? value() #less_than_or_equal_to + | key() ::space::? ::gte:: ::space::? value() #greater_than_or_equal_to | field() ::space::? ::equal:: ::space::? value() #equal_to #native_key: @@ -85,9 +85,18 @@ key_value_pair: | | +key: + ::meta_prefix:: meta_key() + | ::field_prefix:: field_key() + | field_key() + #meta_key: word_or_keyword()+ +#field_key: + word_or_keyword()+ + | quoted_string() + #flag: word_or_keyword()+ diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/FieldKey.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/FieldKey.php new file mode 100644 index 0000000000..0041781e0b --- /dev/null +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/FieldKey.php @@ -0,0 +1,31 @@ +name = $name; + } + + public function getIndexField() + { + return 'yolo'; + } + + public function getValue() + { + return $this->name; + } + + public function __toString() + { + return sprintf('field.%s', $this->name); + } +} diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/MetadataKey.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/MetadataKey.php index bb0e01dd14..04772a62c2 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/MetadataKey.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/KeyValue/MetadataKey.php @@ -2,7 +2,6 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue; -use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Assert\Assertion; class MetadataKey implements Key diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/RangeExpression.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/RangeExpression.php index b5e905b3f4..4c324c3c7a 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/RangeExpression.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/RangeExpression.php @@ -2,41 +2,49 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic\AST; +use Assert\Assertion; +use Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue\FieldKey; +use Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue\Key; use Alchemy\Phrasea\SearchEngine\Elastic\Exception\QueryException; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryHelper; class RangeExpression extends Node { - private $field; + private $key; + private $field_cache; private $lower_bound; private $lower_inclusive; private $higher_bound; private $higher_inclusive; - public static function lessThan(Field $field, $bound) + public static function lessThan(Key $key, $bound) { - return new self($field, $bound, false); + return new self($key, $bound, false); } - public static function lessThanOrEqual(Field $field, $bound) + public static function lessThanOrEqual(Key $key, $bound) { - return new self($field, $bound, true); + return new self($key, $bound, true); } - public static function greaterThan(Field $field, $bound) + public static function greaterThan(Key $key, $bound) { - return new self($field, null, null, $bound, false); + return new self($key, null, false, $bound, false); } - public static function greaterThanOrEqual(Field $field, $bound) + public static function greaterThanOrEqual(Key $key, $bound) { - return new self($field, null, null, $bound, true); + return new self($key, null, false, $bound, true); } - public function __construct(Field $field, $lb, $li = false, $hb = null, $hi = false) + public function __construct(Key $key, $lb, $li = false, $hb = null, $hi = false) { - $this->field = $field; + Assertion::nullOrScalar($lb); + Assertion::boolean($li); + Assertion::nullOrScalar($hb); + Assertion::boolean($hi); + $this->key = $key; $this->lower_bound = $lb; $this->lower_inclusive = $li; $this->higher_bound = $hb; @@ -45,14 +53,9 @@ class RangeExpression extends Node public function buildQuery(QueryContext $context) { - $structure_field = $context->get($this->field); - if (!$structure_field) { - throw new QueryException(sprintf('Field "%s" does not exist', $this->field->getValue())); - } - $params = array(); if ($this->lower_bound !== null) { - if (!$structure_field->isValueCompatible($this->lower_bound)) { + if (!$this->isValueCompatible($this->lower_bound, $context)) { return; } if ($this->lower_inclusive) { @@ -62,7 +65,7 @@ class RangeExpression extends Node } } if ($this->higher_bound !== null) { - if (!$structure_field->isValueCompatible($this->higher_bound)) { + if (!$this->isValueCompatible($this->higher_bound, $context)) { return; } if ($this->higher_inclusive) { @@ -73,9 +76,48 @@ class RangeExpression extends Node } $query = []; - $query['range'][$structure_field->getIndexField()] = $params; + $query['range'][$this->getIndexField($context)] = $params; - return QueryHelper::wrapPrivateFieldQuery($structure_field, $query); + return $this->postProcessQuery($query, $context); + } + + private function isValueCompatible($value, QueryContext $context) + { + if ($this->key instanceof FieldKey) { + return $this->getField($context)->isValueCompatible($value); + } else { + return true; + } + } + + private function getIndexField(QueryContext $context) + { + if ($this->key instanceof FieldKey) { + return $this->getField($context)->getIndexField(); + } else { + return $this->key->getIndexField(); + } + } + + private function postProcessQuery($query, QueryContext $context) + { + if ($this->key instanceof FieldKey) { + $field = $this->getField($context); + return QueryHelper::wrapPrivateFieldQuery($field, $query); + } else { + return $query; + } + } + + private function getField(QueryContext $context) + { + if ($this->field_cache === null) { + $this->field_cache = $context->get($this->key->getValue()); + } + if ($this->field_cache === null) { + throw new QueryException(sprintf('Field "%s" does not exist', $this->key->getValue())); + } + return $this->field_cache; } public function getTermNodes() @@ -101,6 +143,6 @@ class RangeExpression extends Node } } - return sprintf('', $this->field->getValue(), $string); + return sprintf('', $this->key, $string); } } diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php index f6a8ae9f76..aba598fcea 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php @@ -17,6 +17,7 @@ class NodeTypes const EQUAL_EXPR = '#equal_to'; const FIELD_STATEMENT = '#field_statement'; const FIELD = '#field'; + const FIELD_KEY = '#field_key'; const VALUE = '#value'; const TERM = '#thesaurus_term'; const TEXT = '#text'; diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php index 4b87db3a11..3089735efc 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php @@ -101,6 +101,9 @@ class QueryVisitor implements Visit case NodeTypes::METADATA_KEY: return $this->visitMetadataKeyNode($element); + case NodeTypes::FIELD_KEY: + return new AST\KeyValue\FieldKey($this->visitString($element)); + default: throw new Exception(sprintf('Unknown node type "%s".', $element->getId())); } @@ -151,18 +154,18 @@ class QueryVisitor implements Visit if ($node->getChildrenNumber() !== 2) { throw new Exception('Comparison operator can only have 2 childs.'); } - $field = $node->getChild(0)->accept($this); - $expression = $node->getChild(1)->accept($this); + $key = $node->getChild(0)->accept($this); + $boundary = $node->getChild(1)->accept($this); switch ($node->getId()) { case NodeTypes::LT_EXPR: - return AST\RangeExpression::lessThan($field, $expression); + return AST\RangeExpression::lessThan($key, $boundary); case NodeTypes::LTE_EXPR: - return AST\RangeExpression::lessThanOrEqual($field, $expression); + return AST\RangeExpression::lessThanOrEqual($key, $boundary); case NodeTypes::GT_EXPR: - return AST\RangeExpression::greaterThan($field, $expression); + return AST\RangeExpression::greaterThan($key, $boundary); case NodeTypes::GTE_EXPR: - return AST\RangeExpression::greaterThanOrEqual($field, $expression); + return AST\RangeExpression::greaterThanOrEqual($key, $boundary); } } diff --git a/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php new file mode 100644 index 0000000000..a18e2fd201 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php @@ -0,0 +1,93 @@ +assertTrue(method_exists(RangeExpression::class, '__toString'), 'Class does not have method __toString'); + } + + /** + * @dataProvider serializationProvider + */ + public function testSerialization($expression, $serialization) + { + $this->assertEquals($serialization, (string) $expression); + } + + public function serializationProvider() + { + $key_prophecy = $this->prophesize(Key::class); + $key_prophecy->__toString()->willReturn('foo'); + $key = $key_prophecy->reveal(); + return [ + [RangeExpression::lessThan($key, 42), '' ], + [RangeExpression::lessThanOrEqual($key, 42), ''], + [RangeExpression::greaterThan($key, 42), '' ], + [RangeExpression::greaterThanOrEqual($key, 42), ''], + ]; + } + + /** + * @dataProvider queryProvider + */ + public function testQueryBuild($factory, $key, $value, $result) + { + $query_context = $this->prophesize(QueryContext::class); + $node = RangeExpression::$factory($key, $value); + $query = $node->buildQuery($query_context->reveal()); + $this->assertEquals(json_decode($result, true), $query); + } + + public function queryProvider() + { + $key_prophecy = $this->prophesize(Key::class); + $key_prophecy->getIndexField()->willReturn('foo'); + $key = $key_prophecy->reveal(); + return [ + ['lessThan', $key, 'bar', '{"range":{"foo": {"lt":"bar"}}}'], + ['lessThanOrEqual', $key, 'baz', '{"range":{"foo": {"lte":"baz"}}}'], + ['greaterThan', $key, 'qux', '{"range":{"foo": {"gt":"qux"}}}'], + ['greaterThanOrEqual', $key, 'bla', '{"range":{"foo": {"gte":"bla"}}}'], + ]; + } + + public function testQueryBuildWithFieldKey() + { + $key = $this->prophesize(FieldKey::class); + $key->getValue()->willReturn('foo'); + $node = RangeExpression::lessThan($key->reveal(), 'bar'); + $structure_field = $this->prophesize(Field::class); + $structure_field->isPrivate()->willReturn(false); + $structure_field->isValueCompatible('bar')->willReturn(true); + $structure_field->getIndexField()->willReturn('baz'); + $query_context = $this->prophesize(QueryContext::class); + $query_context->get('foo')->willReturn($structure_field->reveal()); + $query = $node->buildQuery($query_context->reveal()); + + $expected = '{ + "range": { + "baz": { + "lt": "bar" + } + } + }'; + + $this->assertEquals(json_decode($expected, true), $query); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv b/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv index 0b6ec954d2..fa1b37a72b 100644 --- a/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv +++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv @@ -49,18 +49,18 @@ foo EXCEPT (bar OR baz)|( EXCEPT ( OR )) foo EXCEPT (bar EXCEPT baz)|( EXCEPT ( EXCEPT )) # Comparison operators -foo < 42| -foo ≤ 42| -foo > 42| -foo ≥ 42| -foo < 2015/01/01| -foo ≤ 2015/01/01| -foo > 2015/01/01| -foo ≥ 2015/01/01| -foo < "2015/01/01"| -foo ≤ "2015/01/01"| -foo > "2015/01/01"| -foo ≥ "2015/01/01"| +foo < 42| +foo ≤ 42| +foo > 42| +foo ≥ 42| +foo < 2015/01/01| +foo ≤ 2015/01/01| +foo > 2015/01/01| +foo ≥ 2015/01/01| +foo < "2015/01/01"| +foo ≤ "2015/01/01"| +foo > "2015/01/01"| +foo ≥ "2015/01/01"| foo = 42|( == ) foo = bar|( == ) foo = "bar"|( == ) @@ -104,6 +104,11 @@ true| # Metadata (EXIF or anything else) matcher meta.MimeType:image/jpeg| +exif.MimeType:image/jpeg| +meta.Duration < 300| +meta.Duration ≤ 300| +meta.Duration > 300| +meta.Duration ≥ 300| # Unescaped "." issue on key prefixes fieldOne:foo|( MATCHES )