mirror of
https://github.com/firefly-iii/firefly-iii.git
synced 2025-09-24 06:43:23 +00:00
Merge pull request #9598 from Sobuno/NewQueryParserV3
New Query Parser for Search Engine and Rules
This commit is contained in:
@@ -325,6 +325,12 @@ USE_RUNNING_BALANCE=false
|
|||||||
#
|
#
|
||||||
FIREFLY_III_LAYOUT=v1
|
FIREFLY_III_LAYOUT=v1
|
||||||
|
|
||||||
|
#
|
||||||
|
# Which Query Parser implementation to use for the Search Engine and Rules
|
||||||
|
# 'new' is experimental, 'legacy' is the classic one
|
||||||
|
#
|
||||||
|
QUERY_PARSER_IMPLEMENTATION=legacy
|
||||||
|
|
||||||
#
|
#
|
||||||
# Please make sure this URL matches the external URL of your Firefly III installation.
|
# Please make sure this URL matches the external URL of your Firefly III installation.
|
||||||
# It is used to validate specific requests and to generate URLs in emails.
|
# It is used to validate specific requests and to generate URLs in emails.
|
||||||
|
@@ -91,15 +91,27 @@ class CreateController extends Controller
|
|||||||
if ('' !== $query) {
|
if ('' !== $query) {
|
||||||
$search = app(SearchInterface::class);
|
$search = app(SearchInterface::class);
|
||||||
$search->parseQuery($query);
|
$search->parseQuery($query);
|
||||||
$words = $search->getWordsAsString();
|
$words = $search->getWords();
|
||||||
|
$excludedWords = $search->getExcludedWords();
|
||||||
$operators = $search->getOperators()->toArray();
|
$operators = $search->getOperators()->toArray();
|
||||||
if ('' !== $words) {
|
if (count($words) > 0) {
|
||||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words]));
|
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $words)]));
|
||||||
|
foreach($words as $word) {
|
||||||
$operators[] = [
|
$operators[] = [
|
||||||
'type' => 'description_contains',
|
'type' => 'description_contains',
|
||||||
'value' => $words,
|
'value' => $word,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (count($excludedWords) > 0) {
|
||||||
|
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $excludedWords)]));
|
||||||
|
foreach($excludedWords as $excludedWord) {
|
||||||
|
$operators[] = [
|
||||||
|
'type' => '-description_contains',
|
||||||
|
'value' => $excludedWord,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
$oldTriggers = $this->parseFromOperators($operators);
|
$oldTriggers = $this->parseFromOperators($operators);
|
||||||
}
|
}
|
||||||
// var_dump($oldTriggers);exit;
|
// var_dump($oldTriggers);exit;
|
||||||
|
@@ -87,11 +87,26 @@ class EditController extends Controller
|
|||||||
if ('' !== $query) {
|
if ('' !== $query) {
|
||||||
$search = app(SearchInterface::class);
|
$search = app(SearchInterface::class);
|
||||||
$search->parseQuery($query);
|
$search->parseQuery($query);
|
||||||
$words = $search->getWordsAsString();
|
$words = $search->getWords();
|
||||||
|
$excludedWords = $search->getExcludedWords();
|
||||||
$operators = $search->getOperators()->toArray();
|
$operators = $search->getOperators()->toArray();
|
||||||
if ('' !== $words) {
|
if (count($words) > 0) {
|
||||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words]));
|
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $words)]));
|
||||||
$operators[] = ['type' => 'description_contains', 'value' => $words];
|
foreach($words as $word) {
|
||||||
|
$operators[] = [
|
||||||
|
'type' => 'description_contains',
|
||||||
|
'value' => $word,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($excludedWords) > 0) {
|
||||||
|
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $excludedWords)]));
|
||||||
|
foreach($excludedWords as $excludedWord) {
|
||||||
|
$operators[] = [
|
||||||
|
'type' => '-description_contains',
|
||||||
|
'value' => $excludedWord,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$oldTriggers = $this->parseFromOperators($operators);
|
$oldTriggers = $this->parseFromOperators($operators);
|
||||||
}
|
}
|
||||||
|
@@ -83,12 +83,13 @@ class SearchController extends Controller
|
|||||||
$searcher->parseQuery($fullQuery);
|
$searcher->parseQuery($fullQuery);
|
||||||
|
|
||||||
// words from query and operators:
|
// words from query and operators:
|
||||||
$query = $searcher->getWordsAsString();
|
$words = $searcher->getWords();
|
||||||
|
$excludedWords = $searcher->getExcludedWords();
|
||||||
$operators = $searcher->getOperators();
|
$operators = $searcher->getOperators();
|
||||||
$invalidOperators = $searcher->getInvalidOperators();
|
$invalidOperators = $searcher->getInvalidOperators();
|
||||||
$subTitle = (string) trans('breadcrumbs.search_result', ['query' => $fullQuery]);
|
$subTitle = (string) trans('breadcrumbs.search_result', ['query' => $fullQuery]);
|
||||||
|
|
||||||
return view('search.index', compact('query', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators'));
|
return view('search.index', compact('words', 'excludedWords', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -23,7 +23,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace FireflyIII\Providers;
|
namespace FireflyIII\Providers;
|
||||||
|
|
||||||
|
use FireflyIII\Support\Search\QueryParser\GdbotsQueryParser;
|
||||||
use FireflyIII\Support\Search\OperatorQuerySearch;
|
use FireflyIII\Support\Search\OperatorQuerySearch;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParser;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||||
use FireflyIII\Support\Search\SearchInterface;
|
use FireflyIII\Support\Search\SearchInterface;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -43,6 +46,18 @@ class SearchServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->bind(
|
||||||
|
QueryParserInterface::class,
|
||||||
|
static function (): GdbotsQueryParser|QueryParser {
|
||||||
|
$implementation = config('search.query_parser');
|
||||||
|
|
||||||
|
return match($implementation) {
|
||||||
|
'new' => app(QueryParser::class),
|
||||||
|
default => app(GdbotsQueryParser::class),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
SearchInterface::class,
|
SearchInterface::class,
|
||||||
static function (Application $app) {
|
static function (Application $app) {
|
||||||
|
@@ -39,22 +39,14 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
|
|||||||
use FireflyIII\Repositories\Category\CategoryRepositoryInterface;
|
use FireflyIII\Repositories\Category\CategoryRepositoryInterface;
|
||||||
use FireflyIII\Repositories\Tag\TagRepositoryInterface;
|
use FireflyIII\Repositories\Tag\TagRepositoryInterface;
|
||||||
use FireflyIII\Repositories\UserGroups\Currency\CurrencyRepositoryInterface;
|
use FireflyIII\Repositories\UserGroups\Currency\CurrencyRepositoryInterface;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\Node;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\FieldNode;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\StringNode;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\NodeGroup;
|
||||||
|
|
||||||
use FireflyIII\Support\ParseDateString;
|
use FireflyIII\Support\ParseDateString;
|
||||||
use FireflyIII\User;
|
use FireflyIII\User;
|
||||||
use Gdbots\QueryParser\Enum\BoolOperator;
|
|
||||||
use Gdbots\QueryParser\Node\Date;
|
|
||||||
use Gdbots\QueryParser\Node\Emoji;
|
|
||||||
use Gdbots\QueryParser\Node\Emoticon;
|
|
||||||
use Gdbots\QueryParser\Node\Field;
|
|
||||||
use Gdbots\QueryParser\Node\Hashtag;
|
|
||||||
use Gdbots\QueryParser\Node\Mention;
|
|
||||||
use Gdbots\QueryParser\Node\Node;
|
|
||||||
use Gdbots\QueryParser\Node\Numbr;
|
|
||||||
use Gdbots\QueryParser\Node\Phrase;
|
|
||||||
use Gdbots\QueryParser\Node\Subquery;
|
|
||||||
use Gdbots\QueryParser\Node\Url;
|
|
||||||
use Gdbots\QueryParser\Node\Word;
|
|
||||||
use Gdbots\QueryParser\QueryParser;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
@@ -131,6 +123,16 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
return implode(' ', $this->words);
|
return implode(' ', $this->words);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getWords(): array
|
||||||
|
{
|
||||||
|
return $this->words;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExcludedWords(): array
|
||||||
|
{
|
||||||
|
return $this->prohibitedWords;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws FireflyException
|
* @throws FireflyException
|
||||||
*/
|
*/
|
||||||
@@ -145,10 +147,11 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
public function parseQuery(string $query): void
|
public function parseQuery(string $query): void
|
||||||
{
|
{
|
||||||
app('log')->debug(sprintf('Now in parseQuery(%s)', $query));
|
app('log')->debug(sprintf('Now in parseQuery(%s)', $query));
|
||||||
$parser = new QueryParser();
|
$parser = app(QueryParserInterface::class);
|
||||||
|
app('log')->debug(sprintf('Using %s as implementation for QueryParserInterface', get_class($parser)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$query1 = $parser->parse($query);
|
$parsedQuery = $parser->parse($query);
|
||||||
} catch (\LogicException|\TypeError $e) {
|
} catch (\LogicException|\TypeError $e) {
|
||||||
app('log')->error($e->getMessage());
|
app('log')->error($e->getMessage());
|
||||||
app('log')->error(sprintf('Could not parse search: "%s".', $query));
|
app('log')->error(sprintf('Could not parse search: "%s".', $query));
|
||||||
@@ -156,10 +159,8 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
throw new FireflyException(sprintf('Invalid search value "%s". See the logs.', e($query)), 0, $e);
|
throw new FireflyException(sprintf('Invalid search value "%s". See the logs.', e($query)), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
app('log')->debug(sprintf('Found %d node(s)', count($query1->getNodes())));
|
app('log')->debug(sprintf('Found %d node(s) at top-level', count($parsedQuery->getNodes())));
|
||||||
foreach ($query1->getNodes() as $searchNode) {
|
$this->handleSearchNode($parsedQuery, $parsedQuery->isProhibited(false));
|
||||||
$this->handleSearchNode($searchNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add missing information
|
// add missing information
|
||||||
$this->collector->withBillInformation();
|
$this->collector->withBillInformation();
|
||||||
@@ -173,48 +174,64 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
*
|
*
|
||||||
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
|
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
|
||||||
*/
|
*/
|
||||||
private function handleSearchNode(Node $searchNode): void
|
private function handleSearchNode(Node $node, $flipProhibitedFlag): void
|
||||||
{
|
{
|
||||||
$class = get_class($searchNode);
|
app('log')->debug(sprintf('Now in handleSearchNode(%s)', get_class($node)));
|
||||||
app('log')->debug(sprintf('Now in handleSearchNode(%s)', $class));
|
|
||||||
|
switch (true) {
|
||||||
|
case $node instanceof StringNode:
|
||||||
|
$this->handleStringNode($node, $flipProhibitedFlag);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $node instanceof FieldNode:
|
||||||
|
$this->handleFieldNode($node, $flipProhibitedFlag);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $node instanceof NodeGroup:
|
||||||
|
$this->handleNodeGroup($node, $flipProhibitedFlag);
|
||||||
|
break;
|
||||||
|
|
||||||
switch ($class) {
|
|
||||||
default:
|
default:
|
||||||
app('log')->error(sprintf('Cannot handle node %s', $class));
|
app('log')->error(sprintf('Cannot handle node %s', get_class($node)));
|
||||||
|
throw new FireflyException(sprintf('Firefly III search can\'t handle "%s"-nodes', get_class($node)));
|
||||||
throw new FireflyException(sprintf('Firefly III search can\'t handle "%s"-nodes', $class));
|
}
|
||||||
|
|
||||||
case Subquery::class:
|
|
||||||
// loop all notes in subquery:
|
|
||||||
foreach ($searchNode->getNodes() as $subNode) { // @phpstan-ignore-line PHPStan thinks getNodes() does not exist but it does.
|
|
||||||
$this->handleSearchNode($subNode); // let's hope it's not too recursive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
private function handleNodeGroup(NodeGroup $node, $flipProhibitedFlag): void
|
||||||
|
{
|
||||||
|
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||||
|
|
||||||
case Word::class:
|
foreach ($node->getNodes() as $subNode) {
|
||||||
case Phrase::class:
|
$this->handleSearchNode($subNode, $prohibited);
|
||||||
case Numbr::class:
|
}
|
||||||
case Url::class:
|
}
|
||||||
case Date::class:
|
|
||||||
case Hashtag::class:
|
|
||||||
case Emoticon::class:
|
|
||||||
case Emoji::class:
|
|
||||||
case Mention::class:
|
|
||||||
$allWords = (string) $searchNode->getValue();
|
|
||||||
app('log')->debug(sprintf('Add words "%s" to search string, because Node class is "%s"', $allWords, $class));
|
|
||||||
$this->words[] = $allWords;
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Field::class:
|
|
||||||
app('log')->debug(sprintf('Now handle Node class %s', $class));
|
|
||||||
|
|
||||||
/** @var Field $searchNode */
|
private function handleStringNode(StringNode $node, $flipProhibitedFlag): void
|
||||||
// used to search for x:y
|
{
|
||||||
$operator = strtolower($searchNode->getValue());
|
$string = (string) $node->getValue();
|
||||||
$value = $searchNode->getNode()->getValue();
|
|
||||||
$prohibited = BoolOperator::PROHIBITED === $searchNode->getBoolOperator();
|
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||||
|
|
||||||
|
if($prohibited) {
|
||||||
|
app('log')->debug(sprintf('Exclude string "%s" from search string', $string));
|
||||||
|
$this->prohibitedWords[] = $string;
|
||||||
|
} else {
|
||||||
|
app('log')->debug(sprintf('Add string "%s" to search string', $string));
|
||||||
|
$this->words[] = $string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws FireflyException
|
||||||
|
*/
|
||||||
|
private function handleFieldNode(FieldNode $node, $flipProhibitedFlag): void
|
||||||
|
{
|
||||||
|
$operator = strtolower($node->getOperator());
|
||||||
|
$value = $node->getValue();
|
||||||
|
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||||
|
|
||||||
$context = config(sprintf('search.operators.%s.needs_context', $operator));
|
$context = config(sprintf('search.operators.%s.needs_context', $operator));
|
||||||
|
|
||||||
// is an operator that needs no context, and value is false, then prohibited = true.
|
// is an operator that needs no context, and value is false, then prohibited = true.
|
||||||
@@ -229,19 +246,16 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// must be valid operator:
|
// must be valid operator:
|
||||||
if (
|
if (in_array($operator, $this->validOperators, true)) {
|
||||||
in_array($operator, $this->validOperators, true)
|
if ($this->updateCollector($operator, (string)$value, $prohibited)) {
|
||||||
&& $this->updateCollector($operator, (string) $value, $prohibited)) {
|
$this->operators->push([
|
||||||
$this->operators->push(
|
|
||||||
[
|
|
||||||
'type' => self::getRootOperator($operator),
|
'type' => self::getRootOperator($operator),
|
||||||
'value' => (string)$value,
|
'value' => (string)$value,
|
||||||
'prohibited' => $prohibited,
|
'prohibited' => $prohibited,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
app('log')->debug(sprintf('Added operator type "%s"', $operator));
|
app('log')->debug(sprintf('Added operator type "%s"', $operator));
|
||||||
}
|
}
|
||||||
if (!in_array($operator, $this->validOperators, true)) {
|
} else {
|
||||||
app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator));
|
app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator));
|
||||||
$this->invalidOperators[] = [
|
$this->invalidOperators[] = [
|
||||||
'type' => $operator,
|
'type' => $operator,
|
||||||
@@ -249,7 +263,6 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws FireflyException
|
* @throws FireflyException
|
||||||
@@ -2766,7 +2779,7 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
public function searchTransactions(): LengthAwarePaginator
|
public function searchTransactions(): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$this->parseTagInstructions();
|
$this->parseTagInstructions();
|
||||||
if (0 === count($this->getWords()) && 0 === count($this->getOperators())) {
|
if (0 === count($this->getWords()) && 0 === count($this->getExcludedWords()) && 0 === count($this->getOperators())) {
|
||||||
return new LengthAwarePaginator([], 0, 5, 1);
|
return new LengthAwarePaginator([], 0, 5, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2818,11 +2831,6 @@ class OperatorQuerySearch implements SearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getWords(): array
|
|
||||||
{
|
|
||||||
return $this->words;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDate(Carbon $date): void
|
public function setDate(Carbon $date): void
|
||||||
{
|
{
|
||||||
$this->date = $date;
|
$this->date = $date;
|
||||||
|
31
app/Support/Search/QueryParser/FieldNode.php
Normal file
31
app/Support/Search/QueryParser/FieldNode.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a field operator with value (e.g. amount:100)
|
||||||
|
*/
|
||||||
|
class FieldNode extends Node
|
||||||
|
{
|
||||||
|
private string $operator;
|
||||||
|
private string $value;
|
||||||
|
|
||||||
|
public function __construct(string $operator, string $value, bool $prohibited = false)
|
||||||
|
{
|
||||||
|
$this->operator = $operator;
|
||||||
|
$this->value = $value;
|
||||||
|
$this->prohibited = $prohibited;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperator(): string
|
||||||
|
{
|
||||||
|
return $this->operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
81
app/Support/Search/QueryParser/GdbotsQueryParser.php
Normal file
81
app/Support/Search/QueryParser/GdbotsQueryParser.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
use FireflyIII\Exceptions\FireflyException;
|
||||||
|
use Gdbots\QueryParser\QueryParser as BaseQueryParser;
|
||||||
|
use Gdbots\QueryParser\Node as GdbotsNode;
|
||||||
|
use Gdbots\QueryParser\Enum\BoolOperator;
|
||||||
|
|
||||||
|
class GdbotsQueryParser implements QueryParserInterface
|
||||||
|
{
|
||||||
|
private BaseQueryParser $parser;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->parser = new BaseQueryParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return NodeGroup
|
||||||
|
* @throws FireflyException
|
||||||
|
*/
|
||||||
|
public function parse(string $query): NodeGroup
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->parser->parse($query);
|
||||||
|
$nodes = array_map(
|
||||||
|
fn(GdbotsNode\Node $node) => $this->convertNode($node),
|
||||||
|
$result->getNodes()
|
||||||
|
);
|
||||||
|
return new NodeGroup($nodes);
|
||||||
|
} catch (\LogicException|\TypeError $e) {
|
||||||
|
fwrite(STDERR, "Setting up GdbotsQueryParserTest\n");
|
||||||
|
dd('Creating GdbotsQueryParser');
|
||||||
|
app('log')->error($e->getMessage());
|
||||||
|
app('log')->error(sprintf('Could not parse search: "%s".', $query));
|
||||||
|
|
||||||
|
throw new FireflyException(sprintf('Invalid search value "%s". See the logs.', e($query)), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertNode(GdbotsNode\Node $node): Node
|
||||||
|
{
|
||||||
|
switch (true) {
|
||||||
|
case $node instanceof GdbotsNode\Word:
|
||||||
|
return new StringNode($node->getValue());
|
||||||
|
|
||||||
|
case $node instanceof GdbotsNode\Field:
|
||||||
|
return new FieldNode(
|
||||||
|
$node->getValue(),
|
||||||
|
(string) $node->getNode()->getValue(),
|
||||||
|
BoolOperator::PROHIBITED === $node->getBoolOperator()
|
||||||
|
);
|
||||||
|
|
||||||
|
case $node instanceof GdbotsNode\Subquery:
|
||||||
|
return new NodeGroup(
|
||||||
|
array_map(
|
||||||
|
fn(GdbotsNode\Node $subNode) => $this->convertNode($subNode),
|
||||||
|
$node->getNodes()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
case $node instanceof GdbotsNode\Phrase:
|
||||||
|
case $node instanceof GdbotsNode\Numbr:
|
||||||
|
case $node instanceof GdbotsNode\Date:
|
||||||
|
case $node instanceof GdbotsNode\Url:
|
||||||
|
case $node instanceof GdbotsNode\Hashtag:
|
||||||
|
case $node instanceof GdbotsNode\Mention:
|
||||||
|
case $node instanceof GdbotsNode\Emoticon:
|
||||||
|
case $node instanceof GdbotsNode\Emoji:
|
||||||
|
return new StringNode((string) $node->getValue());
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new FireflyException(
|
||||||
|
sprintf('Unsupported node type: %s', get_class($node))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
app/Support/Search/QueryParser/Node.php
Normal file
31
app/Support/Search/QueryParser/Node.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all nodes
|
||||||
|
*/
|
||||||
|
abstract class Node
|
||||||
|
{
|
||||||
|
protected bool $prohibited;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the prohibited status of the node, optionally inverted based on flipFlag
|
||||||
|
*
|
||||||
|
* Flipping is used when a node is inside a NodeGroup that has a prohibited status itself, causing inversion of the query parts inside
|
||||||
|
*
|
||||||
|
* @param bool $flipFlag When true, inverts the prohibited status
|
||||||
|
* @return bool The (potentially inverted) prohibited status
|
||||||
|
*/
|
||||||
|
public function isProhibited(bool $flipFlag): bool
|
||||||
|
{
|
||||||
|
if ($flipFlag === true) {
|
||||||
|
return !$this->prohibited;
|
||||||
|
} else {
|
||||||
|
return $this->prohibited;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
34
app/Support/Search/QueryParser/NodeGroup.php
Normal file
34
app/Support/Search/QueryParser/NodeGroup.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a group of nodes.
|
||||||
|
*
|
||||||
|
* NodeGroups can be nested inside other NodeGroups, making them subqueries
|
||||||
|
*/
|
||||||
|
class NodeGroup extends Node
|
||||||
|
{
|
||||||
|
/** @var Node[] */
|
||||||
|
private array $nodes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Node[] $nodes
|
||||||
|
* @param bool $prohibited
|
||||||
|
*/
|
||||||
|
public function __construct(array $nodes, bool $prohibited = false)
|
||||||
|
{
|
||||||
|
$this->nodes = $nodes;
|
||||||
|
$this->prohibited = $prohibited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Node[]
|
||||||
|
*/
|
||||||
|
public function getNodes(): array
|
||||||
|
{
|
||||||
|
return $this->nodes;
|
||||||
|
}
|
||||||
|
}
|
177
app/Support/Search/QueryParser/QueryParser.php
Normal file
177
app/Support/Search/QueryParser/QueryParser.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a result from parsing a query node
|
||||||
|
*
|
||||||
|
* Contains the parsed node and a flag indicating if this is the end of the query.
|
||||||
|
* Used to handle subquery parsing and termination.
|
||||||
|
*/
|
||||||
|
class NodeResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?Node $node,
|
||||||
|
public readonly bool $isSubqueryEnd
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-pass parser that processes query strings into structured nodes.
|
||||||
|
* Scans each character once (O(n)) to build field searches, quoted strings,
|
||||||
|
* prohibited terms and nested subqueries without backtracking.
|
||||||
|
*/
|
||||||
|
class QueryParser implements QueryParserInterface
|
||||||
|
{
|
||||||
|
private string $query;
|
||||||
|
private int $position = 0;
|
||||||
|
|
||||||
|
/** @return NodeGroup */
|
||||||
|
public function parse(string $query): NodeGroup
|
||||||
|
{
|
||||||
|
$this->query = $query;
|
||||||
|
$this->position = 0;
|
||||||
|
return $this->buildNodeGroup(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return NodeGroup */
|
||||||
|
private function buildNodeGroup(bool $isSubquery, bool $prohibited = false): NodeGroup
|
||||||
|
{
|
||||||
|
$nodes = [];
|
||||||
|
$nodeResult = $this->buildNextNode($isSubquery);
|
||||||
|
|
||||||
|
while ($nodeResult->node !== null) {
|
||||||
|
$nodes[] = $nodeResult->node;
|
||||||
|
if($nodeResult->isSubqueryEnd) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$nodeResult = $this->buildNextNode($isSubquery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NodeGroup($nodes, $prohibited);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildNextNode(bool $isSubquery): NodeResult
|
||||||
|
{
|
||||||
|
$tokenUnderConstruction = '';
|
||||||
|
$inQuotes = false;
|
||||||
|
$fieldName = '';
|
||||||
|
$prohibited = false;
|
||||||
|
|
||||||
|
while ($this->position < strlen($this->query)) {
|
||||||
|
$char = $this->query[$this->position];
|
||||||
|
|
||||||
|
// If we're in a quoted string, we treat all characters except another quote as ordinary characters
|
||||||
|
if ($inQuotes) {
|
||||||
|
if ($char !== '"') {
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
$this->position++;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
$this->position++;
|
||||||
|
return new NodeResult(
|
||||||
|
$this->createNode($tokenUnderConstruction, $fieldName, $prohibited),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($char) {
|
||||||
|
case '-':
|
||||||
|
if ($tokenUnderConstruction === '') {
|
||||||
|
// A minus sign at the beginning of a token indicates prohibition
|
||||||
|
$prohibited = true;
|
||||||
|
} else {
|
||||||
|
// In any other location, it's just a normal character
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '"':
|
||||||
|
if ($tokenUnderConstruction === '') {
|
||||||
|
// A quote sign at the beginning of a token indicates the start of a quoted string
|
||||||
|
$inQuotes = true;
|
||||||
|
} else {
|
||||||
|
// In any other location, it's just a normal character
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '(':
|
||||||
|
if ($tokenUnderConstruction === '') {
|
||||||
|
// A left parentheses at the beginning of a token indicates the start of a subquery
|
||||||
|
$this->position++;
|
||||||
|
return new NodeResult($this->buildNodeGroup(true, $prohibited),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// In any other location, it's just a normal character
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ')':
|
||||||
|
// A right parentheses while in a subquery means the subquery ended,
|
||||||
|
// thus also signaling the end of any node currently being built
|
||||||
|
if ($isSubquery) {
|
||||||
|
$this->position++;
|
||||||
|
return new NodeResult(
|
||||||
|
$tokenUnderConstruction !== ''
|
||||||
|
? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
|
||||||
|
: null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// In any other location, it's just a normal character
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
case ':':
|
||||||
|
if ($tokenUnderConstruction !== '') {
|
||||||
|
// If we meet a colon with a left-hand side string, we know we're in a field and are about to set up the value
|
||||||
|
$fieldName = $tokenUnderConstruction;
|
||||||
|
$tokenUnderConstruction = '';
|
||||||
|
} else {
|
||||||
|
// In any other location, it's just a normal character
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ' ':
|
||||||
|
// A space indicates the end of a token construction if non-empty, otherwise it's just ignored
|
||||||
|
if ($tokenUnderConstruction !== '') {
|
||||||
|
$this->position++;
|
||||||
|
return new NodeResult(
|
||||||
|
$this->createNode($tokenUnderConstruction, $fieldName, $prohibited),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$tokenUnderConstruction .= $char;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalNode = $tokenUnderConstruction !== '' || $fieldName !== ''
|
||||||
|
? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new NodeResult($finalNode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNode(string $token, string $fieldName, bool $prohibited): Node
|
||||||
|
{
|
||||||
|
if (strlen($fieldName) > 0) {
|
||||||
|
return new FieldNode(trim($fieldName), trim($token), $prohibited);
|
||||||
|
}
|
||||||
|
return new StringNode(trim($token), $prohibited);
|
||||||
|
}
|
||||||
|
}
|
13
app/Support/Search/QueryParser/QueryParserInterface.php
Normal file
13
app/Support/Search/QueryParser/QueryParserInterface.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
interface QueryParserInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return NodeGroup
|
||||||
|
*/
|
||||||
|
public function parse(string $query): NodeGroup;
|
||||||
|
}
|
24
app/Support/Search/QueryParser/StringNode.php
Normal file
24
app/Support/Search/QueryParser/StringNode.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a string in the search query, meaning either a single-word without spaces or a quote-delimited string
|
||||||
|
*/
|
||||||
|
class StringNode extends Node
|
||||||
|
{
|
||||||
|
private string $value;
|
||||||
|
|
||||||
|
public function __construct(string $value, bool $prohibited = false)
|
||||||
|
{
|
||||||
|
$this->value = $value;
|
||||||
|
$this->prohibited = $prohibited;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
@@ -38,8 +38,10 @@ interface SearchInterface
|
|||||||
public function getModifiers(): Collection;
|
public function getModifiers(): Collection;
|
||||||
|
|
||||||
public function getOperators(): Collection;
|
public function getOperators(): Collection;
|
||||||
|
public function getWords(): array;
|
||||||
|
|
||||||
public function getWordsAsString(): string;
|
public function getWordsAsString(): string;
|
||||||
|
public function getExcludedWords(): array;
|
||||||
|
|
||||||
public function hasModifiers(): bool;
|
public function hasModifiers(): bool;
|
||||||
|
|
||||||
|
@@ -253,4 +253,8 @@ return [
|
|||||||
'destination_balance_lt' => ['alias' => false, 'needs_context' => true],
|
'destination_balance_lt' => ['alias' => false, 'needs_context' => true],
|
||||||
'destination_balance_is' => ['alias' => false, 'needs_context' => true],
|
'destination_balance_is' => ['alias' => false, 'needs_context' => true],
|
||||||
],
|
],
|
||||||
|
/**
|
||||||
|
* Which query parser to use - 'new' or 'legacy'
|
||||||
|
*/
|
||||||
|
'query_parser' => env('QUERY_PARSER_IMPLEMENTATION', 'legacy'),
|
||||||
];
|
];
|
||||||
|
@@ -258,6 +258,11 @@ span.twitter-typeahead {
|
|||||||
top: 46px !important;
|
top: 46px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-word {
|
||||||
|
white-space: pre;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
.twitter-typeahead {
|
.twitter-typeahead {
|
||||||
width:100%;
|
width:100%;
|
||||||
|
@@ -329,7 +329,9 @@ return [
|
|||||||
'search_query' => 'Query',
|
'search_query' => 'Query',
|
||||||
'search_found_transactions' => 'Firefly III found :count transaction in :time seconds.|Firefly III found :count transactions in :time seconds.',
|
'search_found_transactions' => 'Firefly III found :count transaction in :time seconds.|Firefly III found :count transactions in :time seconds.',
|
||||||
'search_found_more_transactions' => 'Firefly III found more than :count transactions in :time seconds.',
|
'search_found_more_transactions' => 'Firefly III found more than :count transactions in :time seconds.',
|
||||||
'search_for_query' => 'Firefly III is searching for transactions with all of these words in them: <span class="text-info">:query</span>',
|
'search_for_overview' => 'Firefly III is searching for transactions that fulfill <b>all</b> of the following conditions:',
|
||||||
|
'search_for_query' => 'All of these words must be present: <span class="text-info">:query</span>',
|
||||||
|
'search_for_excluded_words' => 'None of these words may be present: <span class="text-info">:excluded_words</span>',
|
||||||
'invalid_operators_list' => 'These search parameters are not valid and have been ignored.',
|
'invalid_operators_list' => 'These search parameters are not valid and have been ignored.',
|
||||||
|
|
||||||
// old
|
// old
|
||||||
@@ -729,7 +731,6 @@ return [
|
|||||||
// Ignore this comment
|
// Ignore this comment
|
||||||
|
|
||||||
// END
|
// END
|
||||||
'modifiers_applies_are' => 'The following modifiers are applied to the search as well:',
|
|
||||||
'general_search_error' => 'An error occurred while searching. Please check the log files for more information.',
|
'general_search_error' => 'An error occurred while searching. Please check the log files for more information.',
|
||||||
'search_box' => 'Search',
|
'search_box' => 'Search',
|
||||||
'search_box_intro' => 'Welcome to the search function of Firefly III. Enter your search query in the box. Make sure you check out the help file because the search is pretty advanced.',
|
'search_box_intro' => 'Welcome to the search function of Firefly III. Enter your search query in the box. Make sure you check out the help file because the search is pretty advanced.',
|
||||||
|
@@ -38,11 +38,38 @@
|
|||||||
<input type="hidden" name="rule" value="{{ ruleId }}"/>
|
<input type="hidden" name="rule" value="{{ ruleId }}"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% if '' != query %}
|
|
||||||
<p>
|
<p>
|
||||||
{{ trans('firefly.search_for_query', {query: query|escape})|raw }}
|
{{ trans('firefly.search_for_overview') |raw }}
|
||||||
</p>
|
</p>
|
||||||
|
<ul>
|
||||||
|
{% if words|length > 0 %}
|
||||||
|
<li>
|
||||||
|
{{- trans('firefly.search_for_query', {
|
||||||
|
query: words
|
||||||
|
|map(word => '<span class="search-word">' ~ word|escape ~ '</span>')
|
||||||
|
|join(' ')
|
||||||
|
})|raw -}}
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if excludedWords|length > 0 %}
|
||||||
|
<li>
|
||||||
|
{{- trans('firefly.search_for_excluded_words', {
|
||||||
|
excluded_words: excludedWords
|
||||||
|
|map(word => '<span class="search-word">' ~ word|escape ~ '</span>')
|
||||||
|
|join(' ')
|
||||||
|
})|raw -}}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for operator in operators %}
|
||||||
|
{% if operator.prohibited %}
|
||||||
|
<li>{{- trans('firefly.search_modifier_not_'~operator.type, {value: operator.value}) -}}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if not operator.prohibited %}
|
||||||
|
<li>{{- trans('firefly.search_modifier_'~operator.type, {value: operator.value}) -}}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
{% if invalidOperators|length > 0 %}
|
{% if invalidOperators|length > 0 %}
|
||||||
<p>{{ trans('firefly.invalid_operators_list') }}</p>
|
<p>{{ trans('firefly.invalid_operators_list') }}</p>
|
||||||
@@ -52,25 +79,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if operators|length > 0 %}
|
|
||||||
<p>{{ trans('firefly.modifiers_applies_are') }}</p>
|
|
||||||
<ul>
|
|
||||||
{% for operator in operators %}
|
|
||||||
{% if operator.prohibited %}
|
|
||||||
<li>{{ trans('firefly.search_modifier_not_'~operator.type, {value: operator.value}) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if not operator.prohibited %}
|
|
||||||
<li>{{ trans('firefly.search_modifier_'~operator.type, {value: operator.value}) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if query or operators|length > 0 %}
|
{% if query|length > 0 or excludedWords|length > 0 or operators|length > 0 %}
|
||||||
<div class="row result_row">
|
<div class="row result_row">
|
||||||
<div class="col-lg-12 col-md-12 col-sm-12">
|
<div class="col-lg-12 col-md-12 col-sm-12">
|
||||||
<div class="box search_box">
|
<div class="box search_box">
|
||||||
@@ -125,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if query == "" and operators|length == 0 %}
|
{% if query|length == 0 and excludedWords|length == 0 and operators|length == 0 %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12 col-md-12 col-sm-12">
|
<div class="col-lg-12 col-md-12 col-sm-12">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\unit\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
use FireflyIII\Support\Search\QueryParser\FieldNode;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\StringNode;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\NodeGroup;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\Node;
|
||||||
|
use Tests\integration\TestCase;
|
||||||
|
|
||||||
|
abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
|
||||||
|
{
|
||||||
|
abstract protected function createParser(): QueryParserInterface;
|
||||||
|
|
||||||
|
public function queryDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty query' => [
|
||||||
|
'query' => '',
|
||||||
|
'expected' => new NodeGroup([])
|
||||||
|
],
|
||||||
|
'simple word' => [
|
||||||
|
'query' => 'groceries',
|
||||||
|
'expected' => new NodeGroup([new StringNode('groceries')])
|
||||||
|
],
|
||||||
|
'prohibited word' => [
|
||||||
|
'query' => '-groceries',
|
||||||
|
'expected' => new NodeGroup([new StringNode('groceries', true)])
|
||||||
|
],
|
||||||
|
'prohibited field' => [
|
||||||
|
'query' => '-amount:100',
|
||||||
|
'expected' => new NodeGroup([new FieldNode('amount', '100', true)])
|
||||||
|
],
|
||||||
|
'quoted word' => [
|
||||||
|
'query' => '"test phrase"',
|
||||||
|
'expected' => new NodeGroup([new StringNode('test phrase')])
|
||||||
|
],
|
||||||
|
'prohibited quoted word' => [
|
||||||
|
'query' => '-"test phrase"',
|
||||||
|
'expected' => new NodeGroup([new StringNode('test phrase', true)])
|
||||||
|
],
|
||||||
|
'multiple words' => [
|
||||||
|
'query' => 'groceries shopping market',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new StringNode('groceries'),
|
||||||
|
new StringNode('shopping'),
|
||||||
|
new StringNode('market')
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'field operator' => [
|
||||||
|
'query' => 'amount:100',
|
||||||
|
'expected' => new NodeGroup([new FieldNode('amount', '100')])
|
||||||
|
],
|
||||||
|
'quoted field value with single space' => [
|
||||||
|
'query' => 'description:"test phrase"',
|
||||||
|
'expected' => new NodeGroup([new FieldNode('description', 'test phrase')])
|
||||||
|
],
|
||||||
|
'multiple fields' => [
|
||||||
|
'query' => 'amount:100 category:food',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'simple subquery' => [
|
||||||
|
'query' => '(amount:100 category:food)',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'prohibited subquery' => [
|
||||||
|
'query' => '-(amount:100 category:food)',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
], true)
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'nested subquery' => [
|
||||||
|
'query' => '(amount:100 (description:"test" category:food))',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('description', 'test'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'mixed words and operators' => [
|
||||||
|
'query' => 'groceries amount:50 shopping',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new StringNode('groceries'),
|
||||||
|
new FieldNode('amount', '50'),
|
||||||
|
new StringNode('shopping')
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'subquery after field value' => [
|
||||||
|
'query' => 'amount:100 (description:"market" category:food)',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('description', 'market'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'word followed by subquery' => [
|
||||||
|
'query' => 'groceries (amount:100 description_contains:"test")',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new StringNode('groceries'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new FieldNode('description_contains', 'test')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'nested subquery with prohibited field' => [
|
||||||
|
'query' => '(amount:100 (description_contains:"test payment" -has_attachments:true))',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('description_contains', 'test payment'),
|
||||||
|
new FieldNode('has_attachments', 'true', true)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'complex nested subqueries' => [
|
||||||
|
'query' => 'shopping (amount:50 market (-category:food word description:"test phrase" (has_notes:true)))',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new StringNode('shopping'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '50'),
|
||||||
|
new StringNode('market'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('category', 'food', true),
|
||||||
|
new StringNode('word'),
|
||||||
|
new FieldNode('description', 'test phrase'),
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('has_notes', 'true')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'word with multiple spaces' => [
|
||||||
|
'query' => '"multiple spaces"',
|
||||||
|
'expected' => new NodeGroup([new StringNode('multiple spaces')])
|
||||||
|
],
|
||||||
|
'field with multiple spaces in value' => [
|
||||||
|
'query' => 'description:"multiple spaces here"',
|
||||||
|
'expected' => new NodeGroup([new FieldNode('description', 'multiple spaces here')])
|
||||||
|
],
|
||||||
|
'unmatched right parenthesis in word' => [
|
||||||
|
'query' => 'test)word',
|
||||||
|
'expected' => new NodeGroup([new StringNode('test)word')])
|
||||||
|
],
|
||||||
|
'unmatched right parenthesis in field' => [
|
||||||
|
'query' => 'description:test)phrase',
|
||||||
|
'expected' => new NodeGroup([new FieldNode('description', 'test)phrase')])
|
||||||
|
],
|
||||||
|
'subquery followed by word' => [
|
||||||
|
'query' => '(amount:100 category:food) shopping',
|
||||||
|
'expected' => new NodeGroup([
|
||||||
|
new NodeGroup([
|
||||||
|
new FieldNode('amount', '100'),
|
||||||
|
new FieldNode('category', 'food')
|
||||||
|
]),
|
||||||
|
new StringNode('shopping')
|
||||||
|
])
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider queryDataProvider
|
||||||
|
* @param string $query The query string to parse
|
||||||
|
* @param Node $expected The expected parse result
|
||||||
|
*/
|
||||||
|
public function testQueryParsing(string $query, Node $expected): void
|
||||||
|
{
|
||||||
|
$actual = $this->createParser()->parse($query);
|
||||||
|
|
||||||
|
$this->assertEquals($expected, $actual);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\unit\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
use FireflyIII\Support\Search\QueryParser\GdbotsQueryParser;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @group unit-test
|
||||||
|
* @group support
|
||||||
|
* @group search
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
final class GdbotsQueryParserParseQueryTest extends AbstractQueryParserInterfaceParseQueryTest
|
||||||
|
{
|
||||||
|
protected function createParser(): QueryParserInterface
|
||||||
|
{
|
||||||
|
return new GdbotsQueryParser();
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\unit\Support\Search\QueryParser;
|
||||||
|
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParser;
|
||||||
|
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @group unit-test
|
||||||
|
* @group support
|
||||||
|
* @group search
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
final class QueryParserParseQueryTest extends AbstractQueryParserInterfaceParseQueryTest
|
||||||
|
{
|
||||||
|
protected function createParser(): QueryParserInterface
|
||||||
|
{
|
||||||
|
return new QueryParser();
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user