From 84560a6f44746b89bbb4ae3e9791497d0ab85564 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Tue, 31 Dec 2024 10:16:27 +0100
Subject: [PATCH 01/27] WIP
---
app/Providers/SearchServiceProvider.php | 11 +
app/Support/Search/GdbotsQueryParser.php | 80 +++++++
app/Support/Search/OperatorQuerySearch.php | 149 ++++++------
app/Support/Search/QueryParser.php | 171 ++++++++++++++
app/Support/Search/QueryParserInterface.php | 112 +++++++++
...ractQueryParserInterfaceParseQueryTest.php | 213 ++++++++++++++++++
.../GdbotsQueryParserParseQueryTest.php | 24 ++
.../Search/QueryParserParseQueryTest.php | 25 ++
8 files changed, 699 insertions(+), 86 deletions(-)
create mode 100644 app/Support/Search/GdbotsQueryParser.php
create mode 100644 app/Support/Search/QueryParser.php
create mode 100644 app/Support/Search/QueryParserInterface.php
create mode 100644 tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
create mode 100644 tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
create mode 100644 tests/unit/Support/Search/QueryParserParseQueryTest.php
diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php
index 0bb2b1056b..627d9f0820 100644
--- a/app/Providers/SearchServiceProvider.php
+++ b/app/Providers/SearchServiceProvider.php
@@ -24,6 +24,8 @@ declare(strict_types=1);
namespace FireflyIII\Providers;
use FireflyIII\Support\Search\OperatorQuerySearch;
+use FireflyIII\Support\Search\QueryParser;
+use FireflyIII\Support\Search\QueryParserInterface;
use FireflyIII\Support\Search\SearchInterface;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
@@ -43,6 +45,15 @@ class SearchServiceProvider extends ServiceProvider
*/
public function register(): void
{
+ $this->app->bind(
+ QueryParserInterface::class,
+ static function (Application $app) {
+ /** @var QueryParser $queryParser */
+ $queryParser = app(QueryParser::class);
+ return $queryParser;
+ }
+ );
+
$this->app->bind(
SearchInterface::class,
static function (Application $app) {
diff --git a/app/Support/Search/GdbotsQueryParser.php b/app/Support/Search/GdbotsQueryParser.php
new file mode 100644
index 0000000000..c8249c455a
--- /dev/null
+++ b/app/Support/Search/GdbotsQueryParser.php
@@ -0,0 +1,80 @@
+parser = new BaseQueryParser();
+ }
+
+ /**
+ * @return Node[]
+ * @throws FireflyException
+ */
+ public function parse(string $query): array
+ {
+ try {
+ $result = $this->parser->parse($query);
+ return array_map(
+ fn(GdbotsNode\Node $node) => $this->convertNode($node),
+ $result->getNodes()
+ );
+ } 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 Word($node->getValue());
+
+ case $node instanceof GdbotsNode\Field:
+ return new Field(
+ $node->getValue(),
+ (string) $node->getNode()->getValue(),
+ BoolOperator::PROHIBITED === $node->getBoolOperator()
+ );
+
+ case $node instanceof GdbotsNode\Subquery:
+ return new Subquery(
+ 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 Word((string) $node->getValue());
+
+ default:
+ throw new FireflyException(
+ sprintf('Unsupported node type: %s', get_class($node))
+ );
+ }
+ }
+}
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index 8683d4e289..ca989bd911 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -41,20 +41,6 @@ use FireflyIII\Repositories\Tag\TagRepositoryInterface;
use FireflyIII\Repositories\UserGroups\Currency\CurrencyRepositoryInterface;
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;
@@ -145,10 +131,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);
+ $nodes = $parser->parse($query);
} catch (\LogicException|\TypeError $e) {
app('log')->error($e->getMessage());
app('log')->error(sprintf('Could not parse search: "%s".', $query));
@@ -156,9 +143,9 @@ 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)', count($nodes)));
+ foreach ($nodes as $node) {
+ $this->handleSearchNode($node);
}
// add missing information
@@ -173,81 +160,71 @@ class OperatorQuerySearch implements SearchInterface
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
- private function handleSearchNode(Node $searchNode): void
+ private function handleSearchNode(Node $node): 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 ($class) {
- default:
- app('log')->error(sprintf('Cannot handle node %s', $class));
-
- 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;
-
- 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));
+ switch (true) {
+ case $node instanceof Word:
+ $allWords = (string) $node->getValue();
+ app('log')->debug(sprintf('Add words "%s" to search string', $allWords));
$this->words[] = $allWords;
-
break;
- case Field::class:
- app('log')->debug(sprintf('Now handle Node class %s', $class));
+ case $node instanceof Field:
+ $this->handleFieldNode($node);
+ break;
- /** @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));
+ case $node instanceof Subquery:
+ foreach ($node->getNodes() as $subNode) {
+ $this->handleSearchNode($subNode);
+ }
+ break;
- // 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';
- }
+ default:
+ 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)));
+ }
+ }
- // 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,
- ];
- }
+ /**
+ * @throws FireflyException
+ */
+ private function handleFieldNode(Field $node): void
+ {
+ $operator = strtolower($node->getOperator());
+ $value = $node->getValue();
+ $prohibited = $node->isProhibited();
+
+ $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,
+ ];
}
}
diff --git a/app/Support/Search/QueryParser.php b/app/Support/Search/QueryParser.php
new file mode 100644
index 0000000000..ba07f2a1c9
--- /dev/null
+++ b/app/Support/Search/QueryParser.php
@@ -0,0 +1,171 @@
+query = $query;
+ $this->position = 0;
+ return $this->parseQuery();
+ }
+
+ private function parseQuery(): array
+ {
+ $nodes = [];
+
+ while ($this->position < strlen($this->query)) {
+ $this->skipWhitespace();
+
+ if ($this->position >= strlen($this->query)) {
+ break;
+ }
+
+ // Handle subquery
+ if ($this->query[$this->position] === '(') {
+ $nodes[] = $this->parseSubquery();
+ continue;
+ }
+
+ // Handle field operator
+ if ($this->isStartOfField()) {
+ $nodes[] = $this->parseField();
+ continue;
+ }
+
+ // Handle word
+ $nodes[] = $this->parseWord();
+ }
+
+ return $nodes;
+ }
+
+ private function parseSubquery(): Subquery
+ {
+ $this->position++; // Skip opening parenthesis
+ $nodes = [];
+
+ while ($this->position < strlen($this->query)) {
+ $this->skipWhitespace();
+
+ if ($this->query[$this->position] === ')') {
+ $this->position++; // Skip closing parenthesis
+ break;
+ }
+
+ if ($this->query[$this->position] === '(') {
+ $nodes[] = $this->parseSubquery();
+ continue;
+ }
+
+ if ($this->isStartOfField()) {
+ $nodes[] = $this->parseField();
+ continue;
+ }
+
+ $nodes[] = $this->parseWord();
+ }
+
+ return new Subquery($nodes);
+ }
+
+ private function parseField(): Field
+ {
+ $prohibited = false;
+ if ($this->query[$this->position] === '-') {
+ $prohibited = true;
+ $this->position++;
+ }
+
+ $operator = '';
+ while ($this->position < strlen($this->query) && $this->query[$this->position] !== ':') {
+ $operator .= $this->query[$this->position];
+ $this->position++;
+ }
+ $this->position++; // Skip colon
+
+ $value = '';
+ $inQuotes = false;
+ while ($this->position < strlen($this->query)) {
+ $char = $this->query[$this->position];
+
+ if ($char === '"' && !$inQuotes) {
+ $inQuotes = true;
+ $this->position++;
+ continue;
+ }
+
+ if ($char === '"' && $inQuotes) {
+ $inQuotes = false;
+ $this->position++;
+ break;
+ }
+
+ if (!$inQuotes && ($char === ' ' || $char === ')')) {
+ break;
+ }
+
+ $value .= $char;
+ $this->position++;
+ }
+
+ return new Field(trim($operator), trim($value), $prohibited);
+ }
+
+ private function parseWord(): Word
+ {
+ $word = '';
+ while ($this->position < strlen($this->query)) {
+ $char = $this->query[$this->position];
+ if ($char === ' ' || $char === '(' || $char === ')') {
+ break;
+ }
+ $word .= $char;
+ $this->position++;
+ }
+ return new Word(trim($word));
+ }
+
+ private function isStartOfField(): bool
+ {
+ $pos = $this->position;
+ if ($this->query[$pos] === '-') {
+ $pos++;
+ }
+
+ // Look ahead for a colon that's not inside quotes
+ $inQuotes = false;
+ while ($pos < strlen($this->query)) {
+ if ($this->query[$pos] === '"') {
+ $inQuotes = !$inQuotes;
+ }
+ if ($this->query[$pos] === ':' && !$inQuotes) {
+ return true;
+ }
+ if ($this->query[$pos] === ' ' && !$inQuotes) {
+ return false;
+ }
+ $pos++;
+ }
+ return false;
+ }
+
+ private function skipWhitespace(): void
+ {
+ while ($this->position < strlen($this->query) && $this->query[$this->position] === ' ') {
+ $this->position++;
+ }
+ }
+}
diff --git a/app/Support/Search/QueryParserInterface.php b/app/Support/Search/QueryParserInterface.php
new file mode 100644
index 0000000000..d56494ce70
--- /dev/null
+++ b/app/Support/Search/QueryParserInterface.php
@@ -0,0 +1,112 @@
+value = $value;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+}
+
+/**
+ * Represents a field operator with value (e.g. amount:100)
+ */
+class Field extends Node
+{
+ private string $operator;
+ private string $value;
+ private bool $prohibited;
+
+ 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;
+ }
+
+ public function isProhibited(): bool
+ {
+ return $this->prohibited;
+ }
+
+ public function __toString(): string
+ {
+ return ($this->prohibited ? '-' : '') . $this->operator . ':' . $this->value;
+ }
+}
+
+/**
+ * Represents a subquery (group of nodes)
+ */
+class Subquery extends Node
+{
+ /** @var Node[] */
+ private array $nodes;
+
+ /**
+ * @param Node[] $nodes
+ */
+ public function __construct(array $nodes)
+ {
+ $this->nodes = $nodes;
+ }
+
+ /**
+ * @return Node[]
+ */
+ public function getNodes(): array
+ {
+ return $this->nodes;
+ }
+
+ public function __toString(): string
+ {
+ return '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
+ }
+}
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
new file mode 100644
index 0000000000..bb02a95b03
--- /dev/null
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -0,0 +1,213 @@
+
+ *
+ * This file is part of Firefly III (https://github.com/firefly-iii).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+declare(strict_types=1);
+
+namespace Tests\unit\Support;
+
+use FireflyIII\Support\Search\Field;
+use FireflyIII\Support\Search\QueryParserInterface;
+use FireflyIII\Support\Search\Word;
+use FireflyIII\Support\Search\Subquery;
+use Tests\integration\TestCase;
+
+
+abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
+{
+ abstract protected function createParser(): QueryParserInterface;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public function __construct(string $name)
+ {
+ parent::__construct($name);
+ }
+
+ public function testGivenEmptyStringWhenParsingQueryThenReturnsEmptyArray(): void
+ {
+ $result = $this->createParser()->parse('');
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testGivenProhibitedFieldOperatorWhenParsingQueryThenReturnsFieldNode(): void
+ {
+ $result = $this->createParser()->parse('-amount:100');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertTrue($result[0]->isProhibited());
+ $this->assertEquals('amount', $result[0]->getOperator());
+ $this->assertEquals('100', $result[0]->getValue());
+ }
+
+ /*public function testGivenNestedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
+ {
+ $result = $this->createParser()->parse('(amount:100 (description_contains:"test payment" -has_attachments:true))');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Subquery::class, $result[0]);
+
+ $nodes = $result[0]->getNodes();
+ $this->assertCount(2, $nodes);
+
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ $this->assertEquals('amount', $nodes[0]->getOperator());
+ $this->assertEquals('100', $nodes[0]->getValue());
+
+ $this->assertInstanceOf(Subquery::class, $nodes[1]);
+ $subNodes = $nodes[1]->getNodes();
+ $this->assertCount(2, $subNodes);
+
+ $this->assertInstanceOf(Field::class, $subNodes[0]);
+ $this->assertEquals('description_contains', $subNodes[0]->getOperator());
+ $this->assertEquals('test payment', $subNodes[0]->getValue());
+
+ $this->assertInstanceOf(Field::class, $subNodes[1]);
+ $this->assertTrue($subNodes[1]->isProhibited());
+ $this->assertEquals('has_attachments', $subNodes[1]->getOperator());
+ $this->assertEquals('true', $subNodes[1]->getValue());
+ }*/
+
+ public function testGivenSimpleWordWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse('groceries');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('groceries', $result[0]->getValue());
+ }
+
+ public function testGivenMultipleWordsWhenParsingQueryThenReturnsWordNodes(): void
+ {
+ $result = $this->createParser()->parse('groceries shopping market');
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('groceries', $result[0]->getValue());
+
+ $this->assertInstanceOf(Word::class, $result[1]);
+ $this->assertEquals('shopping', $result[1]->getValue());
+
+ $this->assertInstanceOf(Word::class, $result[2]);
+ $this->assertEquals('market', $result[2]->getValue());
+ }
+
+ public function testGivenMixedWordsAndOperatorsWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('groceries amount:50 shopping');
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('groceries', $result[0]->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[1]);
+ $this->assertEquals('amount', $result[1]->getOperator());
+ $this->assertEquals('50', $result[1]->getValue());
+
+ $this->assertInstanceOf(Word::class, $result[2]);
+ $this->assertEquals('shopping', $result[2]->getValue());
+ }
+
+ public function testGivenQuotedValueWithSpacesWhenParsingQueryThenReturnsFieldNode(): void
+ {
+ $result = $this->createParser()->parse('description_contains:"shopping at market"');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('description_contains', $result[0]->getOperator());
+ $this->assertEquals('shopping at market', $result[0]->getValue());
+ }
+
+ public function testGivenDecimalNumberWhenParsingQueryThenReturnsFieldNode(): void
+ {
+ $result = $this->createParser()->parse('amount:123.45');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('amount', $result[0]->getOperator());
+ $this->assertEquals('123.45', $result[0]->getValue());
+ }
+
+ public function testGivenBooleanOperatorWhenParsingQueryThenReturnsFieldNode(): void
+ {
+ $result = $this->createParser()->parse('has_any_category:true');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('has_any_category', $result[0]->getOperator());
+ $this->assertEquals('true', $result[0]->getValue());
+ }
+
+ /*public function testGivenIncompleteFieldOperatorWhenParsingQueryThenHandlesGracefully(): void
+ {
+ $result = $this->createParser()->parse('amount:');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('amount', $result[0]->getOperator());
+ $this->assertEquals('', $result[0]->getValue());
+ }*/
+
+ public function testGivenUnterminatedQuoteWhenParsingQueryThenHandlesGracefully(): void
+ {
+ $result = $this->createParser()->parse('description_contains:"unterminated');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('description_contains', $result[0]->getOperator());
+ $this->assertEquals('unterminated', $result[0]->getValue());
+ }
+
+ public function testGivenWordFollowedBySubqueryWithoutSpaceWhenParsingQueryThenReturnsCorrectNodes(): void
+{
+ $result = $this->createParser()->parse('groceries(amount:100 description_contains:"test")');
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+
+ // Test the word node
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('groceries', $result[0]->getValue());
+
+ // Test the subquery node
+ $this->assertInstanceOf(Subquery::class, $result[1]);
+ $nodes = $result[1]->getNodes();
+ $this->assertCount(2, $nodes);
+
+ // Test first field in subquery
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ $this->assertEquals('amount', $nodes[0]->getOperator());
+ $this->assertEquals('100', $nodes[0]->getValue());
+
+ // Test second field in subquery
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ $this->assertEquals('description_contains', $nodes[1]->getOperator());
+ $this->assertEquals('test', $nodes[1]->getValue());
+}
+}
diff --git a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
new file mode 100644
index 0000000000..c0e6fbd71d
--- /dev/null
+++ b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
@@ -0,0 +1,24 @@
+
Date: Tue, 31 Dec 2024 10:40:32 +0100
Subject: [PATCH 02/27] Fix namespace
---
.../Search/AbstractQueryParserInterfaceParseQueryTest.php | 2 +-
tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php | 4 ++--
tests/unit/Support/Search/QueryParserParseQueryTest.php | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index bb02a95b03..8c4758d2e1 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -22,7 +22,7 @@
declare(strict_types=1);
-namespace Tests\unit\Support;
+namespace Tests\unit\Support\Search;
use FireflyIII\Support\Search\Field;
use FireflyIII\Support\Search\QueryParserInterface;
diff --git a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
index c0e6fbd71d..897d9ecb01 100644
--- a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
@@ -1,10 +1,10 @@
Date: Wed, 1 Jan 2025 04:17:55 +0100
Subject: [PATCH 03/27] More WIP
---
app/Support/Search/OperatorQuerySearch.php | 13 +-
app/Support/Search/QueryParser2.php | 136 +++++++++++
app/Support/Search/QueryParserInterface.php | 23 +-
...ractQueryParserInterfaceParseQueryTest.php | 226 ++++++++++++++----
.../Search/QueryParserParseQueryTest.php | 4 +-
5 files changed, 341 insertions(+), 61 deletions(-)
create mode 100644 app/Support/Search/QueryParser2.php
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index ca989bd911..596aedd7d0 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -166,9 +166,15 @@ class OperatorQuerySearch implements SearchInterface
switch (true) {
case $node instanceof Word:
- $allWords = (string) $node->getValue();
- app('log')->debug(sprintf('Add words "%s" to search string', $allWords));
- $this->words[] = $allWords;
+ $word = (string) $node->getValue();
+ if($node->isProhibited()) {
+ app('log')->debug(sprintf('Exclude word "%s" from search string', $word));
+ $this->prohibitedWords[] = $word;
+ } else {
+ app('log')->debug(sprintf('Add word "%s" to search string', $word));
+ $this->words[] = $word;
+ }
+
break;
case $node instanceof Field:
@@ -176,6 +182,7 @@ class OperatorQuerySearch implements SearchInterface
break;
case $node instanceof Subquery:
+ //TODO: Handle Subquery prohibition, i.e. flip all prohibition flags inside the subquery
foreach ($node->getNodes() as $subNode) {
$this->handleSearchNode($subNode);
}
diff --git a/app/Support/Search/QueryParser2.php b/app/Support/Search/QueryParser2.php
new file mode 100644
index 0000000000..76e2a816e4
--- /dev/null
+++ b/app/Support/Search/QueryParser2.php
@@ -0,0 +1,136 @@
+query = $query;
+ $this->position = 0;
+ return $this->parseQuery();
+ }
+
+ private function parseQuery(): array
+ {
+ $nodes = [];
+ $token = $this->buildNextNode();
+
+ while ($token !== null) {
+ $nodes[] = $token;
+ $token = $this->buildNextNode();
+ }
+
+ return $nodes;
+ }
+
+ private function buildNextNode(): ?Node
+ {
+ $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 $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ }
+ }
+
+ 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 Subquery($this->parseQuery(), $prohibited);
+ } else {
+ // In any other location, it's just a normal character
+ $tokenUnderConstruction .= $char;
+ }
+ break;
+
+ case ')':
+ if ($tokenUnderConstruction !== '') {
+ $this->position++;
+ return $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ }
+ $this->position++;
+ return null;
+
+
+ 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 $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ }
+ break;
+
+ default:
+ $tokenUnderConstruction .= $char;
+ }
+
+ $this->position++;
+ }
+
+ return $fieldName !== '' || $tokenUnderConstruction !== ''
+ ? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
+ : null;
+ }
+
+ private function createNode(string $token, string $fieldName, bool $prohibited): Node
+ {
+ if (strlen($fieldName) > 0) {
+ return new Field(trim($fieldName), trim($token), $prohibited);
+ }
+ return new Word(trim($token), $prohibited);
+ }
+}
diff --git a/app/Support/Search/QueryParserInterface.php b/app/Support/Search/QueryParserInterface.php
index d56494ce70..93f88d1e75 100644
--- a/app/Support/Search/QueryParserInterface.php
+++ b/app/Support/Search/QueryParserInterface.php
@@ -27,10 +27,12 @@ abstract class Node
class Word extends Node
{
private string $value;
+ private bool $prohibited;
- public function __construct(string $value)
+ public function __construct(string $value, bool $prohibited = false)
{
$this->value = $value;
+ $this->prohibited = $prohibited;
}
public function getValue(): string
@@ -38,9 +40,14 @@ class Word extends Node
return $this->value;
}
+ public function isProhibited(): bool
+ {
+ return $this->prohibited;
+ }
+
public function __toString(): string
{
- return $this->value;
+ return ($this->prohibited ? '-' : '') . $this->value;
}
}
@@ -89,12 +96,15 @@ class Subquery extends Node
/** @var Node[] */
private array $nodes;
+ private bool $prohibited;
+
/**
* @param Node[] $nodes
*/
- public function __construct(array $nodes)
+ public function __construct(array $nodes, bool $prohibited = false)
{
$this->nodes = $nodes;
+ $this->prohibited = $prohibited;
}
/**
@@ -105,8 +115,13 @@ class Subquery extends Node
return $this->nodes;
}
+ public function isProhibited(): bool
+ {
+ return $this->prohibited;
+ }
+
public function __toString(): string
{
- return '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
+ return ($this->prohibited ? '-' : '') . '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
}
}
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index 8c4758d2e1..5b27e4caeb 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -65,35 +65,6 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertEquals('100', $result[0]->getValue());
}
- /*public function testGivenNestedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
- {
- $result = $this->createParser()->parse('(amount:100 (description_contains:"test payment" -has_attachments:true))');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertInstanceOf(Subquery::class, $result[0]);
-
- $nodes = $result[0]->getNodes();
- $this->assertCount(2, $nodes);
-
- $this->assertInstanceOf(Field::class, $nodes[0]);
- $this->assertEquals('amount', $nodes[0]->getOperator());
- $this->assertEquals('100', $nodes[0]->getValue());
-
- $this->assertInstanceOf(Subquery::class, $nodes[1]);
- $subNodes = $nodes[1]->getNodes();
- $this->assertCount(2, $subNodes);
-
- $this->assertInstanceOf(Field::class, $subNodes[0]);
- $this->assertEquals('description_contains', $subNodes[0]->getOperator());
- $this->assertEquals('test payment', $subNodes[0]->getValue());
-
- $this->assertInstanceOf(Field::class, $subNodes[1]);
- $this->assertTrue($subNodes[1]->isProhibited());
- $this->assertEquals('has_attachments', $subNodes[1]->getOperator());
- $this->assertEquals('true', $subNodes[1]->getValue());
- }*/
-
public function testGivenSimpleWordWhenParsingQueryThenReturnsWordNode(): void
{
$result = $this->createParser()->parse('groceries');
@@ -166,14 +137,23 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertEquals('true', $result[0]->getValue());
}
- /*public function testGivenIncompleteFieldOperatorWhenParsingQueryThenHandlesGracefully(): void
+ public function testGivenFieldOperatorWithBlankValueWhenParsingQueryThenReturnsCorrectNodes(): void
{
$result = $this->createParser()->parse('amount:');
$this->assertInstanceOf(Field::class, $result[0]);
$this->assertEquals('amount', $result[0]->getOperator());
$this->assertEquals('', $result[0]->getValue());
- }*/
+ }
+
+ public function testGivenFieldOperatorWithEmptyQuotedStringWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('amount:""');
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('amount', $result[0]->getOperator());
+ $this->assertEquals('', $result[0]->getValue());
+ }
public function testGivenUnterminatedQuoteWhenParsingQueryThenHandlesGracefully(): void
{
@@ -184,30 +164,172 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertEquals('unterminated', $result[0]->getValue());
}
- public function testGivenWordFollowedBySubqueryWithoutSpaceWhenParsingQueryThenReturnsCorrectNodes(): void
-{
- $result = $this->createParser()->parse('groceries(amount:100 description_contains:"test")');
+ public function testGivenWordFollowedBySubqueryWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('groceries (amount:100 description_contains:"test")');
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
- // Test the word node
- $this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('groceries', $result[0]->getValue());
+ // Test the word node
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('groceries', $result[0]->getValue());
- // Test the subquery node
- $this->assertInstanceOf(Subquery::class, $result[1]);
- $nodes = $result[1]->getNodes();
- $this->assertCount(2, $nodes);
+ // Test the subquery node
+ $this->assertInstanceOf(Subquery::class, $result[1]);
+ $nodes = $result[1]->getNodes();
+ $this->assertCount(2, $nodes);
- // Test first field in subquery
- $this->assertInstanceOf(Field::class, $nodes[0]);
- $this->assertEquals('amount', $nodes[0]->getOperator());
- $this->assertEquals('100', $nodes[0]->getValue());
+ // Test first field in subquery
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ $this->assertEquals('amount', $nodes[0]->getOperator());
+ $this->assertEquals('100', $nodes[0]->getValue());
- // Test second field in subquery
- $this->assertInstanceOf(Field::class, $nodes[1]);
- $this->assertEquals('description_contains', $nodes[1]->getOperator());
- $this->assertEquals('test', $nodes[1]->getValue());
-}
+ // Test second field in subquery
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ $this->assertEquals('description_contains', $nodes[1]->getOperator());
+ $this->assertEquals('test', $nodes[1]->getValue());
+ }
+
+ public function testGivenMultipleFieldsWithQuotedValuesWhenParsingQueryThenReturnsFieldNodes(): void
+ {
+ $result = $this->createParser()->parse('description:"shopping at market" notes:"paid in cash" category:"groceries and food"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('description', $result[0]->getOperator());
+ $this->assertEquals('shopping at market', $result[0]->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[1]);
+ $this->assertEquals('notes', $result[1]->getOperator());
+ $this->assertEquals('paid in cash', $result[1]->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[2]);
+ $this->assertEquals('category', $result[2]->getOperator());
+ $this->assertEquals('groceries and food', $result[2]->getValue());
+ }
+
+ public function testGivenSubqueryAfterFieldValueWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('amount:100 (description:"market" category:food)');
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('amount', $result[0]->getOperator());
+ $this->assertEquals('100', $result[0]->getValue());
+
+ $this->assertInstanceOf(Subquery::class, $result[1]);
+ $nodes = $result[1]->getNodes();
+ $this->assertCount(2, $nodes);
+
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ $this->assertEquals('description', $nodes[0]->getOperator());
+ $this->assertEquals('market', $nodes[0]->getValue());
+
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ $this->assertEquals('category', $nodes[1]->getOperator());
+ $this->assertEquals('food', $nodes[1]->getValue());
+ }
+
+ public function testGivenMultipleFieldsWithQuotedValuesWithoutSpacesWhenParsingQueryThenReturnsFieldNodes(): void
+ {
+ $result = $this->createParser()->parse('description:"shopping market"category:"groceries"notes:"cash payment"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ $this->assertEquals('description', $result[0]->getOperator());
+ $this->assertEquals('shopping market', $result[0]->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[1]);
+ $this->assertEquals('category', $result[1]->getOperator());
+ $this->assertEquals('groceries', $result[1]->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[2]);
+ $this->assertEquals('notes', $result[2]->getOperator());
+ $this->assertEquals('cash payment', $result[2]->getValue());
+ }
+
+ public function testGivenStringWithSingleQuoteInMiddleWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse('stringWithSingle"InMiddle');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('stringWithSingle"InMiddle', $result[0]->getValue());
+ }
+
+ public function testGivenWordStartingWithColonWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse(':startingWithColon');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals(':startingWithColon', $result[0]->getValue());
+ }
+
+ public function testGivenComplexNestedSubqueriesWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('shopping (amount:50 market (-category:food word description:"test phrase" (has_notes:true)))');
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+
+ // Test the first word node
+ $this->assertInstanceOf(Word::class, $result[0]);
+ $this->assertEquals('shopping', $result[0]->getValue());
+
+ // Test first level subquery
+ $this->assertInstanceOf(Subquery::class, $result[1]);
+ /** @var Subquery $firstLevelSubquery */
+ $firstLevelSubquery = $result[1];
+ $level1Nodes = $firstLevelSubquery->getNodes();
+ $this->assertCount(3, $level1Nodes);
+
+ // Test field in first level
+ $this->assertInstanceOf(Field::class, $level1Nodes[0]);
+ $this->assertEquals('amount', $level1Nodes[0]->getOperator());
+ $this->assertEquals('50', $level1Nodes[0]->getValue());
+
+ // Test word in first level
+ $this->assertInstanceOf(Word::class, $level1Nodes[1]);
+ $this->assertEquals('market', $level1Nodes[1]->getValue());
+
+ // Test second level subquery
+ $this->assertInstanceOf(Subquery::class, $level1Nodes[2]);
+ $level2Nodes = $level1Nodes[2]->getNodes();
+ $this->assertCount(4, $level2Nodes);
+
+ // Test prohibited field in second level
+ $this->assertInstanceOf(Field::class, $level2Nodes[0]);
+ $this->assertTrue($level2Nodes[0]->isProhibited());
+ $this->assertEquals('category', $level2Nodes[0]->getOperator());
+ $this->assertEquals('food', $level2Nodes[0]->getValue());
+
+ // Test word in second level
+ $this->assertInstanceOf(Word::class, $level2Nodes[1]);
+ $this->assertEquals('word', $level2Nodes[1]->getValue());
+
+ // Test field with quoted value in second level
+ $this->assertInstanceOf(Field::class, $level2Nodes[2]);
+ $this->assertEquals('description', $level2Nodes[2]->getOperator());
+ $this->assertEquals('test phrase', $level2Nodes[2]->getValue());
+
+ // Test third level subquery
+ $this->assertInstanceOf(Subquery::class, $level2Nodes[3]);
+ $level3Nodes = $level2Nodes[3]->getNodes();
+ $this->assertCount(1, $level3Nodes);
+
+ // Test field in third level
+ $this->assertInstanceOf(Field::class, $level3Nodes[0]);
+ $this->assertEquals('has_notes', $level3Nodes[0]->getOperator());
+ $this->assertEquals('true', $level3Nodes[0]->getValue());
+ }
}
diff --git a/tests/unit/Support/Search/QueryParserParseQueryTest.php b/tests/unit/Support/Search/QueryParserParseQueryTest.php
index a5c4e85963..f05301f29c 100644
--- a/tests/unit/Support/Search/QueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParserParseQueryTest.php
@@ -2,7 +2,7 @@
namespace Tests\unit\Support\Search;
-use FireflyIII\Support\Search\QueryParser;
+use FireflyIII\Support\Search\QueryParser2;
use FireflyIII\Support\Search\QueryParserInterface;
use Tests\unit\Support\Search\AbstractQueryParserInterfaceParseQueryTest;
@@ -20,6 +20,6 @@ final class QueryParserParseQueryTest extends AbstractQueryParserInterfaceParseQ
{
protected function createParser(): QueryParserInterface
{
- return new QueryParser();
+ return new QueryParser2();
}
}
From 0e3d779e241db92c394d987756a23f47d911a9b2 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 04:27:44 +0100
Subject: [PATCH 04/27] Remove V1
---
app/Support/Search/QueryParser.php | 171 -----------------------------
1 file changed, 171 deletions(-)
delete mode 100644 app/Support/Search/QueryParser.php
diff --git a/app/Support/Search/QueryParser.php b/app/Support/Search/QueryParser.php
deleted file mode 100644
index ba07f2a1c9..0000000000
--- a/app/Support/Search/QueryParser.php
+++ /dev/null
@@ -1,171 +0,0 @@
-query = $query;
- $this->position = 0;
- return $this->parseQuery();
- }
-
- private function parseQuery(): array
- {
- $nodes = [];
-
- while ($this->position < strlen($this->query)) {
- $this->skipWhitespace();
-
- if ($this->position >= strlen($this->query)) {
- break;
- }
-
- // Handle subquery
- if ($this->query[$this->position] === '(') {
- $nodes[] = $this->parseSubquery();
- continue;
- }
-
- // Handle field operator
- if ($this->isStartOfField()) {
- $nodes[] = $this->parseField();
- continue;
- }
-
- // Handle word
- $nodes[] = $this->parseWord();
- }
-
- return $nodes;
- }
-
- private function parseSubquery(): Subquery
- {
- $this->position++; // Skip opening parenthesis
- $nodes = [];
-
- while ($this->position < strlen($this->query)) {
- $this->skipWhitespace();
-
- if ($this->query[$this->position] === ')') {
- $this->position++; // Skip closing parenthesis
- break;
- }
-
- if ($this->query[$this->position] === '(') {
- $nodes[] = $this->parseSubquery();
- continue;
- }
-
- if ($this->isStartOfField()) {
- $nodes[] = $this->parseField();
- continue;
- }
-
- $nodes[] = $this->parseWord();
- }
-
- return new Subquery($nodes);
- }
-
- private function parseField(): Field
- {
- $prohibited = false;
- if ($this->query[$this->position] === '-') {
- $prohibited = true;
- $this->position++;
- }
-
- $operator = '';
- while ($this->position < strlen($this->query) && $this->query[$this->position] !== ':') {
- $operator .= $this->query[$this->position];
- $this->position++;
- }
- $this->position++; // Skip colon
-
- $value = '';
- $inQuotes = false;
- while ($this->position < strlen($this->query)) {
- $char = $this->query[$this->position];
-
- if ($char === '"' && !$inQuotes) {
- $inQuotes = true;
- $this->position++;
- continue;
- }
-
- if ($char === '"' && $inQuotes) {
- $inQuotes = false;
- $this->position++;
- break;
- }
-
- if (!$inQuotes && ($char === ' ' || $char === ')')) {
- break;
- }
-
- $value .= $char;
- $this->position++;
- }
-
- return new Field(trim($operator), trim($value), $prohibited);
- }
-
- private function parseWord(): Word
- {
- $word = '';
- while ($this->position < strlen($this->query)) {
- $char = $this->query[$this->position];
- if ($char === ' ' || $char === '(' || $char === ')') {
- break;
- }
- $word .= $char;
- $this->position++;
- }
- return new Word(trim($word));
- }
-
- private function isStartOfField(): bool
- {
- $pos = $this->position;
- if ($this->query[$pos] === '-') {
- $pos++;
- }
-
- // Look ahead for a colon that's not inside quotes
- $inQuotes = false;
- while ($pos < strlen($this->query)) {
- if ($this->query[$pos] === '"') {
- $inQuotes = !$inQuotes;
- }
- if ($this->query[$pos] === ':' && !$inQuotes) {
- return true;
- }
- if ($this->query[$pos] === ' ' && !$inQuotes) {
- return false;
- }
- $pos++;
- }
- return false;
- }
-
- private function skipWhitespace(): void
- {
- while ($this->position < strlen($this->query) && $this->query[$this->position] === ' ') {
- $this->position++;
- }
- }
-}
From be2d3f3637de4698d9eb10427ef5ace4ceff471e Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 04:28:06 +0100
Subject: [PATCH 05/27] Rename QueryParser2 to QueryParser
---
app/Support/Search/{QueryParser2.php => QueryParser.php} | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
rename app/Support/Search/{QueryParser2.php => QueryParser.php} (98%)
diff --git a/app/Support/Search/QueryParser2.php b/app/Support/Search/QueryParser.php
similarity index 98%
rename from app/Support/Search/QueryParser2.php
rename to app/Support/Search/QueryParser.php
index 76e2a816e4..e25e7f2afe 100644
--- a/app/Support/Search/QueryParser2.php
+++ b/app/Support/Search/QueryParser.php
@@ -7,7 +7,7 @@ namespace FireflyIII\Support\Search;
/**
* Query parser class
*/
-class QueryParser2 implements QueryParserInterface
+class QueryParser implements QueryParserInterface
{
private string $query;
private int $position = 0;
From e38e4574adf40f33ba30b43ce0bdfd97b865d7a5 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 04:28:18 +0100
Subject: [PATCH 06/27] Fix warnings in tests and add some more
---
...ractQueryParserInterfaceParseQueryTest.php | 408 +++++++++++-------
1 file changed, 247 insertions(+), 161 deletions(-)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index 5b27e4caeb..06e67a1713 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -30,7 +30,6 @@ use FireflyIII\Support\Search\Word;
use FireflyIII\Support\Search\Subquery;
use Tests\integration\TestCase;
-
abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
{
abstract protected function createParser(): QueryParserInterface;
@@ -60,9 +59,11 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
$this->assertInstanceOf(Field::class, $result[0]);
- $this->assertTrue($result[0]->isProhibited());
- $this->assertEquals('amount', $result[0]->getOperator());
- $this->assertEquals('100', $result[0]->getValue());
+ /** @var Field $field */
+ $field = $result[0];
+ $this->assertTrue($field->isProhibited());
+ $this->assertEquals('amount', $field->getOperator());
+ $this->assertEquals('100', $field->getValue());
}
public function testGivenSimpleWordWhenParsingQueryThenReturnsWordNode(): void
@@ -72,7 +73,9 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
$this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('groceries', $result[0]->getValue());
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('groceries', $word->getValue());
}
public function testGivenMultipleWordsWhenParsingQueryThenReturnsWordNodes(): void
@@ -83,13 +86,19 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertCount(3, $result);
$this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('groceries', $result[0]->getValue());
+ /** @var Word $word1 */
+ $word1 = $result[0];
+ $this->assertEquals('groceries', $word1->getValue());
$this->assertInstanceOf(Word::class, $result[1]);
- $this->assertEquals('shopping', $result[1]->getValue());
+ /** @var Word $word2 */
+ $word2 = $result[1];
+ $this->assertEquals('shopping', $word2->getValue());
$this->assertInstanceOf(Word::class, $result[2]);
- $this->assertEquals('market', $result[2]->getValue());
+ /** @var Word $word3 */
+ $word3 = $result[2];
+ $this->assertEquals('market', $word3->getValue());
}
public function testGivenMixedWordsAndOperatorsWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -100,14 +109,20 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertCount(3, $result);
$this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('groceries', $result[0]->getValue());
+ /** @var Word $word1 */
+ $word1 = $result[0];
+ $this->assertEquals('groceries', $word1->getValue());
$this->assertInstanceOf(Field::class, $result[1]);
- $this->assertEquals('amount', $result[1]->getOperator());
- $this->assertEquals('50', $result[1]->getValue());
+ /** @var Field $field */
+ $field = $result[1];
+ $this->assertEquals('amount', $field->getOperator());
+ $this->assertEquals('50', $field->getValue());
$this->assertInstanceOf(Word::class, $result[2]);
- $this->assertEquals('shopping', $result[2]->getValue());
+ /** @var Word $word2 */
+ $word2 = $result[2];
+ $this->assertEquals('shopping', $word2->getValue());
}
public function testGivenQuotedValueWithSpacesWhenParsingQueryThenReturnsFieldNode(): void
@@ -115,100 +130,10 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$result = $this->createParser()->parse('description_contains:"shopping at market"');
$this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('description_contains', $result[0]->getOperator());
- $this->assertEquals('shopping at market', $result[0]->getValue());
- }
-
- public function testGivenDecimalNumberWhenParsingQueryThenReturnsFieldNode(): void
- {
- $result = $this->createParser()->parse('amount:123.45');
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('amount', $result[0]->getOperator());
- $this->assertEquals('123.45', $result[0]->getValue());
- }
-
- public function testGivenBooleanOperatorWhenParsingQueryThenReturnsFieldNode(): void
- {
- $result = $this->createParser()->parse('has_any_category:true');
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('has_any_category', $result[0]->getOperator());
- $this->assertEquals('true', $result[0]->getValue());
- }
-
- public function testGivenFieldOperatorWithBlankValueWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('amount:');
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('amount', $result[0]->getOperator());
- $this->assertEquals('', $result[0]->getValue());
- }
-
- public function testGivenFieldOperatorWithEmptyQuotedStringWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('amount:""');
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('amount', $result[0]->getOperator());
- $this->assertEquals('', $result[0]->getValue());
- }
-
- public function testGivenUnterminatedQuoteWhenParsingQueryThenHandlesGracefully(): void
- {
- $result = $this->createParser()->parse('description_contains:"unterminated');
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('description_contains', $result[0]->getOperator());
- $this->assertEquals('unterminated', $result[0]->getValue());
- }
-
- public function testGivenWordFollowedBySubqueryWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('groceries (amount:100 description_contains:"test")');
-
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
-
- // Test the word node
- $this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('groceries', $result[0]->getValue());
-
- // Test the subquery node
- $this->assertInstanceOf(Subquery::class, $result[1]);
- $nodes = $result[1]->getNodes();
- $this->assertCount(2, $nodes);
-
- // Test first field in subquery
- $this->assertInstanceOf(Field::class, $nodes[0]);
- $this->assertEquals('amount', $nodes[0]->getOperator());
- $this->assertEquals('100', $nodes[0]->getValue());
-
- // Test second field in subquery
- $this->assertInstanceOf(Field::class, $nodes[1]);
- $this->assertEquals('description_contains', $nodes[1]->getOperator());
- $this->assertEquals('test', $nodes[1]->getValue());
- }
-
- public function testGivenMultipleFieldsWithQuotedValuesWhenParsingQueryThenReturnsFieldNodes(): void
- {
- $result = $this->createParser()->parse('description:"shopping at market" notes:"paid in cash" category:"groceries and food"');
-
- $this->assertIsArray($result);
- $this->assertCount(3, $result);
-
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('description', $result[0]->getOperator());
- $this->assertEquals('shopping at market', $result[0]->getValue());
-
- $this->assertInstanceOf(Field::class, $result[1]);
- $this->assertEquals('notes', $result[1]->getOperator());
- $this->assertEquals('paid in cash', $result[1]->getValue());
-
- $this->assertInstanceOf(Field::class, $result[2]);
- $this->assertEquals('category', $result[2]->getOperator());
- $this->assertEquals('groceries and food', $result[2]->getValue());
+ /** @var Field $field */
+ $field = $result[0];
+ $this->assertEquals('description_contains', $field->getOperator());
+ $this->assertEquals('shopping at market', $field->getValue());
}
public function testGivenSubqueryAfterFieldValueWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -219,60 +144,97 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertCount(2, $result);
$this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('amount', $result[0]->getOperator());
- $this->assertEquals('100', $result[0]->getValue());
+ /** @var Field $field */
+ $field = $result[0];
+ $this->assertEquals('amount', $field->getOperator());
+ $this->assertEquals('100', $field->getValue());
$this->assertInstanceOf(Subquery::class, $result[1]);
- $nodes = $result[1]->getNodes();
+ /** @var Subquery $subquery */
+ $subquery = $result[1];
+ $nodes = $subquery->getNodes();
$this->assertCount(2, $nodes);
$this->assertInstanceOf(Field::class, $nodes[0]);
- $this->assertEquals('description', $nodes[0]->getOperator());
- $this->assertEquals('market', $nodes[0]->getValue());
+ /** @var Field $field1 */
+ $field1 = $nodes[0];
+ $this->assertEquals('description', $field1->getOperator());
+ $this->assertEquals('market', $field1->getValue());
$this->assertInstanceOf(Field::class, $nodes[1]);
- $this->assertEquals('category', $nodes[1]->getOperator());
- $this->assertEquals('food', $nodes[1]->getValue());
+ /** @var Field $field2 */
+ $field2 = $nodes[1];
+ $this->assertEquals('category', $field2->getOperator());
+ $this->assertEquals('food', $field2->getValue());
}
- public function testGivenMultipleFieldsWithQuotedValuesWithoutSpacesWhenParsingQueryThenReturnsFieldNodes(): void
+ public function testGivenWordFollowedBySubqueryWhenParsingQueryThenReturnsCorrectNodes(): void
{
- $result = $this->createParser()->parse('description:"shopping market"category:"groceries"notes:"cash payment"');
+ $result = $this->createParser()->parse('groceries (amount:100 description_contains:"test")');
$this->assertIsArray($result);
- $this->assertCount(3, $result);
+ $this->assertCount(2, $result);
- $this->assertInstanceOf(Field::class, $result[0]);
- $this->assertEquals('description', $result[0]->getOperator());
- $this->assertEquals('shopping market', $result[0]->getValue());
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('groceries', $word->getValue());
- $this->assertInstanceOf(Field::class, $result[1]);
- $this->assertEquals('category', $result[1]->getOperator());
- $this->assertEquals('groceries', $result[1]->getValue());
+ $this->assertInstanceOf(Subquery::class, $result[1]);
+ /** @var Subquery $subquery */
+ $subquery = $result[1];
+ $nodes = $subquery->getNodes();
+ $this->assertCount(2, $nodes);
- $this->assertInstanceOf(Field::class, $result[2]);
- $this->assertEquals('notes', $result[2]->getOperator());
- $this->assertEquals('cash payment', $result[2]->getValue());
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ /** @var Field $field1 */
+ $field1 = $nodes[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('100', $field1->getValue());
+
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ /** @var Field $field2 */
+ $field2 = $nodes[1];
+ $this->assertEquals('description_contains', $field2->getOperator());
+ $this->assertEquals('test', $field2->getValue());
}
- public function testGivenStringWithSingleQuoteInMiddleWhenParsingQueryThenReturnsWordNode(): void
+ public function testGivenNestedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
{
- $result = $this->createParser()->parse('stringWithSingle"InMiddle');
+ $result = $this->createParser()->parse('(amount:100 (description_contains:"test payment" -has_attachments:true))');
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('stringWithSingle"InMiddle', $result[0]->getValue());
- }
+ $this->assertInstanceOf(Subquery::class, $result[0]);
+ /** @var Subquery $outerSubquery */
+ $outerSubquery = $result[0];
+ $nodes = $outerSubquery->getNodes();
+ $this->assertCount(2, $nodes);
- public function testGivenWordStartingWithColonWhenParsingQueryThenReturnsWordNode(): void
- {
- $result = $this->createParser()->parse(':startingWithColon');
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ /** @var Field $field1 */
+ $field1 = $nodes[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('100', $field1->getValue());
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals(':startingWithColon', $result[0]->getValue());
+ $this->assertInstanceOf(Subquery::class, $nodes[1]);
+ /** @var Subquery $innerSubquery */
+ $innerSubquery = $nodes[1];
+ $subNodes = $innerSubquery->getNodes();
+ $this->assertCount(2, $subNodes);
+
+ $this->assertInstanceOf(Field::class, $subNodes[0]);
+ /** @var Field $field2 */
+ $field2 = $subNodes[0];
+ $this->assertEquals('description_contains', $field2->getOperator());
+ $this->assertEquals('test payment', $field2->getValue());
+
+ $this->assertInstanceOf(Field::class, $subNodes[1]);
+ /** @var Field $field3 */
+ $field3 = $subNodes[1];
+ $this->assertTrue($field3->isProhibited());
+ $this->assertEquals('has_attachments', $field3->getOperator());
+ $this->assertEquals('true', $field3->getValue());
}
public function testGivenComplexNestedSubqueriesWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -282,54 +244,178 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(2, $result);
- // Test the first word node
$this->assertInstanceOf(Word::class, $result[0]);
- $this->assertEquals('shopping', $result[0]->getValue());
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('shopping', $word->getValue());
- // Test first level subquery
$this->assertInstanceOf(Subquery::class, $result[1]);
/** @var Subquery $firstLevelSubquery */
$firstLevelSubquery = $result[1];
$level1Nodes = $firstLevelSubquery->getNodes();
$this->assertCount(3, $level1Nodes);
- // Test field in first level
$this->assertInstanceOf(Field::class, $level1Nodes[0]);
- $this->assertEquals('amount', $level1Nodes[0]->getOperator());
- $this->assertEquals('50', $level1Nodes[0]->getValue());
+ /** @var Field $field1 */
+ $field1 = $level1Nodes[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('50', $field1->getValue());
- // Test word in first level
$this->assertInstanceOf(Word::class, $level1Nodes[1]);
- $this->assertEquals('market', $level1Nodes[1]->getValue());
+ /** @var Word $word2 */
+ $word2 = $level1Nodes[1];
+ $this->assertEquals('market', $word2->getValue());
- // Test second level subquery
$this->assertInstanceOf(Subquery::class, $level1Nodes[2]);
- $level2Nodes = $level1Nodes[2]->getNodes();
+ /** @var Subquery $secondLevelSubquery */
+ $secondLevelSubquery = $level1Nodes[2];
+ $level2Nodes = $secondLevelSubquery->getNodes();
$this->assertCount(4, $level2Nodes);
- // Test prohibited field in second level
$this->assertInstanceOf(Field::class, $level2Nodes[0]);
- $this->assertTrue($level2Nodes[0]->isProhibited());
- $this->assertEquals('category', $level2Nodes[0]->getOperator());
- $this->assertEquals('food', $level2Nodes[0]->getValue());
+ /** @var Field $field2 */
+ $field2 = $level2Nodes[0];
+ $this->assertTrue($field2->isProhibited());
+ $this->assertEquals('category', $field2->getOperator());
+ $this->assertEquals('food', $field2->getValue());
- // Test word in second level
$this->assertInstanceOf(Word::class, $level2Nodes[1]);
- $this->assertEquals('word', $level2Nodes[1]->getValue());
+ /** @var Word $word3 */
+ $word3 = $level2Nodes[1];
+ $this->assertEquals('word', $word3->getValue());
- // Test field with quoted value in second level
$this->assertInstanceOf(Field::class, $level2Nodes[2]);
- $this->assertEquals('description', $level2Nodes[2]->getOperator());
- $this->assertEquals('test phrase', $level2Nodes[2]->getValue());
+ /** @var Field $field3 */
+ $field3 = $level2Nodes[2];
+ $this->assertEquals('description', $field3->getOperator());
+ $this->assertEquals('test phrase', $field3->getValue());
- // Test third level subquery
$this->assertInstanceOf(Subquery::class, $level2Nodes[3]);
- $level3Nodes = $level2Nodes[3]->getNodes();
+ /** @var Subquery $thirdLevelSubquery */
+ $thirdLevelSubquery = $level2Nodes[3];
+ $level3Nodes = $thirdLevelSubquery->getNodes();
$this->assertCount(1, $level3Nodes);
- // Test field in third level
$this->assertInstanceOf(Field::class, $level3Nodes[0]);
- $this->assertEquals('has_notes', $level3Nodes[0]->getOperator());
- $this->assertEquals('true', $level3Nodes[0]->getValue());
+ /** @var Field $field4 */
+ $field4 = $level3Nodes[0];
+ $this->assertEquals('has_notes', $field4->getOperator());
+ $this->assertEquals('true', $field4->getValue());
+ }
+
+ public function testGivenProhibitedWordWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse('-groceries');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertTrue($word->isProhibited());
+ $this->assertEquals('groceries', $word->getValue());
+ }
+
+ public function testGivenQuotedWordWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse('"test phrase"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('test phrase', $word->getValue());
+ }
+
+ public function testGivenProhibitedQuotedWordWhenParsingQueryThenReturnsWordNode(): void
+ {
+ $result = $this->createParser()->parse('-"test phrase"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertTrue($word->isProhibited());
+ $this->assertEquals('test phrase', $word->getValue());
+ }
+
+ public function testGivenMultipleFieldsWhenParsingQueryThenReturnsFieldNodes(): void
+ {
+ $result = $this->createParser()->parse('amount:100 category:food description:"test phrase"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+
+ $this->assertInstanceOf(Field::class, $result[0]);
+ /** @var Field $field1 */
+ $field1 = $result[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('100', $field1->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[1]);
+ /** @var Field $field2 */
+ $field2 = $result[1];
+ $this->assertEquals('category', $field2->getOperator());
+ $this->assertEquals('food', $field2->getValue());
+
+ $this->assertInstanceOf(Field::class, $result[2]);
+ /** @var Field $field3 */
+ $field3 = $result[2];
+ $this->assertEquals('description', $field3->getOperator());
+ $this->assertEquals('test phrase', $field3->getValue());
+ }
+
+ public function testGivenProhibitedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
+ {
+ $result = $this->createParser()->parse('-(amount:100 category:food)');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Subquery::class, $result[0]);
+ /** @var Subquery $subquery */
+ $subquery = $result[0];
+ $this->assertTrue($subquery->isProhibited());
+
+ $nodes = $subquery->getNodes();
+ $this->assertCount(2, $nodes);
+
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ /** @var Field $field1 */
+ $field1 = $nodes[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('100', $field1->getValue());
+
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ /** @var Field $field2 */
+ $field2 = $nodes[1];
+ $this->assertEquals('category', $field2->getOperator());
+ $this->assertEquals('food', $field2->getValue());
+ }
+
+ public function testGivenWordWithMultipleSpacesWhenParsingQueryThenRetainsSpaces(): void
+ {
+ $result = $this->createParser()->parse('"multiple spaces"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('multiple spaces', $word->getValue());
+ }
+
+ public function testGivenFieldWithMultipleSpacesInValueWhenParsingQueryThenRetainsSpaces(): void
+ {
+ $result = $this->createParser()->parse('description:"multiple spaces here"');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Field::class, $result[0]);
+ /** @var Field $field */
+ $field = $result[0];
+ $this->assertEquals('description', $field->getOperator());
+ $this->assertEquals('multiple spaces here', $field->getValue());
}
}
From 0eca1c8d039f9ff19cc5f91f967bb5b95dd0f57a Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 04:29:31 +0100
Subject: [PATCH 07/27] Fix test
---
tests/unit/Support/Search/QueryParserParseQueryTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/unit/Support/Search/QueryParserParseQueryTest.php b/tests/unit/Support/Search/QueryParserParseQueryTest.php
index f05301f29c..a5c4e85963 100644
--- a/tests/unit/Support/Search/QueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParserParseQueryTest.php
@@ -2,7 +2,7 @@
namespace Tests\unit\Support\Search;
-use FireflyIII\Support\Search\QueryParser2;
+use FireflyIII\Support\Search\QueryParser;
use FireflyIII\Support\Search\QueryParserInterface;
use Tests\unit\Support\Search\AbstractQueryParserInterfaceParseQueryTest;
@@ -20,6 +20,6 @@ final class QueryParserParseQueryTest extends AbstractQueryParserInterfaceParseQ
{
protected function createParser(): QueryParserInterface
{
- return new QueryParser2();
+ return new QueryParser();
}
}
From 631ed4956aa758c9a592d3d13735aa829394ab32 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 05:08:01 +0100
Subject: [PATCH 08/27] Handle right parenthesis behaviour correctly
---
app/Support/Search/QueryParser.php | 74 ++++++++++++++-----
...ractQueryParserInterfaceParseQueryTest.php | 56 ++++++++++++++
2 files changed, 112 insertions(+), 18 deletions(-)
diff --git a/app/Support/Search/QueryParser.php b/app/Support/Search/QueryParser.php
index e25e7f2afe..9005671c71 100644
--- a/app/Support/Search/QueryParser.php
+++ b/app/Support/Search/QueryParser.php
@@ -5,7 +5,25 @@ declare(strict_types=1);
namespace FireflyIII\Support\Search;
/**
- * Query parser class
+ * 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 $isQueryEnd
+ ) {
+ }
+}
+
+
+/**
+ * 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
{
@@ -16,23 +34,26 @@ class QueryParser implements QueryParserInterface
{
$this->query = $query;
$this->position = 0;
- return $this->parseQuery();
+ return $this->parseQuery(false);
}
- private function parseQuery(): array
+ private function parseQuery(bool $isSubquery): array
{
$nodes = [];
- $token = $this->buildNextNode();
+ $nodeResult = $this->buildNextNode($isSubquery);
- while ($token !== null) {
- $nodes[] = $token;
- $token = $this->buildNextNode();
+ while ($nodeResult->node !== null) {
+ $nodes[] = $nodeResult->node;
+ if($nodeResult->isQueryEnd) {
+ break;
+ }
+ $nodeResult = $this->buildNextNode($isSubquery);
}
return $nodes;
}
- private function buildNextNode(): ?Node
+ private function buildNextNode(bool $isSubquery): NodeResult
{
$tokenUnderConstruction = '';
$inQuotes = false;
@@ -44,13 +65,16 @@ class QueryParser implements QueryParserInterface
// If we're in a quoted string, we treat all characters except another quote as ordinary characters
if ($inQuotes) {
- if($char !== '"') {
+ if ($char !== '"') {
$tokenUnderConstruction .= $char;
$this->position++;
continue;
} else {
$this->position++;
- return $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ return new NodeResult(
+ $this->createNode($tokenUnderConstruction, $fieldName, $prohibited),
+ false
+ );
}
}
@@ -79,7 +103,10 @@ class QueryParser implements QueryParserInterface
if ($tokenUnderConstruction === '') {
// A left parentheses at the beginning of a token indicates the start of a subquery
$this->position++;
- return new Subquery($this->parseQuery(), $prohibited);
+ return new NodeResult(
+ new Subquery($this->parseQuery(true), $prohibited),
+ false
+ );
} else {
// In any other location, it's just a normal character
$tokenUnderConstruction .= $char;
@@ -87,12 +114,20 @@ class QueryParser implements QueryParserInterface
break;
case ')':
- if ($tokenUnderConstruction !== '') {
+ // 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 $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ return new NodeResult(
+ $tokenUnderConstruction !== ''
+ ? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
+ : null,
+ true
+ );
}
- $this->position++;
- return null;
+ // In any other location, it's just a normal character
+ $tokenUnderConstruction .= $char;
+ break;
case ':':
@@ -110,7 +145,10 @@ class QueryParser implements QueryParserInterface
// A space indicates the end of a token construction if non-empty, otherwise it's just ignored
if ($tokenUnderConstruction !== '') {
$this->position++;
- return $this->createNode($tokenUnderConstruction, $fieldName, $prohibited);
+ return new NodeResult(
+ $this->createNode($tokenUnderConstruction, $fieldName, $prohibited),
+ false
+ );
}
break;
@@ -121,9 +159,9 @@ class QueryParser implements QueryParserInterface
$this->position++;
}
- return $fieldName !== '' || $tokenUnderConstruction !== ''
+ return new NodeResult($tokenUnderConstruction !== '' || $fieldName !== ''
? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
- : null;
+ : null, true);
}
private function createNode(string $token, string $fieldName, bool $prohibited): Node
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index 06e67a1713..6e44903321 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -418,4 +418,60 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertEquals('description', $field->getOperator());
$this->assertEquals('multiple spaces here', $field->getValue());
}
+
+ public function testGivenUnmatchedRightParenthesisWhenParsingQueryThenTreatsAsCharacter(): void
+ {
+ $result = $this->createParser()->parse('test)word');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Word::class, $result[0]);
+ /** @var Word $word */
+ $word = $result[0];
+ $this->assertEquals('test)word', $word->getValue());
+ }
+
+ public function testGivenUnmatchedRightParenthesisInFieldWhenParsingQueryThenTreatsAsCharacter(): void
+ {
+ $result = $this->createParser()->parse('description:test)phrase');
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(Field::class, $result[0]);
+ /** @var Field $field */
+ $field = $result[0];
+ $this->assertEquals('description', $field->getOperator());
+ $this->assertEquals('test)phrase', $field->getValue());
+ }
+
+ public function testGivenSubqueryFollowedByWordWhenParsingQueryThenReturnsCorrectNodes(): void
+ {
+ $result = $this->createParser()->parse('(amount:100 category:food) shopping');
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+
+ $this->assertInstanceOf(Subquery::class, $result[0]);
+ /** @var Subquery $subquery */
+ $subquery = $result[0];
+ $nodes = $subquery->getNodes();
+ $this->assertCount(2, $nodes);
+
+ $this->assertInstanceOf(Field::class, $nodes[0]);
+ /** @var Field $field1 */
+ $field1 = $nodes[0];
+ $this->assertEquals('amount', $field1->getOperator());
+ $this->assertEquals('100', $field1->getValue());
+
+ $this->assertInstanceOf(Field::class, $nodes[1]);
+ /** @var Field $field2 */
+ $field2 = $nodes[1];
+ $this->assertEquals('category', $field2->getOperator());
+ $this->assertEquals('food', $field2->getValue());
+
+ $this->assertInstanceOf(Word::class, $result[1]);
+ /** @var Word $word */
+ $word = $result[1];
+ $this->assertEquals('shopping', $word->getValue());
+ }
}
From 50e07d422f5dcce76c93c412b0e92d995bc2948d Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 05:13:12 +0100
Subject: [PATCH 09/27] Small QoL additions
---
app/Support/Search/QueryParser.php | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/Support/Search/QueryParser.php b/app/Support/Search/QueryParser.php
index 9005671c71..19a1c678ca 100644
--- a/app/Support/Search/QueryParser.php
+++ b/app/Support/Search/QueryParser.php
@@ -14,7 +14,7 @@ class NodeResult
{
public function __construct(
public readonly ?Node $node,
- public readonly bool $isQueryEnd
+ public readonly bool $isSubqueryEnd
) {
}
}
@@ -30,6 +30,7 @@ class QueryParser implements QueryParserInterface
private string $query;
private int $position = 0;
+ /** @return Node[] */
public function parse(string $query): array
{
$this->query = $query;
@@ -37,6 +38,7 @@ class QueryParser implements QueryParserInterface
return $this->parseQuery(false);
}
+ /** @return Node[] */
private function parseQuery(bool $isSubquery): array
{
$nodes = [];
@@ -44,7 +46,7 @@ class QueryParser implements QueryParserInterface
while ($nodeResult->node !== null) {
$nodes[] = $nodeResult->node;
- if($nodeResult->isQueryEnd) {
+ if($nodeResult->isSubqueryEnd) {
break;
}
$nodeResult = $this->buildNextNode($isSubquery);
From b3f374f4ea795d662640d5cdc547712d3cacaf92 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 05:27:13 +0100
Subject: [PATCH 10/27] Introduce assertion helpers to make tests more readable
---
...ractQueryParserInterfaceParseQueryTest.php | 359 ++++++------------
1 file changed, 108 insertions(+), 251 deletions(-)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index 6e44903321..3c44014535 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -28,6 +28,7 @@ use FireflyIII\Support\Search\Field;
use FireflyIII\Support\Search\QueryParserInterface;
use FireflyIII\Support\Search\Word;
use FireflyIII\Support\Search\Subquery;
+use FireflyIII\Support\Search\Node;
use Tests\integration\TestCase;
abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
@@ -58,12 +59,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field */
- $field = $result[0];
- $this->assertTrue($field->isProhibited());
- $this->assertEquals('amount', $field->getOperator());
- $this->assertEquals('100', $field->getValue());
+ $this->assertIsField($result[0], 'amount', '100', true);
}
public function testGivenSimpleWordWhenParsingQueryThenReturnsWordNode(): void
@@ -72,10 +68,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('groceries', $word->getValue());
+ $this->assertIsWord($result[0], 'groceries');
}
public function testGivenMultipleWordsWhenParsingQueryThenReturnsWordNodes(): void
@@ -84,21 +77,9 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(3, $result);
-
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word1 */
- $word1 = $result[0];
- $this->assertEquals('groceries', $word1->getValue());
-
- $this->assertInstanceOf(Word::class, $result[1]);
- /** @var Word $word2 */
- $word2 = $result[1];
- $this->assertEquals('shopping', $word2->getValue());
-
- $this->assertInstanceOf(Word::class, $result[2]);
- /** @var Word $word3 */
- $word3 = $result[2];
- $this->assertEquals('market', $word3->getValue());
+ $this->assertIsWord($result[0], 'groceries');
+ $this->assertIsWord($result[1], 'shopping');
+ $this->assertIsWord($result[2], 'market');
}
public function testGivenMixedWordsAndOperatorsWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -107,33 +88,18 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(3, $result);
-
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word1 */
- $word1 = $result[0];
- $this->assertEquals('groceries', $word1->getValue());
-
- $this->assertInstanceOf(Field::class, $result[1]);
- /** @var Field $field */
- $field = $result[1];
- $this->assertEquals('amount', $field->getOperator());
- $this->assertEquals('50', $field->getValue());
-
- $this->assertInstanceOf(Word::class, $result[2]);
- /** @var Word $word2 */
- $word2 = $result[2];
- $this->assertEquals('shopping', $word2->getValue());
+ $this->assertIsWord($result[0], 'groceries');
+ $this->assertIsField($result[1], 'amount', '50');
+ $this->assertIsWord($result[2], 'shopping');
}
public function testGivenQuotedValueWithSpacesWhenParsingQueryThenReturnsFieldNode(): void
{
$result = $this->createParser()->parse('description_contains:"shopping at market"');
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field */
- $field = $result[0];
- $this->assertEquals('description_contains', $field->getOperator());
- $this->assertEquals('shopping at market', $field->getValue());
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertIsField($result[0], 'description_contains', 'shopping at market');
}
public function testGivenSubqueryAfterFieldValueWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -142,30 +108,13 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(2, $result);
+ $this->assertIsField($result[0], 'amount', '100');
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field */
- $field = $result[0];
- $this->assertEquals('amount', $field->getOperator());
- $this->assertEquals('100', $field->getValue());
-
- $this->assertInstanceOf(Subquery::class, $result[1]);
- /** @var Subquery $subquery */
- $subquery = $result[1];
- $nodes = $subquery->getNodes();
- $this->assertCount(2, $nodes);
-
- $this->assertInstanceOf(Field::class, $nodes[0]);
- /** @var Field $field1 */
- $field1 = $nodes[0];
- $this->assertEquals('description', $field1->getOperator());
- $this->assertEquals('market', $field1->getValue());
-
- $this->assertInstanceOf(Field::class, $nodes[1]);
- /** @var Field $field2 */
- $field2 = $nodes[1];
- $this->assertEquals('category', $field2->getOperator());
- $this->assertEquals('food', $field2->getValue());
+ $expectedNodes = [
+ new Field('description', 'market'),
+ new Field('category', 'food')
+ ];
+ $this->assertIsSubquery($result[1], $expectedNodes);
}
public function testGivenWordFollowedBySubqueryWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -175,28 +124,13 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(2, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('groceries', $word->getValue());
+ $this->assertIsWord($result[0], 'groceries');
- $this->assertInstanceOf(Subquery::class, $result[1]);
- /** @var Subquery $subquery */
- $subquery = $result[1];
- $nodes = $subquery->getNodes();
- $this->assertCount(2, $nodes);
-
- $this->assertInstanceOf(Field::class, $nodes[0]);
- /** @var Field $field1 */
- $field1 = $nodes[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('100', $field1->getValue());
-
- $this->assertInstanceOf(Field::class, $nodes[1]);
- /** @var Field $field2 */
- $field2 = $nodes[1];
- $this->assertEquals('description_contains', $field2->getOperator());
- $this->assertEquals('test', $field2->getValue());
+ $expectedNodes = [
+ new Field('amount', '100'),
+ new Field('description_contains', 'test')
+ ];
+ $this->assertIsSubquery($result[1], $expectedNodes);
}
public function testGivenNestedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
@@ -205,36 +139,16 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Subquery::class, $result[0]);
- /** @var Subquery $outerSubquery */
- $outerSubquery = $result[0];
- $nodes = $outerSubquery->getNodes();
- $this->assertCount(2, $nodes);
- $this->assertInstanceOf(Field::class, $nodes[0]);
- /** @var Field $field1 */
- $field1 = $nodes[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('100', $field1->getValue());
-
- $this->assertInstanceOf(Subquery::class, $nodes[1]);
- /** @var Subquery $innerSubquery */
- $innerSubquery = $nodes[1];
- $subNodes = $innerSubquery->getNodes();
- $this->assertCount(2, $subNodes);
-
- $this->assertInstanceOf(Field::class, $subNodes[0]);
- /** @var Field $field2 */
- $field2 = $subNodes[0];
- $this->assertEquals('description_contains', $field2->getOperator());
- $this->assertEquals('test payment', $field2->getValue());
-
- $this->assertInstanceOf(Field::class, $subNodes[1]);
- /** @var Field $field3 */
- $field3 = $subNodes[1];
- $this->assertTrue($field3->isProhibited());
- $this->assertEquals('has_attachments', $field3->getOperator());
- $this->assertEquals('true', $field3->getValue());
+ $innerSubqueryNodes = [
+ new Field('description_contains', 'test payment'),
+ new Field('has_attachments', 'true', true)
+ ];
+ $outerSubqueryNodes = [
+ new Field('amount', '100'),
+ new Subquery($innerSubqueryNodes)
+ ];
+ $this->assertIsSubquery($result[0], $outerSubqueryNodes);
}
public function testGivenComplexNestedSubqueriesWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -244,63 +158,22 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(2, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('shopping', $word->getValue());
+ $this->assertIsWord($result[0], 'shopping');
- $this->assertInstanceOf(Subquery::class, $result[1]);
- /** @var Subquery $firstLevelSubquery */
- $firstLevelSubquery = $result[1];
- $level1Nodes = $firstLevelSubquery->getNodes();
- $this->assertCount(3, $level1Nodes);
+ $expectedLevel2 = [
+ new Field('category', 'food', true),
+ new Word('word'),
+ new Field('description', 'test phrase'),
+ new Subquery([new Field('has_notes', 'true')])
+ ];
- $this->assertInstanceOf(Field::class, $level1Nodes[0]);
- /** @var Field $field1 */
- $field1 = $level1Nodes[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('50', $field1->getValue());
+ $expectedLevel1 = [
+ new Field('amount', '50'),
+ new Word('market'),
+ new Subquery($expectedLevel2)
+ ];
- $this->assertInstanceOf(Word::class, $level1Nodes[1]);
- /** @var Word $word2 */
- $word2 = $level1Nodes[1];
- $this->assertEquals('market', $word2->getValue());
-
- $this->assertInstanceOf(Subquery::class, $level1Nodes[2]);
- /** @var Subquery $secondLevelSubquery */
- $secondLevelSubquery = $level1Nodes[2];
- $level2Nodes = $secondLevelSubquery->getNodes();
- $this->assertCount(4, $level2Nodes);
-
- $this->assertInstanceOf(Field::class, $level2Nodes[0]);
- /** @var Field $field2 */
- $field2 = $level2Nodes[0];
- $this->assertTrue($field2->isProhibited());
- $this->assertEquals('category', $field2->getOperator());
- $this->assertEquals('food', $field2->getValue());
-
- $this->assertInstanceOf(Word::class, $level2Nodes[1]);
- /** @var Word $word3 */
- $word3 = $level2Nodes[1];
- $this->assertEquals('word', $word3->getValue());
-
- $this->assertInstanceOf(Field::class, $level2Nodes[2]);
- /** @var Field $field3 */
- $field3 = $level2Nodes[2];
- $this->assertEquals('description', $field3->getOperator());
- $this->assertEquals('test phrase', $field3->getValue());
-
- $this->assertInstanceOf(Subquery::class, $level2Nodes[3]);
- /** @var Subquery $thirdLevelSubquery */
- $thirdLevelSubquery = $level2Nodes[3];
- $level3Nodes = $thirdLevelSubquery->getNodes();
- $this->assertCount(1, $level3Nodes);
-
- $this->assertInstanceOf(Field::class, $level3Nodes[0]);
- /** @var Field $field4 */
- $field4 = $level3Nodes[0];
- $this->assertEquals('has_notes', $field4->getOperator());
- $this->assertEquals('true', $field4->getValue());
+ $this->assertIsSubquery($result[1], $expectedLevel1);
}
public function testGivenProhibitedWordWhenParsingQueryThenReturnsWordNode(): void
@@ -334,11 +207,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertTrue($word->isProhibited());
- $this->assertEquals('test phrase', $word->getValue());
+ $this->assertIsWord($result[0], 'test phrase', true);
}
public function testGivenMultipleFieldsWhenParsingQueryThenReturnsFieldNodes(): void
@@ -347,24 +216,9 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(3, $result);
-
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field1 */
- $field1 = $result[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('100', $field1->getValue());
-
- $this->assertInstanceOf(Field::class, $result[1]);
- /** @var Field $field2 */
- $field2 = $result[1];
- $this->assertEquals('category', $field2->getOperator());
- $this->assertEquals('food', $field2->getValue());
-
- $this->assertInstanceOf(Field::class, $result[2]);
- /** @var Field $field3 */
- $field3 = $result[2];
- $this->assertEquals('description', $field3->getOperator());
- $this->assertEquals('test phrase', $field3->getValue());
+ $this->assertIsField($result[0], 'amount', '100');
+ $this->assertIsField($result[1], 'category', 'food');
+ $this->assertIsField($result[2], 'description', 'test phrase');
}
public function testGivenProhibitedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
@@ -373,25 +227,12 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Subquery::class, $result[0]);
- /** @var Subquery $subquery */
- $subquery = $result[0];
- $this->assertTrue($subquery->isProhibited());
- $nodes = $subquery->getNodes();
- $this->assertCount(2, $nodes);
-
- $this->assertInstanceOf(Field::class, $nodes[0]);
- /** @var Field $field1 */
- $field1 = $nodes[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('100', $field1->getValue());
-
- $this->assertInstanceOf(Field::class, $nodes[1]);
- /** @var Field $field2 */
- $field2 = $nodes[1];
- $this->assertEquals('category', $field2->getOperator());
- $this->assertEquals('food', $field2->getValue());
+ $expectedNodes = [
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ];
+ $this->assertIsSubquery($result[0], $expectedNodes, true);
}
public function testGivenWordWithMultipleSpacesWhenParsingQueryThenRetainsSpaces(): void
@@ -400,10 +241,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('multiple spaces', $word->getValue());
+ $this->assertIsWord($result[0], 'multiple spaces');
}
public function testGivenFieldWithMultipleSpacesInValueWhenParsingQueryThenRetainsSpaces(): void
@@ -412,11 +250,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field */
- $field = $result[0];
- $this->assertEquals('description', $field->getOperator());
- $this->assertEquals('multiple spaces here', $field->getValue());
+ $this->assertIsField($result[0], 'description', 'multiple spaces here');
}
public function testGivenUnmatchedRightParenthesisWhenParsingQueryThenTreatsAsCharacter(): void
@@ -425,10 +259,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('test)word', $word->getValue());
+ $this->assertIsWord($result[0], 'test)word');
}
public function testGivenUnmatchedRightParenthesisInFieldWhenParsingQueryThenTreatsAsCharacter(): void
@@ -437,11 +268,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(Field::class, $result[0]);
- /** @var Field $field */
- $field = $result[0];
- $this->assertEquals('description', $field->getOperator());
- $this->assertEquals('test)phrase', $field->getValue());
+ $this->assertIsField($result[0], 'description', 'test)phrase');
}
public function testGivenSubqueryFollowedByWordWhenParsingQueryThenReturnsCorrectNodes(): void
@@ -451,27 +278,57 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertIsArray($result);
$this->assertCount(2, $result);
- $this->assertInstanceOf(Subquery::class, $result[0]);
- /** @var Subquery $subquery */
- $subquery = $result[0];
- $nodes = $subquery->getNodes();
- $this->assertCount(2, $nodes);
+ $expectedNodes = [
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ];
+ $this->assertIsSubquery($result[0], $expectedNodes);
+ $this->assertIsWord($result[1], 'shopping');
+ }
- $this->assertInstanceOf(Field::class, $nodes[0]);
- /** @var Field $field1 */
- $field1 = $nodes[0];
- $this->assertEquals('amount', $field1->getOperator());
- $this->assertEquals('100', $field1->getValue());
+ private function assertIsWord(Node $node, string $expectedValue, bool $prohibited = false): void
+ {
+ $this->assertInstanceOf(Word::class, $node);
+ /** @var Word $node */
+ $this->assertEquals($expectedValue, $node->getValue());
+ if ($prohibited) {
+ $this->assertTrue($node->isProhibited());
+ }
+ }
- $this->assertInstanceOf(Field::class, $nodes[1]);
- /** @var Field $field2 */
- $field2 = $nodes[1];
- $this->assertEquals('category', $field2->getOperator());
- $this->assertEquals('food', $field2->getValue());
+ private function assertIsField(
+ Node $node,
+ string $expectedOperator,
+ string $expectedValue,
+ bool $prohibited = false
+ ): void {
+ $this->assertInstanceOf(Field::class, $node);
+ /** @var Field $node */
+ $this->assertEquals($expectedOperator, $node->getOperator());
+ $this->assertEquals($expectedValue, $node->getValue());
+ if ($prohibited) {
+ $this->assertTrue($node->isProhibited());
+ }
+ }
- $this->assertInstanceOf(Word::class, $result[1]);
- /** @var Word $word */
- $word = $result[1];
- $this->assertEquals('shopping', $word->getValue());
+ private function assertIsSubquery(Node $node, array $expectedNodes, bool $prohibited = false): void
+ {
+ $this->assertInstanceOf(Subquery::class, $node);
+ /** @var Subquery $node */
+ $this->assertCount(count($expectedNodes), $node->getNodes());
+ if ($prohibited) {
+ $this->assertTrue($node->isProhibited());
+ }
+
+ foreach ($expectedNodes as $index => $expected) {
+ $actual = $node->getNodes()[$index];
+ if ($expected instanceof Word) {
+ $this->assertIsWord($actual, $expected->getValue(), $expected->isProhibited());
+ } elseif ($expected instanceof Field) {
+ $this->assertIsField($actual, $expected->getOperator(), $expected->getValue(), $expected->isProhibited());
+ } elseif ($expected instanceof Subquery) {
+ $this->assertIsSubquery($actual, $expected->getNodes(), $expected->isProhibited());
+ }
+ }
}
}
From 9d9dffee746880ce1191393eedc1d2a589a41e78 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 05:31:25 +0100
Subject: [PATCH 11/27] Always assert prohibited field
---
.../AbstractQueryParserInterfaceParseQueryTest.php | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index 3c44014535..ee71c819e4 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -291,9 +291,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertInstanceOf(Word::class, $node);
/** @var Word $node */
$this->assertEquals($expectedValue, $node->getValue());
- if ($prohibited) {
- $this->assertTrue($node->isProhibited());
- }
+ $this->assertEquals($prohibited, $node->isProhibited());
}
private function assertIsField(
@@ -306,9 +304,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
/** @var Field $node */
$this->assertEquals($expectedOperator, $node->getOperator());
$this->assertEquals($expectedValue, $node->getValue());
- if ($prohibited) {
- $this->assertTrue($node->isProhibited());
- }
+ $this->assertEquals($prohibited, $node->isProhibited());
}
private function assertIsSubquery(Node $node, array $expectedNodes, bool $prohibited = false): void
@@ -316,9 +312,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertInstanceOf(Subquery::class, $node);
/** @var Subquery $node */
$this->assertCount(count($expectedNodes), $node->getNodes());
- if ($prohibited) {
- $this->assertTrue($node->isProhibited());
- }
+ $this->assertEquals($prohibited, $node->isProhibited());
foreach ($expectedNodes as $index => $expected) {
$actual = $node->getNodes()[$index];
From 9729434926d175b0bf7e291b85c1baef1fcd8c88 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 05:36:22 +0100
Subject: [PATCH 12/27] Allow choosing QueryParser implementation
---
app/Providers/SearchServiceProvider.php | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php
index 627d9f0820..dccd90ddec 100644
--- a/app/Providers/SearchServiceProvider.php
+++ b/app/Providers/SearchServiceProvider.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace FireflyIII\Providers;
+use FireflyIII\Support\Search\GdbotsQueryParser;
use FireflyIII\Support\Search\OperatorQuerySearch;
use FireflyIII\Support\Search\QueryParser;
use FireflyIII\Support\Search\QueryParserInterface;
@@ -47,10 +48,13 @@ class SearchServiceProvider extends ServiceProvider
{
$this->app->bind(
QueryParserInterface::class,
- static function (Application $app) {
- /** @var QueryParser $queryParser */
- $queryParser = app(QueryParser::class);
- return $queryParser;
+ static function () {
+ $implementation = env('QUERY_PARSER_IMPLEMENTATION', 'default');
+
+ return match($implementation) {
+ 'new' => app(QueryParser::class),
+ default => app(GdbotsQueryParser::class),
+ };
}
);
From 57b064f590a84a72d6400042037f92d4c8df1a0a Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 14:18:19 +0100
Subject: [PATCH 13/27] Remove incorrect LICENSE message at top of file
---
...ractQueryParserInterfaceParseQueryTest.php | 20 -------------------
1 file changed, 20 deletions(-)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
index ee71c819e4..47efe3a6ed 100644
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
@@ -1,25 +1,5 @@
- *
- * This file is part of Firefly III (https://github.com/firefly-iii).
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
declare(strict_types=1);
namespace Tests\unit\Support\Search;
From 92f5cca65b8d71edeed39702b6b27ab1b50137e7 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Wed, 1 Jan 2025 14:18:56 +0100
Subject: [PATCH 14/27] Add QueryParser tests to correct group
---
tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php | 2 +-
tests/unit/Support/Search/QueryParserParseQueryTest.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
index 897d9ecb01..8f00f7a65d 100644
--- a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
@@ -9,7 +9,7 @@ use Tests\unit\Support\Search\AbstractQueryParserInterfaceParseQueryTest;
/**
* @group unit-test
* @group support
- * @group navigation
+ * @group search
*
* @internal
*
diff --git a/tests/unit/Support/Search/QueryParserParseQueryTest.php b/tests/unit/Support/Search/QueryParserParseQueryTest.php
index a5c4e85963..c85131b586 100644
--- a/tests/unit/Support/Search/QueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParserParseQueryTest.php
@@ -10,7 +10,7 @@ use Tests\unit\Support\Search\AbstractQueryParserInterfaceParseQueryTest;
/**
* @group unit-test
* @group support
- * @group navigation
+ * @group search
*
* @internal
*
From c44b827922e5843ff1351dfee0b1f9f2dd3abe3e Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 20:03:39 +0100
Subject: [PATCH 15/27] Fix environment-based selection of Query Parser
---
app/Providers/SearchServiceProvider.php | 4 ++--
config/search.php | 4 ++++
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php
index dccd90ddec..944348218d 100644
--- a/app/Providers/SearchServiceProvider.php
+++ b/app/Providers/SearchServiceProvider.php
@@ -48,8 +48,8 @@ class SearchServiceProvider extends ServiceProvider
{
$this->app->bind(
QueryParserInterface::class,
- static function () {
- $implementation = env('QUERY_PARSER_IMPLEMENTATION', 'default');
+ static function (): GdbotsQueryParser|QueryParser {
+ $implementation = config('search.query_parser');
return match($implementation) {
'new' => app(QueryParser::class),
diff --git a/config/search.php b/config/search.php
index 304cd080a0..249fabd363 100644
--- a/config/search.php
+++ b/config/search.php
@@ -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'),
];
From 78f9f7e2dd5df5b069c0b69d22059ac87992c8ed Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 20:06:11 +0100
Subject: [PATCH 16/27] Add QUERY_PARSER_IMPLEMENTATION in .env.example
---
.env.example | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.env.example b/.env.example
index e2e0b18720..358fec2d52 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
From afc9ea08f379868e7f037938d2fb4a205e3edcba Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 22:17:56 +0100
Subject: [PATCH 17/27] Rearrange structure
---
app/Support/Search/OperatorQuerySearch.php | 5 +
app/Support/Search/QueryParser/Field.php | 36 +++++
.../{ => QueryParser}/GdbotsQueryParser.php | 2 +-
app/Support/Search/QueryParser/Node.php | 20 +++
.../Search/{ => QueryParser}/QueryParser.php | 2 +-
.../QueryParser/QueryParserInterface.php | 13 ++
app/Support/Search/QueryParser/Subquery.php | 38 ++++++
app/Support/Search/QueryParser/Word.php | 29 ++++
app/Support/Search/QueryParserInterface.php | 127 ------------------
9 files changed, 143 insertions(+), 129 deletions(-)
create mode 100644 app/Support/Search/QueryParser/Field.php
rename app/Support/Search/{ => QueryParser}/GdbotsQueryParser.php (98%)
create mode 100644 app/Support/Search/QueryParser/Node.php
rename app/Support/Search/{ => QueryParser}/QueryParser.php (99%)
create mode 100644 app/Support/Search/QueryParser/QueryParserInterface.php
create mode 100644 app/Support/Search/QueryParser/Subquery.php
create mode 100644 app/Support/Search/QueryParser/Word.php
delete mode 100644 app/Support/Search/QueryParserInterface.php
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index 596aedd7d0..4e9408a3a8 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -39,6 +39,11 @@ 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\Field;
+use FireflyIII\Support\Search\QueryParser\Word;
+use FireflyIII\Support\Search\QueryParser\Subquery;
use FireflyIII\Support\ParseDateString;
use FireflyIII\User;
use Illuminate\Pagination\LengthAwarePaginator;
diff --git a/app/Support/Search/QueryParser/Field.php b/app/Support/Search/QueryParser/Field.php
new file mode 100644
index 0000000000..3028af9730
--- /dev/null
+++ b/app/Support/Search/QueryParser/Field.php
@@ -0,0 +1,36 @@
+operator = $operator;
+ $this->value = $value;
+ $this->prohibited = $prohibited;
+ }
+
+ public function getOperator(): string
+ {
+ return $this->operator;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+
+ public function __toString(): string
+ {
+ return ($this->prohibited ? '-' : '') . $this->operator . ':' . $this->value;
+ }
+}
diff --git a/app/Support/Search/GdbotsQueryParser.php b/app/Support/Search/QueryParser/GdbotsQueryParser.php
similarity index 98%
rename from app/Support/Search/GdbotsQueryParser.php
rename to app/Support/Search/QueryParser/GdbotsQueryParser.php
index c8249c455a..8a56d0f6de 100644
--- a/app/Support/Search/GdbotsQueryParser.php
+++ b/app/Support/Search/QueryParser/GdbotsQueryParser.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace FireflyIII\Support\Search;
+namespace FireflyIII\Support\Search\QueryParser;
use FireflyIII\Exceptions\FireflyException;
use Gdbots\QueryParser\QueryParser as BaseQueryParser;
diff --git a/app/Support/Search/QueryParser/Node.php b/app/Support/Search/QueryParser/Node.php
new file mode 100644
index 0000000000..7dced2e759
--- /dev/null
+++ b/app/Support/Search/QueryParser/Node.php
@@ -0,0 +1,20 @@
+prohibited;
+ }
+}
diff --git a/app/Support/Search/QueryParser.php b/app/Support/Search/QueryParser/QueryParser.php
similarity index 99%
rename from app/Support/Search/QueryParser.php
rename to app/Support/Search/QueryParser/QueryParser.php
index 19a1c678ca..1d7f0658c9 100644
--- a/app/Support/Search/QueryParser.php
+++ b/app/Support/Search/QueryParser/QueryParser.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace FireflyIII\Support\Search;
+namespace FireflyIII\Support\Search\QueryParser;
/**
* Represents a result from parsing a query node
diff --git a/app/Support/Search/QueryParser/QueryParserInterface.php b/app/Support/Search/QueryParser/QueryParserInterface.php
new file mode 100644
index 0000000000..831baf1270
--- /dev/null
+++ b/app/Support/Search/QueryParser/QueryParserInterface.php
@@ -0,0 +1,13 @@
+nodes = $nodes;
+ $this->prohibited = $prohibited;
+ }
+
+ /**
+ * @return Node[]
+ */
+ public function getNodes(): array
+ {
+ return $this->nodes;
+ }
+
+
+ public function __toString(): string
+ {
+ return ($this->prohibited ? '-' : '') . '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
+ }
+}
diff --git a/app/Support/Search/QueryParser/Word.php b/app/Support/Search/QueryParser/Word.php
new file mode 100644
index 0000000000..d7c12b6976
--- /dev/null
+++ b/app/Support/Search/QueryParser/Word.php
@@ -0,0 +1,29 @@
+value = $value;
+ $this->prohibited = $prohibited;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+
+ public function __toString(): string
+ {
+ return ($this->prohibited ? '-' : '') . $this->value;
+ }
+}
diff --git a/app/Support/Search/QueryParserInterface.php b/app/Support/Search/QueryParserInterface.php
deleted file mode 100644
index 93f88d1e75..0000000000
--- a/app/Support/Search/QueryParserInterface.php
+++ /dev/null
@@ -1,127 +0,0 @@
-value = $value;
- $this->prohibited = $prohibited;
- }
-
- public function getValue(): string
- {
- return $this->value;
- }
-
- public function isProhibited(): bool
- {
- return $this->prohibited;
- }
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . $this->value;
- }
-}
-
-/**
- * Represents a field operator with value (e.g. amount:100)
- */
-class Field extends Node
-{
- private string $operator;
- private string $value;
- private bool $prohibited;
-
- 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;
- }
-
- public function isProhibited(): bool
- {
- return $this->prohibited;
- }
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . $this->operator . ':' . $this->value;
- }
-}
-
-/**
- * Represents a subquery (group of nodes)
- */
-class Subquery extends Node
-{
- /** @var Node[] */
- private array $nodes;
-
- private bool $prohibited;
-
- /**
- * @param Node[] $nodes
- */
- public function __construct(array $nodes, bool $prohibited = false)
- {
- $this->nodes = $nodes;
- $this->prohibited = $prohibited;
- }
-
- /**
- * @return Node[]
- */
- public function getNodes(): array
- {
- return $this->nodes;
- }
-
- public function isProhibited(): bool
- {
- return $this->prohibited;
- }
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
- }
-}
From ebd30f48614858a150825947012f628ee0d064aa Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 22:18:12 +0100
Subject: [PATCH 18/27] Move tests to match rearranged structure + standardize
tests
---
...ractQueryParserInterfaceParseQueryTest.php | 308 ------------------
...ractQueryParserInterfaceParseQueryTest.php | 278 ++++++++++++++++
.../GdbotsQueryParserParseQueryTest.php | 7 +-
.../QueryParserParseQueryTest.php | 7 +-
4 files changed, 284 insertions(+), 316 deletions(-)
delete mode 100644 tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
create mode 100644 tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
rename tests/unit/Support/Search/{ => QueryParser}/GdbotsQueryParserParseQueryTest.php (60%)
rename tests/unit/Support/Search/{ => QueryParser}/QueryParserParseQueryTest.php (60%)
diff --git a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
deleted file mode 100644
index 47efe3a6ed..0000000000
--- a/tests/unit/Support/Search/AbstractQueryParserInterfaceParseQueryTest.php
+++ /dev/null
@@ -1,308 +0,0 @@
-createParser()->parse('');
-
- $this->assertIsArray($result);
- $this->assertEmpty($result);
- }
-
- public function testGivenProhibitedFieldOperatorWhenParsingQueryThenReturnsFieldNode(): void
- {
- $result = $this->createParser()->parse('-amount:100');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsField($result[0], 'amount', '100', true);
- }
-
- public function testGivenSimpleWordWhenParsingQueryThenReturnsWordNode(): void
- {
- $result = $this->createParser()->parse('groceries');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsWord($result[0], 'groceries');
- }
-
- public function testGivenMultipleWordsWhenParsingQueryThenReturnsWordNodes(): void
- {
- $result = $this->createParser()->parse('groceries shopping market');
-
- $this->assertIsArray($result);
- $this->assertCount(3, $result);
- $this->assertIsWord($result[0], 'groceries');
- $this->assertIsWord($result[1], 'shopping');
- $this->assertIsWord($result[2], 'market');
- }
-
- public function testGivenMixedWordsAndOperatorsWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('groceries amount:50 shopping');
-
- $this->assertIsArray($result);
- $this->assertCount(3, $result);
- $this->assertIsWord($result[0], 'groceries');
- $this->assertIsField($result[1], 'amount', '50');
- $this->assertIsWord($result[2], 'shopping');
- }
-
- public function testGivenQuotedValueWithSpacesWhenParsingQueryThenReturnsFieldNode(): void
- {
- $result = $this->createParser()->parse('description_contains:"shopping at market"');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsField($result[0], 'description_contains', 'shopping at market');
- }
-
- public function testGivenSubqueryAfterFieldValueWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('amount:100 (description:"market" category:food)');
-
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
- $this->assertIsField($result[0], 'amount', '100');
-
- $expectedNodes = [
- new Field('description', 'market'),
- new Field('category', 'food')
- ];
- $this->assertIsSubquery($result[1], $expectedNodes);
- }
-
- public function testGivenWordFollowedBySubqueryWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('groceries (amount:100 description_contains:"test")');
-
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
-
- $this->assertIsWord($result[0], 'groceries');
-
- $expectedNodes = [
- new Field('amount', '100'),
- new Field('description_contains', 'test')
- ];
- $this->assertIsSubquery($result[1], $expectedNodes);
- }
-
- public function testGivenNestedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
- {
- $result = $this->createParser()->parse('(amount:100 (description_contains:"test payment" -has_attachments:true))');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
-
- $innerSubqueryNodes = [
- new Field('description_contains', 'test payment'),
- new Field('has_attachments', 'true', true)
- ];
- $outerSubqueryNodes = [
- new Field('amount', '100'),
- new Subquery($innerSubqueryNodes)
- ];
- $this->assertIsSubquery($result[0], $outerSubqueryNodes);
- }
-
- public function testGivenComplexNestedSubqueriesWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('shopping (amount:50 market (-category:food word description:"test phrase" (has_notes:true)))');
-
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
-
- $this->assertIsWord($result[0], 'shopping');
-
- $expectedLevel2 = [
- new Field('category', 'food', true),
- new Word('word'),
- new Field('description', 'test phrase'),
- new Subquery([new Field('has_notes', 'true')])
- ];
-
- $expectedLevel1 = [
- new Field('amount', '50'),
- new Word('market'),
- new Subquery($expectedLevel2)
- ];
-
- $this->assertIsSubquery($result[1], $expectedLevel1);
- }
-
- public function testGivenProhibitedWordWhenParsingQueryThenReturnsWordNode(): void
- {
- $result = $this->createParser()->parse('-groceries');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertTrue($word->isProhibited());
- $this->assertEquals('groceries', $word->getValue());
- }
-
- public function testGivenQuotedWordWhenParsingQueryThenReturnsWordNode(): void
- {
- $result = $this->createParser()->parse('"test phrase"');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertInstanceOf(Word::class, $result[0]);
- /** @var Word $word */
- $word = $result[0];
- $this->assertEquals('test phrase', $word->getValue());
- }
-
- public function testGivenProhibitedQuotedWordWhenParsingQueryThenReturnsWordNode(): void
- {
- $result = $this->createParser()->parse('-"test phrase"');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsWord($result[0], 'test phrase', true);
- }
-
- public function testGivenMultipleFieldsWhenParsingQueryThenReturnsFieldNodes(): void
- {
- $result = $this->createParser()->parse('amount:100 category:food description:"test phrase"');
-
- $this->assertIsArray($result);
- $this->assertCount(3, $result);
- $this->assertIsField($result[0], 'amount', '100');
- $this->assertIsField($result[1], 'category', 'food');
- $this->assertIsField($result[2], 'description', 'test phrase');
- }
-
- public function testGivenProhibitedSubqueryWhenParsingQueryThenReturnsSubqueryNode(): void
- {
- $result = $this->createParser()->parse('-(amount:100 category:food)');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
-
- $expectedNodes = [
- new Field('amount', '100'),
- new Field('category', 'food')
- ];
- $this->assertIsSubquery($result[0], $expectedNodes, true);
- }
-
- public function testGivenWordWithMultipleSpacesWhenParsingQueryThenRetainsSpaces(): void
- {
- $result = $this->createParser()->parse('"multiple spaces"');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsWord($result[0], 'multiple spaces');
- }
-
- public function testGivenFieldWithMultipleSpacesInValueWhenParsingQueryThenRetainsSpaces(): void
- {
- $result = $this->createParser()->parse('description:"multiple spaces here"');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsField($result[0], 'description', 'multiple spaces here');
- }
-
- public function testGivenUnmatchedRightParenthesisWhenParsingQueryThenTreatsAsCharacter(): void
- {
- $result = $this->createParser()->parse('test)word');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsWord($result[0], 'test)word');
- }
-
- public function testGivenUnmatchedRightParenthesisInFieldWhenParsingQueryThenTreatsAsCharacter(): void
- {
- $result = $this->createParser()->parse('description:test)phrase');
-
- $this->assertIsArray($result);
- $this->assertCount(1, $result);
- $this->assertIsField($result[0], 'description', 'test)phrase');
- }
-
- public function testGivenSubqueryFollowedByWordWhenParsingQueryThenReturnsCorrectNodes(): void
- {
- $result = $this->createParser()->parse('(amount:100 category:food) shopping');
-
- $this->assertIsArray($result);
- $this->assertCount(2, $result);
-
- $expectedNodes = [
- new Field('amount', '100'),
- new Field('category', 'food')
- ];
- $this->assertIsSubquery($result[0], $expectedNodes);
- $this->assertIsWord($result[1], 'shopping');
- }
-
- private function assertIsWord(Node $node, string $expectedValue, bool $prohibited = false): void
- {
- $this->assertInstanceOf(Word::class, $node);
- /** @var Word $node */
- $this->assertEquals($expectedValue, $node->getValue());
- $this->assertEquals($prohibited, $node->isProhibited());
- }
-
- private function assertIsField(
- Node $node,
- string $expectedOperator,
- string $expectedValue,
- bool $prohibited = false
- ): void {
- $this->assertInstanceOf(Field::class, $node);
- /** @var Field $node */
- $this->assertEquals($expectedOperator, $node->getOperator());
- $this->assertEquals($expectedValue, $node->getValue());
- $this->assertEquals($prohibited, $node->isProhibited());
- }
-
- private function assertIsSubquery(Node $node, array $expectedNodes, bool $prohibited = false): void
- {
- $this->assertInstanceOf(Subquery::class, $node);
- /** @var Subquery $node */
- $this->assertCount(count($expectedNodes), $node->getNodes());
- $this->assertEquals($prohibited, $node->isProhibited());
-
- foreach ($expectedNodes as $index => $expected) {
- $actual = $node->getNodes()[$index];
- if ($expected instanceof Word) {
- $this->assertIsWord($actual, $expected->getValue(), $expected->isProhibited());
- } elseif ($expected instanceof Field) {
- $this->assertIsField($actual, $expected->getOperator(), $expected->getValue(), $expected->isProhibited());
- } elseif ($expected instanceof Subquery) {
- $this->assertIsSubquery($actual, $expected->getNodes(), $expected->isProhibited());
- }
- }
- }
-}
diff --git a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
new file mode 100644
index 0000000000..3d2a5426dc
--- /dev/null
+++ b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
@@ -0,0 +1,278 @@
+ [
+ 'query' => '',
+ 'expected' => []
+ ],
+ 'simple word' => [
+ 'query' => 'grocaeries',
+ 'expected' => [new Word('groceries')]
+ ],
+ 'prohibited word' => [
+ 'query' => '-groceries',
+ 'expected' => [new Word('groceries', true)]
+ ],
+ 'prohibited field' => [
+ 'query' => '-amount:100',
+ 'expected' => [new Field('amount', '100', true)]
+ ],
+ 'quoted word' => [
+ 'query' => '"test phrase"',
+ 'expected' => [new Word('test phrase')]
+ ],
+ 'prohibited quoted word' => [
+ 'query' => '-"test phrase"',
+ 'expected' => [new Word('test phrase', true)]
+ ],
+ 'multiple words' => [
+ 'query' => 'groceries shopping market',
+ 'expected' => [
+ new Word('groceries'),
+ new Word('shopping'),
+ new Word('market')
+ ]
+ ],
+ 'field operator' => [
+ 'query' => 'amount:100',
+ 'expected' => [new Field('amount', '100')]
+ ],
+ 'quoted field value with single space' => [
+ 'query' => 'description:"test phrase"',
+ 'expected' => [new Field('description', 'test phrase')]
+ ],
+ 'multiple fields' => [
+ 'query' => 'amount:100 category:food',
+ 'expected' => [
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ]
+ ],
+ 'simple subquery' => [
+ 'query' => '(amount:100 category:food)',
+ 'expected' => [
+ new Subquery([
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ])
+ ]
+ ],
+ 'prohibited subquery' => [
+ 'query' => '-(amount:100 category:food)',
+ 'expected' => [
+ new Subquery([
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ], true)
+ ]
+ ],
+ 'nested subquery' => [
+ 'query' => '(amount:100 (description:"test" category:food))',
+ 'expected' => [
+ new Subquery([
+ new Field('amount', '100'),
+ new Subquery([
+ new Field('description', 'test'),
+ new Field('category', 'food')
+ ])
+ ])
+ ]
+ ],
+ 'mixed words and operators' => [
+ 'query' => 'groceries amount:50 shopping',
+ 'expected' => [
+ new Word('groceries'),
+ new Field('amount', '50'),
+ new Word('shopping')
+ ]
+ ],
+ 'subquery after field value' => [
+ 'query' => 'amount:100 (description:"market" category:food)',
+ 'expected' => [
+ new Field('amount', '100'),
+ new Subquery([
+ new Field('description', 'market'),
+ new Field('category', 'food')
+ ])
+ ]
+ ],
+ 'word followed by subquery' => [
+ 'query' => 'groceries (amount:100 description_contains:"test")',
+ 'expected' => [
+ new Word('groceries'),
+ new Subquery([
+ new Field('amount', '100'),
+ new Field('description_contains', 'test')
+ ])
+ ]
+ ],
+ 'nested subquery with prohibited field' => [
+ 'query' => '(amount:100 (description_contains:"test payment" -has_attachments:true))',
+ 'expected' => [
+ new Subquery([
+ new Field('amount', '100'),
+ new Subquery([
+ new Field('description_contains', 'test payment'),
+ new Field('has_attachments', 'true', true)
+ ])
+ ])
+ ]
+ ],
+ 'complex nested subqueries' => [
+ 'query' => 'shopping (amount:50 market (-category:food word description:"test phrase" (has_notes:true)))',
+ 'expected' => [
+ new Word('shopping'),
+ new Subquery([
+ new Field('amount', '50'),
+ new Word('market'),
+ new Subquery([
+ new Field('category', 'food', true),
+ new Word('word'),
+ new Field('description', 'test phrase'),
+ new Subquery([
+ new Field('has_notes', 'true')
+ ])
+ ])
+ ])
+ ]
+ ],
+ 'word with multiple spaces' => [
+ 'query' => '"multiple spaces"',
+ 'expected' => [new Word('multiple spaces')]
+ ],
+ 'field with multiple spaces in value' => [
+ 'query' => 'description:"multiple spaces here"',
+ 'expected' => [new Field('description', 'multiple spaces here')]
+ ],
+ 'unmatched right parenthesis in word' => [
+ 'query' => 'test)word',
+ 'expected' => [new Word('test)word')]
+ ],
+ 'unmatched right parenthesis in field' => [
+ 'query' => 'description:test)phrase',
+ 'expected' => [new Field('description', 'test)phrase')]
+ ],
+ 'subquery followed by word' => [
+ 'query' => '(amount:100 category:food) shopping',
+ 'expected' => [
+ new Subquery([
+ new Field('amount', '100'),
+ new Field('category', 'food')
+ ]),
+ new Word('shopping')
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider queryDataProvider
+ * @param string $query The query string to parse
+ * @param array $expected The expected parse result
+ */
+ public function testQueryParsing(string $query, array $expected): void
+ {
+ $result = $this->createParser()->parse($query);
+
+ $this->assertNodesMatch($expected, $result);
+ }
+
+ private function assertNodesMatch(array $expected, array $actual): void
+ {
+ $this->assertCount(count($expected), $actual);
+
+ foreach ($expected as $index => $expectedNode) {
+ $actualNode = $actual[$index];
+ $this->assertNodeMatches($expectedNode, $actualNode);
+ }
+ }
+
+ private function assertNodeMatches(Node $expected, Node $actual): void
+ {
+ $this->assertInstanceOf(get_class($expected), $actual);
+ $this->assertEquals($expected->isProhibited(), $actual->isProhibited());
+
+ match (get_class($expected)) {
+ Word::class => $this->assertWordMatches($expected, $actual),
+ Field::class => $this->assertFieldMatches($expected, $actual),
+ Subquery::class => $this->assertSubqueryMatches($expected, $actual),
+ default => throw new \InvalidArgumentException(sprintf(
+ 'Unexpected node type: %s',
+ get_class($expected)
+ ))
+ };
+ }
+
+ private function assertWordMatches(Word $expected, Word $actual): void
+ {
+ $this->assertEquals($expected->getValue(), $actual->getValue());
+ }
+
+ private function assertFieldMatches(Field $expected, Field $actual): void
+ {
+ $this->assertEquals($expected->getOperator(), $actual->getOperator());
+ $this->assertEquals($expected->getValue(), $actual->getValue());
+ }
+
+ private function assertSubqueryMatches(Subquery $expected, Subquery $actual): void
+ {
+ $this->assertNodesMatch($expected->getNodes(), $actual->getNodes());
+ }
+
+ private function assertIsWord(Node $node, string $expectedValue, bool $prohibited = false): void
+ {
+ $this->assertInstanceOf(Word::class, $node);
+ /** @var Word $node */
+ $this->assertEquals($expectedValue, $node->getValue());
+ $this->assertEquals($prohibited, $node->isProhibited());
+ }
+
+ private function assertIsField(
+ Node $node,
+ string $expectedOperator,
+ string $expectedValue,
+ bool $prohibited = false
+ ): void {
+ $this->assertInstanceOf(Field::class, $node);
+ /** @var Field $node */
+ $this->assertEquals($expectedOperator, $node->getOperator());
+ $this->assertEquals($expectedValue, $node->getValue());
+ $this->assertEquals($prohibited, $node->isProhibited());
+ }
+
+ private function assertIsSubquery(Node $node, array $expectedNodes, bool $prohibited = false): void
+ {
+ $this->assertInstanceOf(Subquery::class, $node);
+ /** @var Subquery $node */
+ $this->assertCount(count($expectedNodes), $node->getNodes());
+ $this->assertEquals($prohibited, $node->isProhibited());
+
+ foreach ($expectedNodes as $index => $expected) {
+ $actual = $node->getNodes()[$index];
+ if ($expected instanceof Word) {
+ $this->assertIsWord($actual, $expected->getValue(), $expected->isProhibited());
+ } elseif ($expected instanceof Field) {
+ $this->assertIsField($actual, $expected->getOperator(), $expected->getValue(), $expected->isProhibited());
+ } elseif ($expected instanceof Subquery) {
+ $this->assertIsSubquery($actual, $expected->getNodes(), $expected->isProhibited());
+ }
+ }
+ }
+}
diff --git a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php b/tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php
similarity index 60%
rename from tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
rename to tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php
index 8f00f7a65d..c5880fe98c 100644
--- a/tests/unit/Support/Search/GdbotsQueryParserParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php
@@ -1,10 +1,9 @@
Date: Thu, 2 Jan 2025 22:40:50 +0100
Subject: [PATCH 19/27] Give a bit better error messages in tests
---
app/Support/Search/QueryParser/Subquery.php | 2 +-
...ractQueryParserInterfaceParseQueryTest.php | 80 ++++++++++---------
2 files changed, 42 insertions(+), 40 deletions(-)
diff --git a/app/Support/Search/QueryParser/Subquery.php b/app/Support/Search/QueryParser/Subquery.php
index 9336ce0a62..f43c1f123a 100644
--- a/app/Support/Search/QueryParser/Subquery.php
+++ b/app/Support/Search/QueryParser/Subquery.php
@@ -33,6 +33,6 @@ class Subquery extends Node
public function __toString(): string
{
- return ($this->prohibited ? '-' : '') . '(' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ')';
+ return ($this->prohibited ? '-' : '') . '[' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ']';
}
}
diff --git a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
index 3d2a5426dc..1a1d668a7b 100644
--- a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
@@ -23,7 +23,7 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
'expected' => []
],
'simple word' => [
- 'query' => 'grocaeries',
+ 'query' => 'groceries',
'expected' => [new Word('groceries')]
],
'prohibited word' => [
@@ -196,7 +196,13 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
private function assertNodesMatch(array $expected, array $actual): void
{
- $this->assertCount(count($expected), $actual);
+ $message = sprintf(
+ "Expected: %s\nActual: %s",
+ $this->formatNodes($expected),
+ $this->formatNodes($actual)
+ );
+
+ $this->assertCount(count($expected), $actual, $message);
foreach ($expected as $index => $expectedNode) {
$actualNode = $actual[$index];
@@ -206,8 +212,10 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
private function assertNodeMatches(Node $expected, Node $actual): void
{
- $this->assertInstanceOf(get_class($expected), $actual);
- $this->assertEquals($expected->isProhibited(), $actual->isProhibited());
+ $message = $this->formatAssertMessage($expected, $actual);
+
+ $this->assertInstanceOf(get_class($expected), $actual, $message);
+ $this->assertEquals($expected->isProhibited(), $actual->isProhibited(), $message);
match (get_class($expected)) {
Word::class => $this->assertWordMatches($expected, $actual),
@@ -220,15 +228,22 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
};
}
+
private function assertWordMatches(Word $expected, Word $actual): void
{
- $this->assertEquals($expected->getValue(), $actual->getValue());
+ $message = $this->formatAssertMessage($expected, $actual); // Using your implementation
+ $this->assertEquals($expected->getValue(), $actual->getValue(), $message);
}
private function assertFieldMatches(Field $expected, Field $actual): void
{
- $this->assertEquals($expected->getOperator(), $actual->getOperator());
- $this->assertEquals($expected->getValue(), $actual->getValue());
+ $message = sprintf(
+ "\nExpected field: %s\nActual field: %s",
+ $this->formatNode($expected),
+ $this->formatNode($actual)
+ );
+ $this->assertEquals($expected->getOperator(), $actual->getOperator(), $message);
+ $this->assertEquals($expected->getValue(), $actual->getValue(), $message);
}
private function assertSubqueryMatches(Subquery $expected, Subquery $actual): void
@@ -236,43 +251,30 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
$this->assertNodesMatch($expected->getNodes(), $actual->getNodes());
}
- private function assertIsWord(Node $node, string $expectedValue, bool $prohibited = false): void
+ private function formatAssertMessage(Node $expected, Node $actual): string
{
- $this->assertInstanceOf(Word::class, $node);
- /** @var Word $node */
- $this->assertEquals($expectedValue, $node->getValue());
- $this->assertEquals($prohibited, $node->isProhibited());
+ return sprintf(
+ "\nExpected: %s\nActual: %s",
+ $this->formatNode($expected),
+ $this->formatNode($actual)
+ );
}
- private function assertIsField(
- Node $node,
- string $expectedOperator,
- string $expectedValue,
- bool $prohibited = false
- ): void {
- $this->assertInstanceOf(Field::class, $node);
- /** @var Field $node */
- $this->assertEquals($expectedOperator, $node->getOperator());
- $this->assertEquals($expectedValue, $node->getValue());
- $this->assertEquals($prohibited, $node->isProhibited());
+ private function formatNode(Node $node): string
+ {
+ return sprintf(
+ '%s(%s)',
+ basename(str_replace('\\', '/', get_class($node))),
+ $node->__toString()
+ );
}
- private function assertIsSubquery(Node $node, array $expectedNodes, bool $prohibited = false): void
- {
- $this->assertInstanceOf(Subquery::class, $node);
- /** @var Subquery $node */
- $this->assertCount(count($expectedNodes), $node->getNodes());
- $this->assertEquals($prohibited, $node->isProhibited());
- foreach ($expectedNodes as $index => $expected) {
- $actual = $node->getNodes()[$index];
- if ($expected instanceof Word) {
- $this->assertIsWord($actual, $expected->getValue(), $expected->isProhibited());
- } elseif ($expected instanceof Field) {
- $this->assertIsField($actual, $expected->getOperator(), $expected->getValue(), $expected->isProhibited());
- } elseif ($expected instanceof Subquery) {
- $this->assertIsSubquery($actual, $expected->getNodes(), $expected->isProhibited());
- }
- }
+ private function formatNodes(array $nodes): string
+ {
+ return '[' . implode(', ', array_map(
+ fn(Node $node) => $this->formatNode($node),
+ $nodes
+ )) . ']';
}
}
From af7a4b5d3da8b20430b88faf83bce56602721c16 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 23:19:21 +0100
Subject: [PATCH 20/27] Renaming of classes, making true recursive structure
---
app/Support/Search/OperatorQuerySearch.php | 60 +++++++++++--------
.../QueryParser/{Field.php => FieldNode.php} | 2 +-
.../Search/QueryParser/GdbotsQueryParser.php | 15 ++---
.../{Subquery.php => NodeGroup.php} | 6 +-
.../Search/QueryParser/QueryParser.php | 27 +++++----
.../QueryParser/QueryParserInterface.php | 4 +-
.../QueryParser/{Word.php => StringNode.php} | 4 +-
7 files changed, 66 insertions(+), 52 deletions(-)
rename app/Support/Search/QueryParser/{Field.php => FieldNode.php} (96%)
rename app/Support/Search/QueryParser/{Subquery.php => NodeGroup.php} (82%)
rename app/Support/Search/QueryParser/{Word.php => StringNode.php} (76%)
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index 4e9408a3a8..74e518fd5c 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -41,9 +41,10 @@ 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\Field;
-use FireflyIII\Support\Search\QueryParser\Word;
-use FireflyIII\Support\Search\QueryParser\Subquery;
+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 Illuminate\Pagination\LengthAwarePaginator;
@@ -140,7 +141,7 @@ class OperatorQuerySearch implements SearchInterface
app('log')->debug(sprintf('Using %s as implementation for QueryParserInterface', get_class($parser)));
try {
- $nodes = $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));
@@ -148,10 +149,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($nodes)));
- foreach ($nodes as $node) {
- $this->handleSearchNode($node);
- }
+ app('log')->debug(sprintf('Found %d node(s) at top-level', count($parsedQuery->getNodes())));
+ $this->handleSearchNode($parsedQuery);
// add missing information
$this->collector->withBillInformation();
@@ -170,27 +169,16 @@ class OperatorQuerySearch implements SearchInterface
app('log')->debug(sprintf('Now in handleSearchNode(%s)', get_class($node)));
switch (true) {
- case $node instanceof Word:
- $word = (string) $node->getValue();
- if($node->isProhibited()) {
- app('log')->debug(sprintf('Exclude word "%s" from search string', $word));
- $this->prohibitedWords[] = $word;
- } else {
- app('log')->debug(sprintf('Add word "%s" to search string', $word));
- $this->words[] = $word;
- }
-
+ case $node instanceof StringNode:
+ $this->handleStringNode($node);
break;
- case $node instanceof Field:
+ case $node instanceof FieldNode:
$this->handleFieldNode($node);
break;
- case $node instanceof Subquery:
- //TODO: Handle Subquery prohibition, i.e. flip all prohibition flags inside the subquery
- foreach ($node->getNodes() as $subNode) {
- $this->handleSearchNode($subNode);
- }
+ case $node instanceof NodeGroup:
+ $this->handleNodeGroup($node);
break;
default:
@@ -199,10 +187,32 @@ class OperatorQuerySearch implements SearchInterface
}
}
+ private function handleNodeGroup(NodeGroup $node): void
+ {
+ //TODO: Handle Subquery prohibition, i.e. flip all prohibition flags inside the subquery
+ foreach ($node->getNodes() as $subNode) {
+ $this->handleSearchNode($subNode);
+ }
+ }
+
+
+
+ private function handleStringNode(StringNode $node): void
+ {
+ $string = (string) $node->getValue();
+ if($node->isProhibited()) {
+ 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(Field $node): void
+ private function handleFieldNode(FieldNode $node): void
{
$operator = strtolower($node->getOperator());
$value = $node->getValue();
diff --git a/app/Support/Search/QueryParser/Field.php b/app/Support/Search/QueryParser/FieldNode.php
similarity index 96%
rename from app/Support/Search/QueryParser/Field.php
rename to app/Support/Search/QueryParser/FieldNode.php
index 3028af9730..4fd847cd5d 100644
--- a/app/Support/Search/QueryParser/Field.php
+++ b/app/Support/Search/QueryParser/FieldNode.php
@@ -7,7 +7,7 @@ namespace FireflyIII\Support\Search\QueryParser;
/**
* Represents a field operator with value (e.g. amount:100)
*/
-class Field extends Node
+class FieldNode extends Node
{
private string $operator;
private string $value;
diff --git a/app/Support/Search/QueryParser/GdbotsQueryParser.php b/app/Support/Search/QueryParser/GdbotsQueryParser.php
index 8a56d0f6de..c580b694be 100644
--- a/app/Support/Search/QueryParser/GdbotsQueryParser.php
+++ b/app/Support/Search/QueryParser/GdbotsQueryParser.php
@@ -19,17 +19,18 @@ class GdbotsQueryParser implements QueryParserInterface
}
/**
- * @return Node[]
+ * @return NodeGroup
* @throws FireflyException
*/
- public function parse(string $query): array
+ public function parse(string $query): NodeGroup
{
try {
$result = $this->parser->parse($query);
- return array_map(
+ $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');
@@ -44,17 +45,17 @@ class GdbotsQueryParser implements QueryParserInterface
{
switch (true) {
case $node instanceof GdbotsNode\Word:
- return new Word($node->getValue());
+ return new StringNode($node->getValue());
case $node instanceof GdbotsNode\Field:
- return new Field(
+ return new FieldNode(
$node->getValue(),
(string) $node->getNode()->getValue(),
BoolOperator::PROHIBITED === $node->getBoolOperator()
);
case $node instanceof GdbotsNode\Subquery:
- return new Subquery(
+ return new NodeGroup(
array_map(
fn(GdbotsNode\Node $subNode) => $this->convertNode($subNode),
$node->getNodes()
@@ -69,7 +70,7 @@ class GdbotsQueryParser implements QueryParserInterface
case $node instanceof GdbotsNode\Mention:
case $node instanceof GdbotsNode\Emoticon:
case $node instanceof GdbotsNode\Emoji:
- return new Word((string) $node->getValue());
+ return new StringNode((string) $node->getValue());
default:
throw new FireflyException(
diff --git a/app/Support/Search/QueryParser/Subquery.php b/app/Support/Search/QueryParser/NodeGroup.php
similarity index 82%
rename from app/Support/Search/QueryParser/Subquery.php
rename to app/Support/Search/QueryParser/NodeGroup.php
index f43c1f123a..967c4d89d4 100644
--- a/app/Support/Search/QueryParser/Subquery.php
+++ b/app/Support/Search/QueryParser/NodeGroup.php
@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace FireflyIII\Support\Search\QueryParser;
/**
- * Represents a subquery (group of nodes)
+ * Represents a group of nodes.
+ *
+ * NodeGroups can be nested inside other NodeGroups, making them subqueries
*/
-class Subquery extends Node
+class NodeGroup extends Node
{
/** @var Node[] */
private array $nodes;
diff --git a/app/Support/Search/QueryParser/QueryParser.php b/app/Support/Search/QueryParser/QueryParser.php
index 1d7f0658c9..f1e951f5ce 100644
--- a/app/Support/Search/QueryParser/QueryParser.php
+++ b/app/Support/Search/QueryParser/QueryParser.php
@@ -30,16 +30,16 @@ class QueryParser implements QueryParserInterface
private string $query;
private int $position = 0;
- /** @return Node[] */
- public function parse(string $query): array
+ /** @return NodeGroup */
+ public function parse(string $query): NodeGroup
{
$this->query = $query;
$this->position = 0;
- return $this->parseQuery(false);
+ return $this->buildNodeGroup(false);
}
- /** @return Node[] */
- private function parseQuery(bool $isSubquery): array
+ /** @return NodeGroup */
+ private function buildNodeGroup(bool $isSubquery, bool $prohibited = false): NodeGroup
{
$nodes = [];
$nodeResult = $this->buildNextNode($isSubquery);
@@ -52,7 +52,7 @@ class QueryParser implements QueryParserInterface
$nodeResult = $this->buildNextNode($isSubquery);
}
- return $nodes;
+ return new NodeGroup($nodes, $prohibited);
}
private function buildNextNode(bool $isSubquery): NodeResult
@@ -105,8 +105,7 @@ class QueryParser implements QueryParserInterface
if ($tokenUnderConstruction === '') {
// A left parentheses at the beginning of a token indicates the start of a subquery
$this->position++;
- return new NodeResult(
- new Subquery($this->parseQuery(true), $prohibited),
+ return new NodeResult($this->buildNodeGroup(true, $prohibited),
false
);
} else {
@@ -161,16 +160,18 @@ class QueryParser implements QueryParserInterface
$this->position++;
}
- return new NodeResult($tokenUnderConstruction !== '' || $fieldName !== ''
- ? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited)
- : null, true);
+ $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 Field(trim($fieldName), trim($token), $prohibited);
+ return new FieldNode(trim($fieldName), trim($token), $prohibited);
}
- return new Word(trim($token), $prohibited);
+ return new StringNode(trim($token), $prohibited);
}
}
diff --git a/app/Support/Search/QueryParser/QueryParserInterface.php b/app/Support/Search/QueryParser/QueryParserInterface.php
index 831baf1270..00383af0ff 100644
--- a/app/Support/Search/QueryParser/QueryParserInterface.php
+++ b/app/Support/Search/QueryParser/QueryParserInterface.php
@@ -7,7 +7,7 @@ namespace FireflyIII\Support\Search\QueryParser;
interface QueryParserInterface
{
/**
- * @return Node[]
+ * @return NodeGroup
*/
- public function parse(string $query): array;
+ public function parse(string $query): NodeGroup;
}
diff --git a/app/Support/Search/QueryParser/Word.php b/app/Support/Search/QueryParser/StringNode.php
similarity index 76%
rename from app/Support/Search/QueryParser/Word.php
rename to app/Support/Search/QueryParser/StringNode.php
index d7c12b6976..387862da30 100644
--- a/app/Support/Search/QueryParser/Word.php
+++ b/app/Support/Search/QueryParser/StringNode.php
@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace FireflyIII\Support\Search\QueryParser;
/**
- * Represents a word in the search query, meaning either a single-word without spaces or a quote-delimited string
+ * Represents a string in the search query, meaning either a single-word without spaces or a quote-delimited string
*/
-class Word extends Node
+class StringNode extends Node
{
private string $value;
From a62916a63d03b73fb3f406a26b52b891f29dc4a1 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Thu, 2 Jan 2025 23:36:00 +0100
Subject: [PATCH 21/27] Simplify further
---
app/Support/Search/QueryParser/FieldNode.php | 5 -
app/Support/Search/QueryParser/Node.php | 2 -
app/Support/Search/QueryParser/NodeGroup.php | 6 -
app/Support/Search/QueryParser/StringNode.php | 5 -
...ractQueryParserInterfaceParseQueryTest.php | 257 ++++++------------
5 files changed, 87 insertions(+), 188 deletions(-)
diff --git a/app/Support/Search/QueryParser/FieldNode.php b/app/Support/Search/QueryParser/FieldNode.php
index 4fd847cd5d..5005d5fe57 100644
--- a/app/Support/Search/QueryParser/FieldNode.php
+++ b/app/Support/Search/QueryParser/FieldNode.php
@@ -28,9 +28,4 @@ class FieldNode extends Node
{
return $this->value;
}
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . $this->operator . ':' . $this->value;
- }
}
diff --git a/app/Support/Search/QueryParser/Node.php b/app/Support/Search/QueryParser/Node.php
index 7dced2e759..3050bd82c7 100644
--- a/app/Support/Search/QueryParser/Node.php
+++ b/app/Support/Search/QueryParser/Node.php
@@ -9,8 +9,6 @@ namespace FireflyIII\Support\Search\QueryParser;
*/
abstract class Node
{
- abstract public function __toString(): string;
-
protected bool $prohibited;
public function isProhibited(): bool
diff --git a/app/Support/Search/QueryParser/NodeGroup.php b/app/Support/Search/QueryParser/NodeGroup.php
index 967c4d89d4..8cf801dbbc 100644
--- a/app/Support/Search/QueryParser/NodeGroup.php
+++ b/app/Support/Search/QueryParser/NodeGroup.php
@@ -31,10 +31,4 @@ class NodeGroup extends Node
{
return $this->nodes;
}
-
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . '[' . implode(' ', array_map(fn($node) => (string)$node, $this->nodes)) . ']';
- }
}
diff --git a/app/Support/Search/QueryParser/StringNode.php b/app/Support/Search/QueryParser/StringNode.php
index 387862da30..6164cc1de7 100644
--- a/app/Support/Search/QueryParser/StringNode.php
+++ b/app/Support/Search/QueryParser/StringNode.php
@@ -21,9 +21,4 @@ class StringNode extends Node
{
return $this->value;
}
-
- public function __toString(): string
- {
- return ($this->prohibited ? '-' : '') . $this->value;
- }
}
diff --git a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
index 1a1d668a7b..f324a8661a 100644
--- a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
+++ b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php
@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Tests\unit\Support\Search\QueryParser;
-use FireflyIII\Support\Search\QueryParser\Field;
+use FireflyIII\Support\Search\QueryParser\FieldNode;
use FireflyIII\Support\Search\QueryParser\QueryParserInterface;
-use FireflyIII\Support\Search\QueryParser\Word;
-use FireflyIII\Support\Search\QueryParser\Subquery;
+use FireflyIII\Support\Search\QueryParser\StringNode;
+use FireflyIII\Support\Search\QueryParser\NodeGroup;
use FireflyIII\Support\Search\QueryParser\Node;
use Tests\integration\TestCase;
@@ -20,164 +20,164 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
return [
'empty query' => [
'query' => '',
- 'expected' => []
+ 'expected' => new NodeGroup([])
],
'simple word' => [
'query' => 'groceries',
- 'expected' => [new Word('groceries')]
+ 'expected' => new NodeGroup([new StringNode('groceries')])
],
'prohibited word' => [
'query' => '-groceries',
- 'expected' => [new Word('groceries', true)]
+ 'expected' => new NodeGroup([new StringNode('groceries', true)])
],
'prohibited field' => [
'query' => '-amount:100',
- 'expected' => [new Field('amount', '100', true)]
+ 'expected' => new NodeGroup([new FieldNode('amount', '100', true)])
],
'quoted word' => [
'query' => '"test phrase"',
- 'expected' => [new Word('test phrase')]
+ 'expected' => new NodeGroup([new StringNode('test phrase')])
],
'prohibited quoted word' => [
'query' => '-"test phrase"',
- 'expected' => [new Word('test phrase', true)]
+ 'expected' => new NodeGroup([new StringNode('test phrase', true)])
],
'multiple words' => [
'query' => 'groceries shopping market',
- 'expected' => [
- new Word('groceries'),
- new Word('shopping'),
- new Word('market')
- ]
+ 'expected' => new NodeGroup([
+ new StringNode('groceries'),
+ new StringNode('shopping'),
+ new StringNode('market')
+ ])
],
'field operator' => [
'query' => 'amount:100',
- 'expected' => [new Field('amount', '100')]
+ 'expected' => new NodeGroup([new FieldNode('amount', '100')])
],
'quoted field value with single space' => [
'query' => 'description:"test phrase"',
- 'expected' => [new Field('description', 'test phrase')]
+ 'expected' => new NodeGroup([new FieldNode('description', 'test phrase')])
],
'multiple fields' => [
'query' => 'amount:100 category:food',
- 'expected' => [
- new Field('amount', '100'),
- new Field('category', 'food')
- ]
+ 'expected' => new NodeGroup([
+ new FieldNode('amount', '100'),
+ new FieldNode('category', 'food')
+ ])
],
'simple subquery' => [
'query' => '(amount:100 category:food)',
- 'expected' => [
- new Subquery([
- new Field('amount', '100'),
- new Field('category', 'food')
+ 'expected' => new NodeGroup([
+ new NodeGroup([
+ new FieldNode('amount', '100'),
+ new FieldNode('category', 'food')
])
- ]
+ ])
],
'prohibited subquery' => [
'query' => '-(amount:100 category:food)',
- 'expected' => [
- new Subquery([
- new Field('amount', '100'),
- new Field('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 Subquery([
- new Field('amount', '100'),
- new Subquery([
- new Field('description', 'test'),
- new Field('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 Word('groceries'),
- new Field('amount', '50'),
- new Word('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 Field('amount', '100'),
- new Subquery([
- new Field('description', 'market'),
- new Field('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 Word('groceries'),
- new Subquery([
- new Field('amount', '100'),
- new Field('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 Subquery([
- new Field('amount', '100'),
- new Subquery([
- new Field('description_contains', 'test payment'),
- new Field('has_attachments', 'true', 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 Word('shopping'),
- new Subquery([
- new Field('amount', '50'),
- new Word('market'),
- new Subquery([
- new Field('category', 'food', true),
- new Word('word'),
- new Field('description', 'test phrase'),
- new Subquery([
- new Field('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 Word('multiple spaces')]
+ 'expected' => new NodeGroup([new StringNode('multiple spaces')])
],
'field with multiple spaces in value' => [
'query' => 'description:"multiple spaces here"',
- 'expected' => [new Field('description', 'multiple spaces here')]
+ 'expected' => new NodeGroup([new FieldNode('description', 'multiple spaces here')])
],
'unmatched right parenthesis in word' => [
'query' => 'test)word',
- 'expected' => [new Word('test)word')]
+ 'expected' => new NodeGroup([new StringNode('test)word')])
],
'unmatched right parenthesis in field' => [
'query' => 'description:test)phrase',
- 'expected' => [new Field('description', 'test)phrase')]
+ 'expected' => new NodeGroup([new FieldNode('description', 'test)phrase')])
],
'subquery followed by word' => [
'query' => '(amount:100 category:food) shopping',
- 'expected' => [
- new Subquery([
- new Field('amount', '100'),
- new Field('category', 'food')
+ 'expected' => new NodeGroup([
+ new NodeGroup([
+ new FieldNode('amount', '100'),
+ new FieldNode('category', 'food')
]),
- new Word('shopping')
- ]
+ new StringNode('shopping')
+ ])
]
];
}
@@ -185,96 +185,13 @@ abstract class AbstractQueryParserInterfaceParseQueryTest extends TestCase
/**
* @dataProvider queryDataProvider
* @param string $query The query string to parse
- * @param array $expected The expected parse result
+ * @param Node $expected The expected parse result
*/
- public function testQueryParsing(string $query, array $expected): void
+ public function testQueryParsing(string $query, Node $expected): void
{
- $result = $this->createParser()->parse($query);
+ $actual = $this->createParser()->parse($query);
- $this->assertNodesMatch($expected, $result);
- }
+ $this->assertEquals($expected, $actual);
- private function assertNodesMatch(array $expected, array $actual): void
- {
- $message = sprintf(
- "Expected: %s\nActual: %s",
- $this->formatNodes($expected),
- $this->formatNodes($actual)
- );
-
- $this->assertCount(count($expected), $actual, $message);
-
- foreach ($expected as $index => $expectedNode) {
- $actualNode = $actual[$index];
- $this->assertNodeMatches($expectedNode, $actualNode);
- }
- }
-
- private function assertNodeMatches(Node $expected, Node $actual): void
- {
- $message = $this->formatAssertMessage($expected, $actual);
-
- $this->assertInstanceOf(get_class($expected), $actual, $message);
- $this->assertEquals($expected->isProhibited(), $actual->isProhibited(), $message);
-
- match (get_class($expected)) {
- Word::class => $this->assertWordMatches($expected, $actual),
- Field::class => $this->assertFieldMatches($expected, $actual),
- Subquery::class => $this->assertSubqueryMatches($expected, $actual),
- default => throw new \InvalidArgumentException(sprintf(
- 'Unexpected node type: %s',
- get_class($expected)
- ))
- };
- }
-
-
- private function assertWordMatches(Word $expected, Word $actual): void
- {
- $message = $this->formatAssertMessage($expected, $actual); // Using your implementation
- $this->assertEquals($expected->getValue(), $actual->getValue(), $message);
- }
-
- private function assertFieldMatches(Field $expected, Field $actual): void
- {
- $message = sprintf(
- "\nExpected field: %s\nActual field: %s",
- $this->formatNode($expected),
- $this->formatNode($actual)
- );
- $this->assertEquals($expected->getOperator(), $actual->getOperator(), $message);
- $this->assertEquals($expected->getValue(), $actual->getValue(), $message);
- }
-
- private function assertSubqueryMatches(Subquery $expected, Subquery $actual): void
- {
- $this->assertNodesMatch($expected->getNodes(), $actual->getNodes());
- }
-
- private function formatAssertMessage(Node $expected, Node $actual): string
- {
- return sprintf(
- "\nExpected: %s\nActual: %s",
- $this->formatNode($expected),
- $this->formatNode($actual)
- );
- }
-
- private function formatNode(Node $node): string
- {
- return sprintf(
- '%s(%s)',
- basename(str_replace('\\', '/', get_class($node))),
- $node->__toString()
- );
- }
-
-
- private function formatNodes(array $nodes): string
- {
- return '[' . implode(', ', array_map(
- fn(Node $node) => $this->formatNode($node),
- $nodes
- )) . ']';
}
}
From 0c955efa8be3f6d2ac3ba1fe9dde186fc2495dd7 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 00:07:57 +0100
Subject: [PATCH 22/27] Allow flipping of subqueries
---
app/Providers/SearchServiceProvider.php | 6 ++---
app/Support/Search/OperatorQuerySearch.php | 28 ++++++++++++----------
app/Support/Search/QueryParser/Node.php | 17 +++++++++++--
3 files changed, 34 insertions(+), 17 deletions(-)
diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php
index 944348218d..98b5a0f850 100644
--- a/app/Providers/SearchServiceProvider.php
+++ b/app/Providers/SearchServiceProvider.php
@@ -23,10 +23,10 @@ declare(strict_types=1);
namespace FireflyIII\Providers;
-use FireflyIII\Support\Search\GdbotsQueryParser;
+use FireflyIII\Support\Search\QueryParser\GdbotsQueryParser;
use FireflyIII\Support\Search\OperatorQuerySearch;
-use FireflyIII\Support\Search\QueryParser;
-use FireflyIII\Support\Search\QueryParserInterface;
+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;
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index 74e518fd5c..e6c445b231 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -150,7 +150,7 @@ class OperatorQuerySearch implements SearchInterface
}
app('log')->debug(sprintf('Found %d node(s) at top-level', count($parsedQuery->getNodes())));
- $this->handleSearchNode($parsedQuery);
+ $this->handleSearchNode($parsedQuery, $parsedQuery->isProhibited(false));
// add missing information
$this->collector->withBillInformation();
@@ -164,21 +164,21 @@ class OperatorQuerySearch implements SearchInterface
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
- private function handleSearchNode(Node $node): void
+ private function handleSearchNode(Node $node, $flipProhibitedFlag): void
{
app('log')->debug(sprintf('Now in handleSearchNode(%s)', get_class($node)));
switch (true) {
case $node instanceof StringNode:
- $this->handleStringNode($node);
+ $this->handleStringNode($node, $flipProhibitedFlag);
break;
case $node instanceof FieldNode:
- $this->handleFieldNode($node);
+ $this->handleFieldNode($node, $flipProhibitedFlag);
break;
case $node instanceof NodeGroup:
- $this->handleNodeGroup($node);
+ $this->handleNodeGroup($node, $flipProhibitedFlag);
break;
default:
@@ -187,20 +187,24 @@ class OperatorQuerySearch implements SearchInterface
}
}
- private function handleNodeGroup(NodeGroup $node): void
+ private function handleNodeGroup(NodeGroup $node, $flipProhibitedFlag): void
{
- //TODO: Handle Subquery prohibition, i.e. flip all prohibition flags inside the subquery
+ $prohibited = $node->isProhibited($flipProhibitedFlag);
+
foreach ($node->getNodes() as $subNode) {
- $this->handleSearchNode($subNode);
+ $this->handleSearchNode($subNode, $prohibited);
}
}
- private function handleStringNode(StringNode $node): void
+ private function handleStringNode(StringNode $node, $flipProhibitedFlag): void
{
$string = (string) $node->getValue();
- if($node->isProhibited()) {
+
+ $prohibited = $node->isProhibited($flipProhibitedFlag);
+
+ if($prohibited) {
app('log')->debug(sprintf('Exclude string "%s" from search string', $string));
$this->prohibitedWords[] = $string;
} else {
@@ -212,11 +216,11 @@ class OperatorQuerySearch implements SearchInterface
/**
* @throws FireflyException
*/
- private function handleFieldNode(FieldNode $node): void
+ private function handleFieldNode(FieldNode $node, $flipProhibitedFlag): void
{
$operator = strtolower($node->getOperator());
$value = $node->getValue();
- $prohibited = $node->isProhibited();
+ $prohibited = $node->isProhibited($flipProhibitedFlag);
$context = config(sprintf('search.operators.%s.needs_context', $operator));
diff --git a/app/Support/Search/QueryParser/Node.php b/app/Support/Search/QueryParser/Node.php
index 3050bd82c7..e7ae29611b 100644
--- a/app/Support/Search/QueryParser/Node.php
+++ b/app/Support/Search/QueryParser/Node.php
@@ -11,8 +11,21 @@ abstract class Node
{
protected bool $prohibited;
- public function isProhibited(): bool
+ /**
+ * 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
{
- return $this->prohibited;
+ if ($flipFlag === true) {
+ return !$this->prohibited;
+ } else {
+ return $this->prohibited;
+ }
+
}
}
From 058a0f9fb229646fba855ba99010f0ad03e098e1 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 00:08:11 +0100
Subject: [PATCH 23/27] Show excluded words in UI
---
app/Http/Controllers/SearchController.php | 3 ++-
app/Support/Search/OperatorQuerySearch.php | 5 +++++
app/Support/Search/SearchInterface.php | 1 +
resources/lang/en_US/firefly.php | 1 +
resources/views/search/index.twig | 9 +++++++--
5 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php
index 6bf2204c3b..09eb820c54 100644
--- a/app/Http/Controllers/SearchController.php
+++ b/app/Http/Controllers/SearchController.php
@@ -84,11 +84,12 @@ class SearchController extends Controller
// words from query and operators:
$query = $searcher->getWordsAsString();
+ $excludedWords = $searcher->getExcludedWordsAsString();
$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('query', 'excludedWords', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators'));
}
/**
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index e6c445b231..e37b90e282 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -123,6 +123,11 @@ class OperatorQuerySearch implements SearchInterface
return implode(' ', $this->words);
}
+ public function getExcludedWordsAsString(): string
+ {
+ return implode(' ', $this->prohibitedWords);
+ }
+
/**
* @throws FireflyException
*/
diff --git a/app/Support/Search/SearchInterface.php b/app/Support/Search/SearchInterface.php
index b9197e9daf..3604cacdd1 100644
--- a/app/Support/Search/SearchInterface.php
+++ b/app/Support/Search/SearchInterface.php
@@ -40,6 +40,7 @@ interface SearchInterface
public function getOperators(): Collection;
public function getWordsAsString(): string;
+ public function getExcludedWordsAsString(): string;
public function hasModifiers(): bool;
diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php
index bd38b2404d..65e810481c 100644
--- a/resources/lang/en_US/firefly.php
+++ b/resources/lang/en_US/firefly.php
@@ -330,6 +330,7 @@ return [
'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: :query ',
+ 'search_for_excluded_words' => 'Firefly III is searching for transactions with none of these words in them: :excluded_words ',
'invalid_operators_list' => 'These search parameters are not valid and have been ignored.',
// old
diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig
index b48dfd069e..f4346c55b1 100644
--- a/resources/views/search/index.twig
+++ b/resources/views/search/index.twig
@@ -43,6 +43,11 @@
{{ trans('firefly.search_for_query', {query: query|escape})|raw }}
{% endif %}
+ {% if '' != excludedWords %}
+
+ {{ trans('firefly.search_for_excluded_words', {excluded_words: excludedWords|escape})|raw }}
+
+ {% endif %}
{% if invalidOperators|length > 0 %}
{{ trans('firefly.invalid_operators_list') }}
@@ -70,7 +75,7 @@
- {% if query or operators|length > 0 %}
+ {% if query or excludedWords or operators|length > 0 %}
{% endif %}
- {% if query == "" and operators|length == 0 %}
+ {% if query == "" and excludedWords == "" and operators|length == 0 %}
From 50aff9cfb64e592e42d818b8d3ae09654da64307 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 00:13:51 +0100
Subject: [PATCH 24/27] Make prohibited word search work
---
app/Support/Search/OperatorQuerySearch.php | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index e37b90e282..2bdbe40c5b 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -2774,7 +2774,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);
}
@@ -2831,6 +2831,11 @@ class OperatorQuerySearch implements SearchInterface
return $this->words;
}
+ public function getExcludedWords(): array
+ {
+ return $this->prohibitedWords;
+ }
+
public function setDate(Carbon $date): void
{
$this->date = $date;
From 36ec1daf3a2ca36964d2e84ca87aad46644c40b8 Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 00:29:38 +0100
Subject: [PATCH 25/27] Update UI
---
resources/lang/en_US/firefly.php | 6 ++---
resources/views/search/index.twig | 40 +++++++++++++++----------------
2 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php
index 65e810481c..f10e621e4f 100644
--- a/resources/lang/en_US/firefly.php
+++ b/resources/lang/en_US/firefly.php
@@ -329,8 +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: :query ',
- 'search_for_excluded_words' => 'Firefly III is searching for transactions with none of these words in them: :excluded_words ',
+ 'search_for_overview' => 'Firefly III is searching for transactions that fulfill all of the following conditions:',
+ 'search_for_query' => 'All of these words must be present: :query ',
+ 'search_for_excluded_words' => 'None of these words may be present: :excluded_words ',
'invalid_operators_list' => 'These search parameters are not valid and have been ignored.',
// old
@@ -730,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.',
diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig
index f4346c55b1..844f00f16a 100644
--- a/resources/views/search/index.twig
+++ b/resources/views/search/index.twig
@@ -38,17 +38,31 @@
{% endif %}
+
+ {{ trans('firefly.search_for_overview') |raw }}
+
+
{% if '' != query %}
-
- {{ trans('firefly.search_for_query', {query: query|escape})|raw }}
-
+
+ {{- trans('firefly.search_for_query', {query: query|escape})|raw -}}
+
{% endif %}
{% if '' != excludedWords %}
-
- {{ trans('firefly.search_for_excluded_words', {excluded_words: excludedWords|escape})|raw }}
-
+
+ {{- trans('firefly.search_for_excluded_words', {excluded_words: excludedWords|escape})|raw -}}
+
{% endif %}
+ {% for operator in operators %}
+ {% if operator.prohibited %}
+ {{- trans('firefly.search_modifier_not_'~operator.type, {value: operator.value}) -}}
+ {% endif %}
+ {% if not operator.prohibited %}
+ {{- trans('firefly.search_modifier_'~operator.type, {value: operator.value}) -}}
+ {% endif %}
+ {% endfor %}
+
+
{% if invalidOperators|length > 0 %}
{{ trans('firefly.invalid_operators_list') }}
@@ -57,20 +71,6 @@
{% endfor %}
{% endif %}
-
- {% if operators|length > 0 %}
- {{ trans('firefly.modifiers_applies_are') }}
-
- {% for operator in operators %}
- {% if operator.prohibited %}
- {{ trans('firefly.search_modifier_not_'~operator.type, {value: operator.value}) }}
- {% endif %}
- {% if not operator.prohibited %}
- {{ trans('firefly.search_modifier_'~operator.type, {value: operator.value}) }}
- {% endif %}
- {% endfor %}
-
- {% endif %}
From e33e3cc40fe970e596c7d94d044236cd3f51d4fe Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 00:45:19 +0100
Subject: [PATCH 26/27] Tweak UI
---
app/Http/Controllers/SearchController.php | 6 +++---
app/Support/Search/OperatorQuerySearch.php | 19 +++++++------------
app/Support/Search/SearchInterface.php | 3 ++-
public/v1/css/firefly.css | 5 +++++
resources/views/search/index.twig | 20 ++++++++++++++------
5 files changed, 31 insertions(+), 22 deletions(-)
diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php
index 09eb820c54..ceab9169c5 100644
--- a/app/Http/Controllers/SearchController.php
+++ b/app/Http/Controllers/SearchController.php
@@ -83,13 +83,13 @@ class SearchController extends Controller
$searcher->parseQuery($fullQuery);
// words from query and operators:
- $query = $searcher->getWordsAsString();
- $excludedWords = $searcher->getExcludedWordsAsString();
+ $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', 'excludedWords', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators'));
+ return view('search.index', compact('words', 'excludedWords', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators'));
}
/**
diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php
index 2bdbe40c5b..915e8ad26d 100644
--- a/app/Support/Search/OperatorQuerySearch.php
+++ b/app/Support/Search/OperatorQuerySearch.php
@@ -123,9 +123,14 @@ class OperatorQuerySearch implements SearchInterface
return implode(' ', $this->words);
}
- public function getExcludedWordsAsString(): string
+ public function getWords(): array
{
- return implode(' ', $this->prohibitedWords);
+ return $this->words;
+ }
+
+ public function getExcludedWords(): array
+ {
+ return $this->prohibitedWords;
}
/**
@@ -2826,16 +2831,6 @@ class OperatorQuerySearch implements SearchInterface
}
}
- public function getWords(): array
- {
- return $this->words;
- }
-
- public function getExcludedWords(): array
- {
- return $this->prohibitedWords;
- }
-
public function setDate(Carbon $date): void
{
$this->date = $date;
diff --git a/app/Support/Search/SearchInterface.php b/app/Support/Search/SearchInterface.php
index 3604cacdd1..3c9a78e19e 100644
--- a/app/Support/Search/SearchInterface.php
+++ b/app/Support/Search/SearchInterface.php
@@ -38,9 +38,10 @@ interface SearchInterface
public function getModifiers(): Collection;
public function getOperators(): Collection;
+ public function getWords(): array;
public function getWordsAsString(): string;
- public function getExcludedWordsAsString(): string;
+ public function getExcludedWords(): array;
public function hasModifiers(): bool;
diff --git a/public/v1/css/firefly.css b/public/v1/css/firefly.css
index 2c93945e76..3b546ab095 100644
--- a/public/v1/css/firefly.css
+++ b/public/v1/css/firefly.css
@@ -258,6 +258,11 @@ span.twitter-typeahead {
top: 46px !important;
}
+.search-word {
+ white-space: pre;
+ background-color: #f5f5f5;
+}
+
/*
.twitter-typeahead {
width:100%;
diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig
index 844f00f16a..07c5b20866 100644
--- a/resources/views/search/index.twig
+++ b/resources/views/search/index.twig
@@ -42,14 +42,22 @@
{{ trans('firefly.search_for_overview') |raw }}
- {% if '' != query %}
+ {% if words|length > 0 %}
- {{- trans('firefly.search_for_query', {query: query|escape})|raw -}}
+ {{- trans('firefly.search_for_query', {
+ query: words
+ |map(word => '' ~ word|escape ~ ' ')
+ |join(' ')
+ })|raw -}}
{% endif %}
- {% if '' != excludedWords %}
+ {% if excludedWords|length > 0 %}
- {{- trans('firefly.search_for_excluded_words', {excluded_words: excludedWords|escape})|raw -}}
+ {{- trans('firefly.search_for_excluded_words', {
+ excluded_words: excludedWords
+ |map(word => '' ~ word|escape ~ ' ')
+ |join(' ')
+ })|raw -}}
{% endif %}
@@ -75,7 +83,7 @@
- {% if query or excludedWords or operators|length > 0 %}
+ {% if query|length > 0 or excludedWords|length > 0 or operators|length > 0 %}
{% endif %}
- {% if query == "" and excludedWords == "" and operators|length == 0 %}
+ {% if query|length == 0 and excludedWords|length == 0 and operators|length == 0 %}
From dae7e7d507ed3fddfc7790a61b57eba3e46fa96c Mon Sep 17 00:00:00 2001
From: Sobuno
Date: Fri, 3 Jan 2025 01:00:17 +0100
Subject: [PATCH 27/27] Handle excludedWords on Rule management pages as well
---
.../Controllers/Rule/CreateController.php | 30 +++++++++++++------
app/Http/Controllers/Rule/EditController.php | 23 +++++++++++---
2 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/app/Http/Controllers/Rule/CreateController.php b/app/Http/Controllers/Rule/CreateController.php
index 9729dfe015..ab2e59b46c 100644
--- a/app/Http/Controllers/Rule/CreateController.php
+++ b/app/Http/Controllers/Rule/CreateController.php
@@ -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);
}
diff --git a/app/Http/Controllers/Rule/EditController.php b/app/Http/Controllers/Rule/EditController.php
index a00df8ef4c..4c4e04b6fc 100644
--- a/app/Http/Controllers/Rule/EditController.php
+++ b/app/Http/Controllers/Rule/EditController.php
@@ -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);
}