Merge pull request #1525 from mdarse/new-in-syntax

Grammar unification
This commit is contained in:
Benoît Burnichon
2015-11-04 21:16:03 +01:00
25 changed files with 424 additions and 1567 deletions

View File

@@ -1,19 +0,0 @@
#!/bin/sh
jison_version="master";
jison_php="jison-$jison_version/ports/php/php.js"
cd grammar
if [ -f $jison_php ];
then
echo "Skip jison download"
else
echo "Download jison lib"
wget https://github.com/zaach/jison/archive/$jison_version.zip
unzip $jison_version.zip
rm $jison_version.zip
fi
node jison-$jison_version/ports/php/php.js query.jison
mv QueryParser.php ../lib/Alchemy/Phrasea/SearchEngine/Elastic/QueryParser.php

View File

@@ -1,149 +0,0 @@
/* description: Parses Phraseanet search queries. */
/* lexical grammar */
%lex
/* lexical states */
%x literal
/* begin lexing */
%%
\s+ /* skip whitespace */
"AND" return 'AND'
"and" return 'AND'
"et" return 'AND'
"OR" return 'OR'
"or" return 'OR'
"ou" return 'OR'
"IN" return 'IN'
"in" return 'IN'
"dans" return 'IN'
"(" return '('
")" return ')'
"*" return '*'
'"' {
//js
this.begin('literal');
//php $this->begin('literal');
}
<literal>'"' {
//js
this.popState();
//php $this->popState();
}
<literal>([^"])* return 'LITERAL'
\S+ return 'WORD'
<<EOF>> return 'EOF'
/lex
/* operator associations and precedence */
%left 'WORD'
%left 'AND' 'OR'
%left 'IN'
%start query
%% /* language grammar */
query
: expressions EOF {
//js
console.log('[QUERY]', $$);
return $$;
/*php
return $$;
*/
}
;
expressions
: expression expressions {
//js
$$ = '('+$1+' DEF_OP '+$2+')';
console.log('[DEF_OP]', $$);
// $$ = sprintf('(%s DEF_OP %s)', $1->text, $2->text);
/*php
$$ = new AST\AndExpression($1->text, $2->text);
*/
}
| expression
;
expression
: expression AND expression {
//js
$$ = '('+$1+' AND '+$3+')';
console.log('[AND]', $$);
/*php
$$ = new AST\AndExpression($1->text, $3->text);
*/
}
| expression OR expression {
//js
$$ = '('+$1+' OR '+$3+')';
console.log('[OR]', $$);
/*php
$$ = new AST\OrExpression($1->text, $3->text);
*/
}
| expression IN keyword {
//js
$$ = '('+$1+' IN '+$3+')';
console.log('[IN]', $$);
/*php
$$ = new AST\InExpression($3->text, $1->text);
*/
}
| '(' expression ')' {
//js
$$ = $2;
//php $$ = $2;
}
| prefix
| text
;
keyword
: WORD {
//js
$$ = '<'+$1+'>';
console.log('[FIELD]', $$);
//php $$ = new AST\KeywordNode($1->text);
}
;
prefix
: WORD '*' {
//js
$$ = $1+'*';
console.log('[PREFIX]', $$);
//php $$ = new AST\PrefixNode($1->text);
}
;
text
: WORD {
//js
$$ = '"'+$1+'"';
console.log('[WORD]', $$);
//php $$ = new AST\TextNode($1->text);
}
| LITERAL {
//js
$$ = '"'+$1+'"';
console.log('[LITERAL]', $$);
//php $$ = new AST\QuotedTextNode($1->text);
}
;
//option namespace:Alchemy\Phrasea\SearchEngine\Elastic
//option class:QueryParser
//option use:Alchemy\Phrasea\SearchEngine\Elastic\AST as AST;
//option fileName:QueryParser.php

View File

@@ -21,7 +21,6 @@
%token raw:_raw_quote " -> default %token raw:_raw_quote " -> default
// Operators (too bad we can't use preg "i" flag) // Operators (too bad we can't use preg "i" flag)
%token in [Ii][Nn]|[Dd][Aa][Nn][Ss]
%token and [Aa][Nn][Dd]|[Ee][Tt] %token and [Aa][Nn][Dd]|[Ee][Tt]
%token or [Oo][Rr]|[Oo][Uu] %token or [Oo][Rr]|[Oo][Uu]
%token except [Ee][Xx][Cc][Ee][Pp][Tt]|[Ss][Aa][Uu][Ff] %token except [Ee][Xx][Cc][Ee][Pp][Tt]|[Ss][Aa][Uu][Ff]
@@ -31,16 +30,23 @@
%token collection collection %token collection collection
%token type type %token type type
%token id id|recordid %token id id|recordid
%token field_prefix field.
%token flag_prefix flag. %token flag_prefix flag.
%token true true|1 %token true true|1
%token false false|0 %token false false|0
%token word [^\s()\[\]:<>≤≥=]+ %token word [^\s\(\)\[\]:<>≤≥=]+
// relative order of precedence is NOT > XOR > AND > OR // relative order of precedence is NOT > XOR > AND > OR
#query: #query:
::space::? primary()? ::space::? ::space::? primary()? ::space::?
| catch_all()
catch_all:
( <space>
| <word>
| keyword()
| symbol() #text )*
// Boolean operators // Boolean operators
@@ -53,46 +59,37 @@ secondary:
ternary: ternary:
quaternary() ( ::space:: ::and:: ::space:: primary() #and )? quaternary() ( ::space:: ::and:: ::space:: primary() #and )?
// Collection / database / record id matcher
quaternary: quaternary:
::database:: ::colon:: string() #database group() #group
| ::collection:: ::colon:: string() #collection | key_value_pair() ( ::space:: primary() #and )?
| ::type:: ::colon:: string() #type | term() ( ::space:: key_value_pair() #and )?
| ::id:: ::colon:: string() #id
| ::flag_prefix:: flag() ::colon:: boolean() #flag_statement
| quinary()
// Key value pairs & field level matchers (restricted to a single field)
key_value_pair:
native_key() ::colon:: ::space::? value() #native_key_value
| ::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
| field() ::space::? ::equal:: ::space::? value() #equal_to
#flag: #flag:
word_or_keyword()+ word_or_keyword()+
boolean: #native_key:
<true> <database>
| <false> | <collection>
| <type>
// Field narrowing | <id>
quinary:
senary() ( ::space:: ::in:: ::space:: field() #in )?
#field: #field:
word_or_keyword()+ word_or_keyword()+
| quoted_string() | quoted_string()
// Field level matchers (*may* be restricted to a field subset)
senary:
group() #group
| 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
| field() ::space::? ::equal:: ::space::? value() #equal_to
| term()
#value: #value:
word_or_keyword()+ word_or_keyword()+
| quoted_string() | quoted_string()
@@ -111,13 +108,9 @@ term:
// Free text handling // Free text handling
text: text:
string_keyword_symbol()
( <space>? string_keyword_symbol() )*
( ::space::? context_block() )?
string_keyword_symbol:
string() string()
| symbol() ( <space>? string() )*
( ::space::? context_block() )?
context_block: context_block:
::parenthese_:: ::space::? context() ::space::? ::_parenthese:: #context ::parenthese_:: ::space::? context() ::space::? ::_parenthese:: #context
@@ -128,6 +121,10 @@ context:
// Generic helpers // Generic helpers
boolean:
<true>
| <false>
string: string:
word_or_keyword()+ word_or_keyword()+
| quoted_string() | quoted_string()
@@ -143,14 +140,14 @@ raw_quoted_string:
::raw_quote_:: <raw_quoted> ::_raw_quote:: ::raw_quote_:: <raw_quoted> ::_raw_quote::
keyword: keyword:
<in> <except>
| <except>
| <and> | <and>
| <or> | <or>
| <database> | <database>
| <collection> | <collection>
| <type> | <type>
| <id> | <id>
| <field_prefix>
| <flag_prefix> | <flag_prefix>
| <true> | <true>
| <false> | <false>

View File

@@ -4,7 +4,6 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryHelper; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryHelper;
use Alchemy\Phrasea\SearchEngine\Elastic\Structure\Field;
use Alchemy\Phrasea\SearchEngine\Elastic\Thesaurus\Concept; use Alchemy\Phrasea\SearchEngine\Elastic\Thesaurus\Concept;
use Alchemy\Phrasea\SearchEngine\Elastic\Thesaurus\TermInterface; use Alchemy\Phrasea\SearchEngine\Elastic\Thesaurus\TermInterface;

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST; namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\Boolean;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class AndExpression extends BinaryOperator class AndOperator extends BinaryOperator
{ {
protected $operator = 'AND'; protected $operator = 'AND';

View File

@@ -1,6 +1,8 @@
<?php <?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST; namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\Boolean;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\Node;
abstract class BinaryOperator extends Node abstract class BinaryOperator extends Node
{ {

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST; namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\Boolean;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class ExceptExpression extends BinaryOperator class ExceptOperator extends BinaryOperator
{ {
protected $operator = 'EXCEPT'; protected $operator = 'EXCEPT';

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST; namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\Boolean;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class OrExpression extends BinaryOperator class OrOperator extends BinaryOperator
{ {
protected $operator = 'OR'; protected $operator = 'OR';

View File

@@ -1,34 +0,0 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class CollectionExpression extends Node
{
private $collectionName;
public function __construct($collectionName)
{
$this->collectionName = $collectionName;
}
public function buildQuery(QueryContext $context)
{
return [
'term' => [
'collection_name' => $this->collectionName
]
];
}
public function getTermNodes()
{
return array();
}
public function __toString()
{
return sprintf('<collection:%s>', $this->collectionName);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class DatabaseExpression extends Node
{
private $database;
public function __construct($database)
{
$this->database = $database;
}
public function buildQuery(QueryContext $context)
{
return [
'term' => [
'databox_name' => $this->database
]
];
}
public function getTermNodes()
{
return array();
}
public function __toString()
{
return sprintf('<database:%s>', $this->database);
}
}

View File

@@ -29,7 +29,7 @@ class FieldEqualsExpression extends Node
$query = [ $query = [
'term' => [ 'term' => [
$structure_field->getIndexField() => $this->value $structure_field->getIndexField(true) => $this->value
] ]
]; ];

View File

@@ -4,7 +4,7 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class InExpression extends Node class FieldMatchExpression extends Node
{ {
protected $field; protected $field;
protected $expression; protected $expression;
@@ -29,6 +29,6 @@ class InExpression extends Node
public function __toString() public function __toString()
{ {
return sprintf('(%s IN %s)', $this->expression, $this->field); return sprintf('(%s MATCHES %s)', $this->field, $this->expression);
} }
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\Node;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
use Assert\Assertion;
class Expression extends Node
{
protected $key;
protected $value;
public function __construct(Key $key, $value)
{
Assertion::string($value);
$this->key = $key;
$this->value = $value;
}
public function buildQuery(QueryContext $context)
{
return $this->key->buildQueryForValue($this->value, $context);
}
public function getTermNodes()
{
return [];
}
public function __toString()
{
return sprintf('<%s:%s>', $this->key, $this->value);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
interface Key
{
public function buildQueryForValue($value, QueryContext $context);
public function __toString();
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
use Assert\Assertion;
class NativeKey implements Key
{
const TYPE_DATABASE = 'database';
const TYPE_COLLECTION = 'collection';
const TYPE_MEDIA_TYPE = 'media_type';
const TYPE_RECORD_IDENTIFIER = 'record_identifier';
private $type;
private $key;
public static function database()
{
return new self(self::TYPE_DATABASE, 'databox_name');
}
public static function collection()
{
return new self(self::TYPE_COLLECTION, 'collection_name');
}
public static function mediaType()
{
return new self(self::TYPE_MEDIA_TYPE, 'type');
}
public static function recordIdentifier()
{
return new self(self::TYPE_RECORD_IDENTIFIER, 'record_id');
}
private function __construct($type, $key)
{
$this->type = $type;
$this->key = $key;
}
public function buildQueryForValue($value, QueryContext $context)
{
Assertion::string($value);
return [
'term' => [
$this->key => $value
]
];
}
public function __toString()
{
return $this->type;
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class RecordIdentifierExpression extends Node
{
private $record_id;
public function __construct($record_id)
{
$this->record_id = $record_id;
}
public function buildQuery(QueryContext $context)
{
return [
'term' => [
'record_id' => $this->record_id
]
];
}
public function getTermNodes()
{
return array();
}
public function __toString()
{
return sprintf('<record_identifier:%s>', $this->record_id);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
class TypeExpression extends Node
{
private $typeName;
public function __construct($typeName)
{
$this->typeName = $typeName;
}
public function buildQuery(QueryContext $context)
{
return [
'term' => [
'type' => $this->typeName
]
];
}
public function getTermNodes()
{
return array();
}
public function __toString()
{
return sprintf('<type:%s>', $this->typeName);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -78,7 +78,9 @@ class FacetsResponse implements JsonSerializable
case 'Type': case 'Type':
return sprintf('type:%s', $this->escaper->escapeWord($value)); return sprintf('type:%s', $this->escaper->escapeWord($value));
default: default:
return sprintf('r"%s" IN %s', $this->escaper->escapeRaw($value), $name); return sprintf('%s = %s',
$this->escaper->escapeWord($name),
$this->escaper->escapeWord($value));
} }
} }

View File

@@ -7,7 +7,6 @@ class NodeTypes
// Tree node types // Tree node types
const QUERY = '#query'; const QUERY = '#query';
const GROUP = '#group'; const GROUP = '#group';
const IN_EXPR = '#in';
const AND_EXPR = '#and'; const AND_EXPR = '#and';
const OR_EXPR = '#or'; const OR_EXPR = '#or';
const EXCEPT_EXPR = '#except'; const EXCEPT_EXPR = '#except';
@@ -16,6 +15,7 @@ class NodeTypes
const LTE_EXPR = '#less_than_or_equal_to'; const LTE_EXPR = '#less_than_or_equal_to';
const GTE_EXPR = '#greater_than_or_equal_to'; const GTE_EXPR = '#greater_than_or_equal_to';
const EQUAL_EXPR = '#equal_to'; const EQUAL_EXPR = '#equal_to';
const FIELD_STATEMENT = '#field_statement';
const FIELD = '#field'; const FIELD = '#field';
const VALUE = '#value'; const VALUE = '#value';
const TERM = '#thesaurus_term'; const TERM = '#thesaurus_term';
@@ -23,14 +23,16 @@ class NodeTypes
const CONTEXT = '#context'; const CONTEXT = '#context';
const FLAG_STATEMENT = '#flag_statement'; const FLAG_STATEMENT = '#flag_statement';
const FLAG = '#flag'; const FLAG = '#flag';
const COLLECTION = '#collection'; const NATIVE_KEY_VALUE = '#native_key_value';
const TYPE = '#type'; const NATIVE_KEY = '#native_key';
const DATABASE = '#database';
const IDENTIFIER = '#id';
// Token types for leaf nodes // Token types for leaf nodes
const TOKEN_WORD = 'word'; const TOKEN_WORD = 'word';
const TOKEN_QUOTED_STRING = 'quoted'; const TOKEN_QUOTED_STRING = 'quoted';
const TOKEN_RAW_STRING = 'raw_quoted'; const TOKEN_RAW_STRING = 'raw_quoted';
const TOKEN_DATABASE = 'database';
const TOKEN_COLLECTION = 'collection';
const TOKEN_MEDIA_TYPE = 'type';
const TOKEN_RECORD_ID = 'id';
const TOKEN_TRUE = 'true'; const TOKEN_TRUE = 'true';
const TOKEN_FALSE = 'false'; const TOKEN_FALSE = 'false';
} }

View File

@@ -3,6 +3,7 @@
namespace Alchemy\Phrasea\SearchEngine\Elastic\Search; namespace Alchemy\Phrasea\SearchEngine\Elastic\Search;
use Alchemy\Phrasea\SearchEngine\Elastic\AST; use Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Exception\Exception;
use Hoa\Compiler\Llk\TreeNode; use Hoa\Compiler\Llk\TreeNode;
use Hoa\Visitor\Element; use Hoa\Visitor\Element;
use Hoa\Visitor\Visit; use Hoa\Visitor\Visit;
@@ -46,9 +47,6 @@ class QueryVisitor implements Visit
case NodeTypes::GROUP: case NodeTypes::GROUP:
return $this->visitNode($element->getChild(0)); return $this->visitNode($element->getChild(0));
case NodeTypes::IN_EXPR:
return $this->visitInNode($element);
case NodeTypes::AND_EXPR: case NodeTypes::AND_EXPR:
return $this->visitAndNode($element); return $this->visitAndNode($element);
@@ -79,6 +77,9 @@ class QueryVisitor implements Visit
case NodeTypes::CONTEXT: case NodeTypes::CONTEXT:
return new AST\Context($this->visitString($element)); return new AST\Context($this->visitString($element));
case NodeTypes::FIELD_STATEMENT:
return $this->visitFieldStatementNode($element);
case NodeTypes::FIELD: case NodeTypes::FIELD:
return new AST\Field($this->visitString($element)); return new AST\Field($this->visitString($element));
@@ -88,20 +89,14 @@ class QueryVisitor implements Visit
case NodeTypes::FLAG: case NodeTypes::FLAG:
return new AST\Flag($this->visitString($element)); return new AST\Flag($this->visitString($element));
case NodeTypes::DATABASE: case NodeTypes::NATIVE_KEY_VALUE:
return $this->visitDatabaseNode($element); return $this->visitNativeKeyValueNode($element);
case NodeTypes::COLLECTION: case NodeTypes::NATIVE_KEY:
return $this->visitCollectionNode($element); return $this->visitNativeKeyNode($element);
case NodeTypes::TYPE:
return $this->visitTypeNode($element);
case NodeTypes::IDENTIFIER:
return $this->visitIdentifierNode($element);
default: default:
throw new \Exception(sprintf('Unknown node type "%s".', $element->getId())); throw new Exception(sprintf('Unknown node type "%s".', $element->getId()));
} }
} }
@@ -114,41 +109,41 @@ class QueryVisitor implements Visit
return new Query($root); return new Query($root);
} }
private function visitInNode(Element $element) private function visitFieldStatementNode(TreeNode $node)
{ {
if ($element->getChildrenNumber() !== 2) { if ($node->getChildrenNumber() !== 2) {
throw new \Exception('IN expression can only have 2 childs.'); throw new Exception('Field statement must have 2 childs.');
} }
$expression = $element->getChild(0)->accept($this); $field = $this->visit($node->getChild(0));
$field = $this->visit($element->getChild(1)); $value = $this->visit($node->getChild(1));
return new AST\InExpression($field, $expression); return new AST\FieldMatchExpression($field, $value);
} }
private function visitAndNode(Element $element) private function visitAndNode(Element $element)
{ {
return $this->handleBinaryOperator($element, function($left, $right) { return $this->handleBinaryOperator($element, function($left, $right) {
return new AST\AndExpression($left, $right); return new AST\Boolean\AndOperator($left, $right);
}); });
} }
private function visitOrNode(Element $element) private function visitOrNode(Element $element)
{ {
return $this->handleBinaryOperator($element, function($left, $right) { return $this->handleBinaryOperator($element, function($left, $right) {
return new AST\OrExpression($left, $right); return new AST\Boolean\OrOperator($left, $right);
}); });
} }
private function visitExceptNode(Element $element) private function visitExceptNode(Element $element)
{ {
return $this->handleBinaryOperator($element, function($left, $right) { return $this->handleBinaryOperator($element, function($left, $right) {
return new AST\ExceptExpression($left, $right); return new AST\Boolean\ExceptOperator($left, $right);
}); });
} }
private function visitRangeNode(TreeNode $node) private function visitRangeNode(TreeNode $node)
{ {
if ($node->getChildrenNumber() !== 2) { if ($node->getChildrenNumber() !== 2) {
throw new \Exception('Comparison operator can only have 2 childs.'); throw new Exception('Comparison operator can only have 2 childs.');
} }
$field = $node->getChild(0)->accept($this); $field = $node->getChild(0)->accept($this);
$expression = $node->getChild(1)->accept($this); $expression = $node->getChild(1)->accept($this);
@@ -168,7 +163,7 @@ class QueryVisitor implements Visit
private function handleBinaryOperator(Element $element, \Closure $factory) private function handleBinaryOperator(Element $element, \Closure $factory)
{ {
if ($element->getChildrenNumber() !== 2) { if ($element->getChildrenNumber() !== 2) {
throw new \Exception('Binary expression can only have 2 childs.'); throw new Exception('Binary expression can only have 2 childs.');
} }
$left = $element->getChild(0)->accept($this); $left = $element->getChild(0)->accept($this);
$right = $element->getChild(1)->accept($this); $right = $element->getChild(1)->accept($this);
@@ -179,7 +174,7 @@ class QueryVisitor implements Visit
private function visitEqualNode(TreeNode $node) private function visitEqualNode(TreeNode $node)
{ {
if ($node->getChildrenNumber() !== 2) { if ($node->getChildrenNumber() !== 2) {
throw new \Exception('Equality operator can only have 2 childs.'); throw new Exception('Equality operator can only have 2 childs.');
} }
return new AST\FieldEqualsExpression( return new AST\FieldEqualsExpression(
@@ -196,13 +191,13 @@ class QueryVisitor implements Visit
$node = $child->accept($this); $node = $child->accept($this);
if ($node instanceof AST\TextNode) { if ($node instanceof AST\TextNode) {
if ($context) { if ($context) {
throw new \Exception('Unexpected text node after context'); throw new Exception('Unexpected text node after context');
} }
$words[] = $node->getValue(); $words[] = $node->getValue();
} elseif ($node instanceof AST\Context) { } elseif ($node instanceof AST\Context) {
$context = $node; $context = $node;
} else { } else {
throw new \Exception('Term node can only contain text nodes'); throw new Exception('Term node can only contain text nodes');
} }
} }
@@ -223,7 +218,7 @@ class QueryVisitor implements Visit
$node instanceof AST\TextNode) { $node instanceof AST\TextNode) {
// Prevent merge once a context is set // Prevent merge once a context is set
if ($last->hasContext()) { if ($last->hasContext()) {
throw new \Exception('Unexpected text node after context'); throw new Exception('Unexpected text node after context');
} }
$nodes[$last_index] = $last = AST\TextNode::merge($last, $node); $nodes[$last_index] = $last = AST\TextNode::merge($last, $node);
} else { } else {
@@ -244,12 +239,12 @@ class QueryVisitor implements Visit
if ($root instanceof AST\ContextAbleInterface) { if ($root instanceof AST\ContextAbleInterface) {
$root = $root->withContext($node); $root = $root->withContext($node);
} else { } else {
throw new \Exception('Unexpected context after non-contextualizable node'); throw new Exception('Unexpected context after non-contextualizable node');
} }
} elseif ($node instanceof AST\Node) { } elseif ($node instanceof AST\Node) {
$root = new AST\AndExpression($root, $node); $root = new AST\Boolean\AndOperator($root, $node);
} else { } else {
throw new \Exception('Unexpected node type inside text node.'); throw new Exception('Unexpected node type inside text node.');
} }
} }
@@ -273,7 +268,7 @@ class QueryVisitor implements Visit
private function visitFlagStatementNode(TreeNode $node) private function visitFlagStatementNode(TreeNode $node)
{ {
if ($node->getChildrenNumber() !== 2) { if ($node->getChildrenNumber() !== 2) {
throw new \Exception('Flag statement can only have 2 childs.'); throw new Exception('Flag statement can only have 2 childs.');
} }
$flag = $node->getChild(0)->accept($this); $flag = $node->getChild(0)->accept($this);
if (!$flag instanceof AST\Flag) { if (!$flag instanceof AST\Flag) {
@@ -289,7 +284,7 @@ class QueryVisitor implements Visit
private function visitBoolean(TreeNode $node) private function visitBoolean(TreeNode $node)
{ {
if (null === $value = $node->getValue()) { if (null === $value = $node->getValue()) {
throw new \Exception('Boolean node must be a token'); throw new Exception('Boolean node must be a token');
} }
switch ($value['token']) { switch ($value['token']) {
case NodeTypes::TOKEN_TRUE: case NodeTypes::TOKEN_TRUE:
@@ -299,47 +294,37 @@ class QueryVisitor implements Visit
return false; return false;
default: default:
throw new \Exception('Unexpected token for a boolean.'); throw new Exception('Unexpected token for a boolean.');
} }
} }
private function visitDatabaseNode(Element $element) private function visitNativeKeyValueNode(TreeNode $node)
{ {
if ($element->getChildrenNumber() !== 1) { if ($node->getChildrenNumber() !== 2) {
throw new \Exception('Base filter can only have a single child.'); throw new Exception('Key value expression can only have 2 childs.');
} }
$baseName = $element->getChild(0)->getValue()['value']; $key = $this->visit($node->getChild(0));
$value = $this->visit($node->getChild(1));
return new AST\DatabaseExpression($baseName); return new AST\KeyValue\Expression($key, $value);
} }
private function visitCollectionNode(Element $element) private function visitNativeKeyNode(Element $element)
{ {
if ($element->getChildrenNumber() !== 1) { if ($element->getChildrenNumber() !== 1) {
throw new \Exception('Collection filter can only have a single child.'); throw new Exception('Native key node can only have a single child.');
} }
$collectionName = $element->getChild(0)->getValue()['value']; $type = $element->getChild(0)->getValue()['token'];
switch ($type) {
return new AST\CollectionExpression($collectionName); case NodeTypes::TOKEN_DATABASE:
} return AST\KeyValue\NativeKey::database();
case NodeTypes::TOKEN_COLLECTION:
private function visitTypeNode(Element $element) return AST\KeyValue\NativeKey::collection();
{ case NodeTypes::TOKEN_MEDIA_TYPE:
if ($element->getChildrenNumber() !== 1) { return AST\KeyValue\NativeKey::mediaType();
throw new \Exception('Type filter can only have a single child.'); case NodeTypes::TOKEN_RECORD_ID:
return AST\KeyValue\NativeKey::recordIdentifier();
default:
throw new InvalidArgumentException(sprintf('Unexpected token type "%s" for native key.', $type));
} }
$typeName = $element->getChild(0)->getValue()['value'];
return new AST\TypeExpression($typeName);
}
private function visitIdentifierNode(Element $element)
{
if ($element->getChildrenNumber() !== 1) {
throw new \Exception('Identifier filter can only have a single child.');
}
$identifier = $element->getChild(0)->getValue()['value'];
return new AST\RecordIdentifierExpression($identifier);
} }
} }

View File

@@ -0,0 +1,67 @@
<?php
namespace Alchemy\Tests\Phrasea\SearchEngine\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\Field as ASTField;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\FieldEqualsExpression;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
use Alchemy\Phrasea\SearchEngine\Elastic\Structure\Field as StructureField;
/**
* @group unit
* @group searchengine
* @group ast
*/
class FieldEqualsExpressionTest extends \PHPUnit_Framework_TestCase
{
public function testSerialization()
{
$this->assertTrue(method_exists(FieldEqualsExpression::class, '__toString'), 'Class does not have method __toString');
$field = $this->prophesize(ASTField::class);
$field->__toString()->willReturn('foo');
$node = new FieldEqualsExpression($field->reveal(), 'bar');
$this->assertEquals('(foo == <value:"bar">)', (string) $node);
}
/**
* @dataProvider queryProvider
*/
public function testQueryBuild($index_field, $value, $compatible_value, $private, $expected_json)
{
$structure_field = $this->prophesize(StructureField::class);
$structure_field->isValueCompatible($value)->willReturn($compatible_value);
$structure_field->getIndexField(true)->willReturn($index_field);
$structure_field->isPrivate()->willReturn($private);
if ($private) {
$structure_field->getDependantCollections()->willReturn(['baz', 'qux']);
}
$ast_field = $this->prophesize(ASTField::class);
$query_context = $this->prophesize(QueryContext::class);
$query_context->get($ast_field->reveal())->willReturn($structure_field);
$node = new FieldEqualsExpression($ast_field->reveal(), 'bar');
$query = $node->buildQuery($query_context->reveal());
$this->assertEquals(json_decode($expected_json, true), $query);
}
public function queryProvider()
{
return [
['foo.raw', 'bar', true, true, '{
"filtered": {
"filter": {
"terms": {
"base_id": ["baz","qux"] } },
"query": {
"term": {
"foo.raw": "bar" } } } }'],
['foo.raw', 'bar', true, false, '{
"term": {
"foo.raw": "bar" } }'],
['foo.raw', 'bar', false, true, 'null'],
['foo.raw', 'bar', false, false, 'null'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Alchemy\Tests\Phrasea\SearchEngine\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue\Key;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue\Expression as KeyValueExpression;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
/**
* @group unit
* @group searchengine
* @group ast
*/
class KeyValueExpressionTest extends \PHPUnit_Framework_TestCase
{
public function testSerialization()
{
$this->assertTrue(method_exists(KeyValueExpression::class, '__toString'), 'Class does not have method __toString');
$key = $this->prophesize(Key::class);
$key->__toString()->willReturn('foo');
$node = new KeyValueExpression($key->reveal(), 'bar');
$this->assertEquals('<foo:bar>', (string) $node);
}
public function testQueryBuild()
{
$query_context = $this->prophesize(QueryContext::class);
$key = $this->prophesize(Key::class);
$key->buildQueryForValue('bar', $query_context->reveal())->willReturn('baz');
$node = new KeyValueExpression($key->reveal(), 'bar');
$query = $node->buildQuery($query_context->reveal());
$this->assertEquals('baz', $query);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Alchemy\Tests\Phrasea\SearchEngine\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\AST\KeyValue\NativeKey;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext;
/**
* @group unit
* @group searchengine
* @group ast
*/
class NativeKeyTest extends \PHPUnit_Framework_TestCase
{
public function testSerialization()
{
$this->assertTrue(method_exists(NativeKey::class, '__toString'), 'Class does not have method __toString');
$this->assertEquals('database', (string) NativeKey::database());
$this->assertEquals('collection', (string) NativeKey::collection());
$this->assertEquals('media_type', (string) NativeKey::mediaType());
$this->assertEquals('record_identifier', (string) NativeKey::recordIdentifier());
}
public function testDatabaseQuery()
{
$query_context = $this->prophesize(QueryContext::class);
$key = NativeKey::database();
$query = $key->buildQueryForValue('bar', $query_context->reveal());
$expected = '{
"term": {
"databox_name": "bar"
}
}';
$this->assertEquals(json_decode($expected, true), $query);
}
public function testCollectionQuery()
{
$query_context = $this->prophesize(QueryContext::class);
$key = NativeKey::collection();
$query = $key->buildQueryForValue('bar', $query_context->reveal());
$expected = '{
"term": {
"collection_name": "bar"
}
}';
$this->assertEquals(json_decode($expected, true), $query);
}
public function testMediaTypeQuery()
{
$query_context = $this->prophesize(QueryContext::class);
$key = NativeKey::mediaType();
$query = $key->buildQueryForValue('bar', $query_context->reveal());
$expected = '{
"term": {
"type": "bar"
}
}';
$this->assertEquals(json_decode($expected, true), $query);
}
public function testRecordIdentifierQuery()
{
$query_context = $this->prophesize(QueryContext::class);
$key = NativeKey::recordIdentifier();
$query = $key->buildQueryForValue('bar', $query_context->reveal());
$expected = '{
"term": {
"record_id": "bar"
}
}';
$this->assertEquals(json_decode($expected, true), $query);
}
}

View File

@@ -1,5 +1,7 @@
foo|<text:"foo"> foo|<text:"foo">
foo (bar)|<text:"foo" context:"bar"> foo (bar)|<text:"foo" context:"bar">
foo "bar"|(<text:"foo "> AND <exact_text:"bar">)
"foo" bar|(<exact_text:"foo"> AND <text:" bar">)
# foo ("bar baz")|<text:"foo" context:"bar baz"> # foo ("bar baz")|<text:"foo" context:"bar baz">
foo bar|<text:"foo bar"> foo bar|<text:"foo bar">
foo bar (baz qux)|<text:"foo bar" context:"baz qux"> foo bar (baz qux)|<text:"foo bar" context:"baz qux">
@@ -46,7 +48,7 @@ foo EXCEPT (bar AND baz)|(<text:"foo"> EXCEPT (<text:"bar"> AND <text:"baz">))
foo EXCEPT (bar OR baz)|(<text:"foo"> EXCEPT (<text:"bar"> OR <text:"baz">)) foo EXCEPT (bar OR baz)|(<text:"foo"> EXCEPT (<text:"bar"> OR <text:"baz">))
foo EXCEPT (bar EXCEPT baz)|(<text:"foo"> EXCEPT (<text:"bar"> EXCEPT <text:"baz">)) foo EXCEPT (bar EXCEPT baz)|(<text:"foo"> EXCEPT (<text:"bar"> EXCEPT <text:"baz">))
# Inequality operators # Comparison operators
foo < 42|<range:foo lt="42"> foo < 42|<range:foo lt="42">
foo ≤ 42|<range:foo lte="42"> foo ≤ 42|<range:foo lte="42">
foo > 42|<range:foo gt="42"> foo > 42|<range:foo gt="42">
@@ -59,23 +61,36 @@ foo < "2015/01/01"|<range:foo lt="2015/01/01">
foo ≤ "2015/01/01"|<range:foo lte="2015/01/01"> foo ≤ "2015/01/01"|<range:foo lte="2015/01/01">
foo > "2015/01/01"|<range:foo gt="2015/01/01"> foo > "2015/01/01"|<range:foo gt="2015/01/01">
foo ≥ "2015/01/01"|<range:foo gte="2015/01/01"> foo ≥ "2015/01/01"|<range:foo gte="2015/01/01">
foo = 42|(<field:foo> == <value:"42">)
foo = bar|(<field:foo> == <value:"bar">)
foo = "bar"|(<field:foo> == <value:"bar">)
# Field narrowing # Field narrowing
foo IN bar|(<text:"foo"> IN <field:bar>) foo:bar|(<field:foo> MATCHES <text:"bar">)
foo bar IN baz|(<text:"foo bar"> IN <field:baz>) foo:[bar]|(<field:foo> MATCHES <term:"bar">)
foo IN bar baz|<text:"foo IN bar baz"> foo:[bar (baz)]|(<field:foo> MATCHES <term:"bar" context:"baz">)
fooINbar|<text:"fooINbar"> foo:bar baz|((<field:foo> MATCHES <text:"bar">) AND <text:"baz">)
foo bar:baz|(<text:"foo"> AND (<field:bar> MATCHES <text:"baz">))
# Regular field with name colliding with a native key
field.collection:foo|(<field:collection> MATCHES <text:"foo">)
field.database:foo|(<field:database> MATCHES <text:"foo">)
field.type:foo|(<field:type> MATCHES <text:"foo">)
field.id:foo|(<field:id> MATCHES <text:"foo">)
# Matchers # Matchers
collection:foo|<collection:foo> collection:foo|<collection:foo>
collection:foo AND bar|(<collection:foo> AND <text:"bar">) collection:foo AND bar|(<collection:foo> AND <text:"bar">)
collection:foo bar|<text:"collection:foo bar"> collection:foo bar|(<collection:foo> AND <text:"bar">)
database:foo|<database:foo> database:foo|<database:foo>
database:foo AND bar|(<database:foo> AND <text:"bar">) database:foo AND bar|(<database:foo> AND <text:"bar">)
database:foo bar|<text:"database:foo bar"> database:foo bar|(<database:foo> AND <text:"bar">)
type:foo|<media_type:foo>
type:foo AND bar|(<media_type:foo> AND <text:"bar">)
type:foo bar|(<media_type:foo> AND <text:"bar">)
id:90|<record_identifier:90> id:90|<record_identifier:90>
id:90 AND foo|(<record_identifier:90> AND <text:"foo">) id:90 AND foo|(<record_identifier:90> AND <text:"foo">)
id:90 foo|<text:"id:90 foo"> id:90 foo|(<record_identifier:90> AND <text:"foo">)
recordid:90|<record_identifier:90> recordid:90|<record_identifier:90>
# Flag matcher # Flag matcher
@@ -84,11 +99,13 @@ flag.foo:1|<flag:foo set>
flag.foo:false|<flag:foo cleared> flag.foo:false|<flag:foo cleared>
flag.foo:0|<flag:foo cleared> flag.foo:0|<flag:foo cleared>
flag.true:true|<flag:true set> flag.true:true|<flag:true set>
flag.foo bar:true|<text:"flag.foo bar:true"> flag.foo bar:true|(<text:"flag.foo"> AND (<field:bar> MATCHES <text:"true">))
true|<text:"true"> true|<text:"true">
# Matcher on unknown name --> fulltext # Matcher on unknown name --> fulltext
foo:bar|<text:"foo:bar"> foo:bar|(<field:foo> MATCHES <text:"bar">)
foo:bar AND baz|((<field:foo> MATCHES <text:"bar">) AND <text:"baz">)
foo AND bar:baz|(<text:"foo"> AND (<field:bar> MATCHES <text:"baz">))
# Search terms with embedded keywords # Search terms with embedded keywords
INA|<text:"INA"> INA|<text:"INA">
Can't render this file because it contains an unexpected character in line 1 and column 11.