diff --git a/grammar/query.pp b/grammar/query.pp index 6deea123e3..c68267c356 100644 --- a/grammar/query.pp +++ b/grammar/query.pp @@ -31,6 +31,9 @@ %token collection collection %token type type %token id id|recordid +%token flag_prefix flag. +%token true true|1 +%token false false|0 %token word [^\s()\[\]:<>≤≥=]+ // relative order of precedence is NOT > XOR > AND > OR @@ -58,9 +61,17 @@ quaternary: | ::collection:: ::colon:: string() #collection | ::type:: ::colon:: string() #type | ::id:: ::colon:: string() #id + | ::flag_prefix:: flag() ::colon:: boolean() #flag_statement | quinary() +#flag: + word_or_keyword()+ + +boolean: + + | + // Field narrowing quinary: @@ -140,6 +151,9 @@ keyword: | | | + | + | + | symbol: diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/FlagStatement.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/FlagStatement.php new file mode 100644 index 0000000000..fd8857632f --- /dev/null +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/AST/FlagStatement.php @@ -0,0 +1,43 @@ +name = $name; + $this->set = $set; + } + + public function buildQuery(QueryContext $context) + { + // TODO Ensure flag exists + $key = RecordHelper::normalizeFlagKey($this->name); + $field = sprintf('flags.%s', $key); + return [ + 'term' => [ + $field => $this->set + ] + ]; + } + + public function getTermNodes() + { + return array(); + } + + public function __toString() + { + return sprintf('', $this->name, $this->set ? 'set' : 'cleared'); + } +} diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php index 691017938f..2967bafc70 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/NodeTypes.php @@ -21,6 +21,8 @@ class NodeTypes const TERM = '#thesaurus_term'; const TEXT = '#text'; const CONTEXT = '#context'; + const FLAG_STATEMENT = '#flag_statement'; + const FLAG = '#flag'; const COLLECTION = '#collection'; const TYPE = '#type'; const DATABASE = '#database'; @@ -29,4 +31,6 @@ class NodeTypes const TOKEN_WORD = 'word'; const TOKEN_QUOTED_STRING = 'quoted'; const TOKEN_RAW_STRING = 'raw_quoted'; + const TOKEN_TRUE = 'true'; + const TOKEN_FALSE = 'false'; } diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php index 9ce5208dde..c377b83ba0 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php @@ -82,6 +82,12 @@ class QueryVisitor implements Visit case NodeTypes::FIELD: return new AST\Field($this->visitString($element)); + case NodeTypes::FLAG_STATEMENT: + return $this->visitFlagStatementNode($element); + + case NodeTypes::FLAG: + return $this->visitString($element); + case NodeTypes::DATABASE: return $this->visitDatabaseNode($element); @@ -242,6 +248,8 @@ class QueryVisitor implements Visit } } elseif ($node instanceof AST\Node) { $root = new AST\AndExpression($root, $node); + } else { + throw new \Exception('Unexpected node type inside text node.'); } } @@ -262,6 +270,35 @@ class QueryVisitor implements Visit return implode($tokens); } + private function visitFlagStatementNode(TreeNode $node) + { + if ($node->getChildrenNumber() !== 2) { + throw new \Exception('Flag statement can only have 2 childs.'); + } + + return new AST\FlagStatement( + $node->getChild(0)->accept($this), + $this->visitBoolean($node->getChild(1)) + ); + } + + private function visitBoolean(TreeNode $node) + { + if (null === $value = $node->getValue()) { + throw new \Exception('Boolean node must be a token'); + } + switch ($value['token']) { + case NodeTypes::TOKEN_TRUE: + return true; + + case NodeTypes::TOKEN_FALSE: + return false; + + default: + throw new \Exception('Unexpected token for a boolean.'); + } + } + private function visitDatabaseNode(Element $element) { if ($element->getChildrenNumber() !== 1) { diff --git a/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/FlagStatementTest.php b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/FlagStatementTest.php new file mode 100644 index 0000000000..6a1a2bbbcb --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/FlagStatementTest.php @@ -0,0 +1,39 @@ +assertTrue(method_exists(FlagStatement::class, '__toString'), 'Class does not have method __toString'); + $node = new FlagStatement('foo', true); + $this->assertEquals('', (string) $node); + $node = new FlagStatement('foo', false); + $this->assertEquals('', (string) $node); + } + + public function testQueryBuild() + { + $query_context = $this->prophesize(QueryContext::class); + + $node = new FlagStatement('foo', true); + $query = $node->buildQuery($query_context->reveal()); + + $expected = '{ + "term": { + "flags.foo": true + } + }'; + + $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 24e80869d4..d1d237df3f 100644 --- a/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv +++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv @@ -78,6 +78,15 @@ id:90 AND foo|( AND ) id:90 foo| recordid:90| +# Flag matcher +flag.foo:true| +flag.foo:1| +flag.foo:false| +flag.foo:0| +flag.true:true| +flag.foo bar:true| +true| + # Matcher on unknown name --> fulltext foo:bar|