mirror of
https://github.com/firefly-iii/firefly-iii.git
synced 2025-02-25 18:45:27 -06: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
|
||||
|
||||
#
|
||||
# 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.
|
||||
# It is used to validate specific requests and to generate URLs in emails.
|
||||
|
@@ -89,16 +89,28 @@ class CreateController extends Controller
|
||||
// build triggers from query, if present.
|
||||
$query = (string) $request->get('from_query');
|
||||
if ('' !== $query) {
|
||||
$search = app(SearchInterface::class);
|
||||
$search = app(SearchInterface::class);
|
||||
$search->parseQuery($query);
|
||||
$words = $search->getWordsAsString();
|
||||
$operators = $search->getOperators()->toArray();
|
||||
if ('' !== $words) {
|
||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words]));
|
||||
$operators[] = [
|
||||
'type' => 'description_contains',
|
||||
'value' => $words,
|
||||
];
|
||||
$words = $search->getWords();
|
||||
$excludedWords = $search->getExcludedWords();
|
||||
$operators = $search->getOperators()->toArray();
|
||||
if (count($words) > 0) {
|
||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $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);
|
||||
}
|
||||
|
@@ -87,11 +87,26 @@ class EditController extends Controller
|
||||
if ('' !== $query) {
|
||||
$search = app(SearchInterface::class);
|
||||
$search->parseQuery($query);
|
||||
$words = $search->getWordsAsString();
|
||||
$words = $search->getWords();
|
||||
$excludedWords = $search->getExcludedWords();
|
||||
$operators = $search->getOperators()->toArray();
|
||||
if ('' !== $words) {
|
||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words]));
|
||||
$operators[] = ['type' => 'description_contains', 'value' => $words];
|
||||
if (count($words) > 0) {
|
||||
session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $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);
|
||||
}
|
||||
|
@@ -83,12 +83,13 @@ class SearchController extends Controller
|
||||
$searcher->parseQuery($fullQuery);
|
||||
|
||||
// words from query and operators:
|
||||
$query = $searcher->getWordsAsString();
|
||||
$words = $searcher->getWords();
|
||||
$excludedWords = $searcher->getExcludedWords();
|
||||
$operators = $searcher->getOperators();
|
||||
$invalidOperators = $searcher->getInvalidOperators();
|
||||
$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;
|
||||
|
||||
use FireflyIII\Support\Search\QueryParser\GdbotsQueryParser;
|
||||
use FireflyIII\Support\Search\OperatorQuerySearch;
|
||||
use FireflyIII\Support\Search\QueryParser\QueryParser;
|
||||
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
|
||||
use FireflyIII\Support\Search\SearchInterface;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -43,6 +46,18 @@ class SearchServiceProvider extends ServiceProvider
|
||||
*/
|
||||
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(
|
||||
SearchInterface::class,
|
||||
static function (Application $app) {
|
||||
|
@@ -39,22 +39,14 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
|
||||
use FireflyIII\Repositories\Category\CategoryRepositoryInterface;
|
||||
use FireflyIII\Repositories\Tag\TagRepositoryInterface;
|
||||
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\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\Support\Collection;
|
||||
|
||||
@@ -131,6 +123,16 @@ class OperatorQuerySearch implements SearchInterface
|
||||
return implode(' ', $this->words);
|
||||
}
|
||||
|
||||
public function getWords(): array
|
||||
{
|
||||
return $this->words;
|
||||
}
|
||||
|
||||
public function getExcludedWords(): array
|
||||
{
|
||||
return $this->prohibitedWords;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FireflyException
|
||||
*/
|
||||
@@ -145,10 +147,11 @@ class OperatorQuerySearch implements SearchInterface
|
||||
public function parseQuery(string $query): void
|
||||
{
|
||||
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 {
|
||||
$query1 = $parser->parse($query);
|
||||
$parsedQuery = $parser->parse($query);
|
||||
} catch (\LogicException|\TypeError $e) {
|
||||
app('log')->error($e->getMessage());
|
||||
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);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('Found %d node(s)', count($query1->getNodes())));
|
||||
foreach ($query1->getNodes() as $searchNode) {
|
||||
$this->handleSearchNode($searchNode);
|
||||
}
|
||||
app('log')->debug(sprintf('Found %d node(s) at top-level', count($parsedQuery->getNodes())));
|
||||
$this->handleSearchNode($parsedQuery, $parsedQuery->isProhibited(false));
|
||||
|
||||
// add missing information
|
||||
$this->collector->withBillInformation();
|
||||
@@ -173,81 +174,93 @@ class OperatorQuerySearch implements SearchInterface
|
||||
*
|
||||
* @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)', $class));
|
||||
app('log')->debug(sprintf('Now in handleSearchNode(%s)', get_class($node)));
|
||||
|
||||
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:
|
||||
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));
|
||||
private function handleNodeGroup(NodeGroup $node, $flipProhibitedFlag): void
|
||||
{
|
||||
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||
|
||||
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
|
||||
}
|
||||
foreach ($node->getNodes() as $subNode) {
|
||||
$this->handleSearchNode($subNode, $prohibited);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case Word::class:
|
||||
case Phrase::class:
|
||||
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;
|
||||
private function handleStringNode(StringNode $node, $flipProhibitedFlag): void
|
||||
{
|
||||
$string = (string) $node->getValue();
|
||||
|
||||
case Field::class:
|
||||
app('log')->debug(sprintf('Now handle Node class %s', $class));
|
||||
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||
|
||||
/** @var Field $searchNode */
|
||||
// used to search for x:y
|
||||
$operator = strtolower($searchNode->getValue());
|
||||
$value = $searchNode->getNode()->getValue();
|
||||
$prohibited = BoolOperator::PROHIBITED === $searchNode->getBoolOperator();
|
||||
$context = config(sprintf('search.operators.%s.needs_context', $operator));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// is an operator that needs no context, and value is false, then prohibited = true.
|
||||
if ('false' === $value && in_array($operator, $this->validOperators, true) && false === $context && !$prohibited) {
|
||||
$prohibited = true;
|
||||
$value = 'true';
|
||||
}
|
||||
// if the operator is prohibited, but the value is false, do an uno reverse
|
||||
if ('false' === $value && $prohibited && in_array($operator, $this->validOperators, true) && false === $context) {
|
||||
$prohibited = false;
|
||||
$value = 'true';
|
||||
}
|
||||
/**
|
||||
* @throws FireflyException
|
||||
*/
|
||||
private function handleFieldNode(FieldNode $node, $flipProhibitedFlag): void
|
||||
{
|
||||
$operator = strtolower($node->getOperator());
|
||||
$value = $node->getValue();
|
||||
$prohibited = $node->isProhibited($flipProhibitedFlag);
|
||||
|
||||
// must be valid operator:
|
||||
if (
|
||||
in_array($operator, $this->validOperators, true)
|
||||
&& $this->updateCollector($operator, (string) $value, $prohibited)) {
|
||||
$this->operators->push(
|
||||
[
|
||||
'type' => self::getRootOperator($operator),
|
||||
'value' => (string) $value,
|
||||
'prohibited' => $prohibited,
|
||||
]
|
||||
);
|
||||
app('log')->debug(sprintf('Added operator type "%s"', $operator));
|
||||
}
|
||||
if (!in_array($operator, $this->validOperators, true)) {
|
||||
app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator));
|
||||
$this->invalidOperators[] = [
|
||||
'type' => $operator,
|
||||
'value' => (string) $value,
|
||||
];
|
||||
}
|
||||
$context = config(sprintf('search.operators.%s.needs_context', $operator));
|
||||
|
||||
// is an operator that needs no context, and value is false, then prohibited = true.
|
||||
if ('false' === $value && in_array($operator, $this->validOperators, true) && false === $context && !$prohibited) {
|
||||
$prohibited = true;
|
||||
$value = 'true';
|
||||
}
|
||||
// if the operator is prohibited, but the value is false, do an uno reverse
|
||||
if ('false' === $value && $prohibited && in_array($operator, $this->validOperators, true) && false === $context) {
|
||||
$prohibited = false;
|
||||
$value = 'true';
|
||||
}
|
||||
|
||||
// must be valid operator:
|
||||
if (in_array($operator, $this->validOperators, true)) {
|
||||
if ($this->updateCollector($operator, (string)$value, $prohibited)) {
|
||||
$this->operators->push([
|
||||
'type' => self::getRootOperator($operator),
|
||||
'value' => (string)$value,
|
||||
'prohibited' => $prohibited,
|
||||
]);
|
||||
app('log')->debug(sprintf('Added operator type "%s"', $operator));
|
||||
}
|
||||
} else {
|
||||
app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator));
|
||||
$this->invalidOperators[] = [
|
||||
'type' => $operator,
|
||||
'value' => (string)$value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2766,7 +2779,7 @@ class OperatorQuerySearch implements SearchInterface
|
||||
public function searchTransactions(): LengthAwarePaginator
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -2818,11 +2831,6 @@ class OperatorQuerySearch implements SearchInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function getWords(): array
|
||||
{
|
||||
return $this->words;
|
||||
}
|
||||
|
||||
public function setDate(Carbon $date): void
|
||||
{
|
||||
$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 getOperators(): Collection;
|
||||
public function getWords(): array;
|
||||
|
||||
public function getWordsAsString(): string;
|
||||
public function getExcludedWords(): array;
|
||||
|
||||
public function hasModifiers(): bool;
|
||||
|
||||
|
@@ -253,4 +253,8 @@ return [
|
||||
'destination_balance_lt' => ['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;
|
||||
}
|
||||
|
||||
.search-word {
|
||||
white-space: pre;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/*
|
||||
.twitter-typeahead {
|
||||
width:100%;
|
||||
|
@@ -329,7 +329,9 @@ return [
|
||||
'search_query' => 'Query',
|
||||
'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_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.',
|
||||
|
||||
// old
|
||||
@@ -729,7 +731,6 @@ return [
|
||||
// Ignore this comment
|
||||
|
||||
// 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.',
|
||||
'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.',
|
||||
|
@@ -38,11 +38,38 @@
|
||||
<input type="hidden" name="rule" value="{{ ruleId }}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if '' != query %}
|
||||
<p>
|
||||
{{ trans('firefly.search_for_query', {query: query|escape})|raw }}
|
||||
</p>
|
||||
<p>
|
||||
{{ trans('firefly.search_for_overview') |raw }}
|
||||
</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 %}
|
||||
{% 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 %}
|
||||
<p>{{ trans('firefly.invalid_operators_list') }}</p>
|
||||
@@ -52,25 +79,11 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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>
|
||||
{% 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="col-lg-12 col-md-12 col-sm-12">
|
||||
<div class="box search_box">
|
||||
@@ -125,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if query == "" and operators|length == 0 %}
|
||||
{% if query|length == 0 and excludedWords|length == 0 and operators|length == 0 %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-sm-12">
|
||||
<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