Merge pull request #9598 from Sobuno/NewQueryParserV3

New Query Parser for Search Engine and Rules
This commit is contained in:
James Cole
2025-01-05 07:32:55 +01:00
committed by GitHub
21 changed files with 843 additions and 126 deletions

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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'));
}
/**

View File

@@ -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) {

View File

@@ -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;

View 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;
}
}

View 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))
);
}
}
}

View 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;
}
}
}

View 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;
}
}

View 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);
}
}

View 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;
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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'),
];

View File

@@ -258,6 +258,11 @@ span.twitter-typeahead {
top: 46px !important;
}
.search-word {
white-space: pre;
background-color: #f5f5f5;
}
/*
.twitter-typeahead {
width:100%;

View File

@@ -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.',

View File

@@ -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">

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}