diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php index 407d75ff554..08848c98681 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php @@ -22,7 +22,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueLessThan; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueLessThanOrEqual; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueStartsWith; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\PropertyName; @@ -178,6 +178,9 @@ public function addNodeTypeCriteria(QueryBuilder $queryBuilder, ExpandedNodeType public function addSearchTermConstraints(QueryBuilder $queryBuilder, SearchTerm $searchTerm, string $nodeTableAlias = 'n'): void { + if ($searchTerm->term === '') { + return; + } $queryBuilder->andWhere('JSON_SEARCH(' . $nodeTableAlias . '.properties, "one", :searchTermPattern, NULL, "$.*.value") IS NOT NULL')->setParameter('searchTermPattern', '%' . $searchTerm->term . '%'); } diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ChildNodes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ChildNodes.feature index 45531933113..61e14ccc33c 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ChildNodes.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ChildNodes.feature @@ -76,11 +76,14 @@ Feature: Find and count nodes using the findChildNodes and countChildNodes queri | a1 | Neos.ContentRepository.Testing:Page | a | {"text": "a1"} | {} | | a2 | Neos.ContentRepository.Testing:Page | a | {"text": "a2"} | {} | | a2a | Neos.ContentRepository.Testing:SpecialPage | a2 | {"text": "a2a"} | {} | - | a2a1 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a1", "stringProperty": "the brown fox", "booleanProperty": true, "integerProperty": 33, "floatProperty": 12.345, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-13"}} | {} | + | a2a1 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a1", "stringProperty": "the brown fox likes Äpfel", "booleanProperty": true, "integerProperty": 33, "floatProperty": 12.345, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-13"}} | {} | | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a2", "stringProperty": "the red fox", "booleanProperty": false, "integerProperty": 22, "floatProperty": 12.34, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-14"}} | {} | - | a2a3 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a3", "stringProperty": "the red Bear", "integerProperty": 19, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-12"}} | {} | + # Note that the node a2a3 is disabled! See below. + | a2a3-disabled | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a3", "stringProperty": "the red Bear", "integerProperty": 19, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-12"}} | {} | | a2a4 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a4", "stringProperty": "the brown Bear", "integerProperty": 19, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-12"}} | {} | | a2a5 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a5", "stringProperty": "the brown bear", "integerProperty": 19, "dateProperty": {"__type": "DateTimeImmutable", "value": "1980-12-13"}} | {} | + # Note that the node a2a6 must not contain any properties! + | a2a6-empty | Neos.ContentRepository.Testing:Page | a2a | {} | {} | | b | Neos.ContentRepository.Testing:Page | home | {"text": "b"} | {} | | b1 | Neos.ContentRepository.Testing:Page | b | {"text": "b1"} | {} | And the current date and time is "2023-03-16T13:00:00+01:00" @@ -89,16 +92,19 @@ Feature: Find and count nodes using the findChildNodes and countChildNodes queri | nodeAggregateId | "a2a5" | | propertyValues | {"integerProperty": 20} | And the command DisableNodeAggregate is executed with payload: - | Key | Value | - | nodeAggregateId | "a2a3" | - | nodeVariantSelectionStrategy | "allVariants" | + | Key | Value | + | nodeAggregateId | "a2a3-disabled" | + | nodeVariantSelectionStrategy | "allVariants" | Scenario: # Child nodes without filter When I execute the findChildNodes query for parent node aggregate id "home" I expect the nodes "terms,contact,a,b" to be returned When I execute the findChildNodes query for parent node aggregate id "a" I expect the nodes "a1,a2" to be returned When I execute the findChildNodes query for parent node aggregate id "a1" I expect no nodes to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" I expect the nodes "a2a1,a2a2,a2a4,a2a5" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" I expect the nodes "a2a1,a2a2,a2a4,a2a5,a2a6-empty" to be returned + # Child nodes with empty filter + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": ""}' I expect the nodes "a2a1,a2a2,a2a4,a2a5,a2a6-empty" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"nodeTypes": ""}' I expect the nodes "a2a1,a2a2,a2a4,a2a5,a2a6-empty" to be returned # Child nodes filtered by node type When I execute the findChildNodes query for parent node aggregate id "home" and filter '{"nodeTypes": "Neos.ContentRepository.Testing:AbstractPage"}' I expect the nodes "terms,contact,a,b" to be returned @@ -109,7 +115,15 @@ Feature: Find and count nodes using the findChildNodes and countChildNodes queri # Child nodes filtered by search term When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "brown"}' I expect the nodes "a2a1,a2a4,a2a5" to be returned + # The total count highlights that the search is case insensitive When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "bear", "pagination": {"limit": 3, "offset": 1}}' I expect the nodes "a2a5" to be returned and the total count to be 2 + # Case insensitive multibyte search + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "äpfel"}' I expect the nodes "a2a1" to be returned + # Search for numbers (could be considered useless) + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "22"}' I expect the nodes "a2a2" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "12.34"}' I expect the nodes "a2a1,a2a2" to be returned + # Search for boolean (could be considered useless) + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"searchTerm": "true"}' I expect the nodes "a2a1" to be returned # Child nodes paginated When I execute the findChildNodes query for parent node aggregate id "home" and filter '{"pagination": {"limit": 3}}' I expect the nodes "terms,contact,a" to be returned and the total count to be 4 @@ -140,10 +154,10 @@ Feature: Find and count nodes using the findChildNodes and countChildNodes queri When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"propertyValue": "stringProperty $=~ \"Bear\""}' I expect the nodes "a2a4,a2a5" to be returned # Child nodes with custom ordering - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "text", "direction": "ASCENDING"}]}' I expect the nodes "a2a1,a2a2,a2a4,a2a5" to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "text", "direction": "DESCENDING"}]}' I expect the nodes "a2a5,a2a4,a2a2,a2a1" to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "non_existing", "direction": "ASCENDING"}]}' I expect the nodes "a2a1,a2a2,a2a4,a2a5" to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "booleanProperty", "direction": "ASCENDING"}, {"type": "propertyName", "field": "dateProperty", "direction": "ASCENDING"}]}' I expect the nodes "a2a4,a2a5,a2a2,a2a1" to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "timestampField", "field": "CREATED", "direction": "ASCENDING"}]}' I expect the nodes "a2a1,a2a2,a2a4,a2a5" to be returned - When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "timestampField", "field": "LAST_MODIFIED", "direction": "DESCENDING"}]}' I expect the nodes "a2a5,a2a1,a2a2,a2a4" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "text", "direction": "ASCENDING"}]}' I expect the nodes "a2a6-empty,a2a1,a2a2,a2a4,a2a5" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "text", "direction": "DESCENDING"}]}' I expect the nodes "a2a5,a2a4,a2a2,a2a1,a2a6-empty" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "non_existing", "direction": "ASCENDING"}]}' I expect the nodes "a2a1,a2a2,a2a4,a2a5,a2a6-empty" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "propertyName", "field": "booleanProperty", "direction": "ASCENDING"}, {"type": "propertyName", "field": "dateProperty", "direction": "ASCENDING"}]}' I expect the nodes "a2a6-empty,a2a4,a2a5,a2a2,a2a1" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "timestampField", "field": "CREATED", "direction": "ASCENDING"}]}' I expect the nodes "a2a1,a2a2,a2a4,a2a5,a2a6-empty" to be returned + When I execute the findChildNodes query for parent node aggregate id "a2a" and filter '{"ordering": [{"type": "timestampField", "field": "LAST_MODIFIED", "direction": "DESCENDING"}]}' I expect the nodes "a2a5,a2a1,a2a2,a2a4,a2a6-empty" to be returned diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/SerializedPropertyValue.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/SerializedPropertyValue.php index 650652adb12..6df6fcd1d6d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/SerializedPropertyValue.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/SerializedPropertyValue.php @@ -38,8 +38,6 @@ private function __construct( } /** - * If the value is NULL an unset-property instruction will be returned instead. - * * @param Value $value */ public static function create( diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php index 50573a90d7c..603c56d014c 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php @@ -7,7 +7,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php index e6bc3188132..8a0574fe12a 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php @@ -7,7 +7,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::countChildNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php index 0a873ae25f2..c86694ab6ff 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php @@ -7,7 +7,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::countDescendantNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php index b0eb0cf6ecc..17e8ada902a 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php @@ -7,7 +7,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindBackReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindBackReferencesFilter.php index 72e034046a1..657e92144f9 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindBackReferencesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindBackReferencesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindChildNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindChildNodesFilter.php index 284dde90eea..66f071cd0d8 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindChildNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindChildNodesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::findChildNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindDescendantNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindDescendantNodesFilter.php index 54277308995..788f3358f7d 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindDescendantNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindDescendantNodesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::findDescendantNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindPrecedingSiblingNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindPrecedingSiblingNodesFilter.php index 7cef184992b..cd92c3bdfc8 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindPrecedingSiblingNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindPrecedingSiblingNodesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::findPrecedingSiblingNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindReferencesFilter.php index 735c5a42cb0..45b2304d345 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindReferencesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindReferencesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindSucceedingSiblingNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindSucceedingSiblingNodesFilter.php index b955533c490..e1b70f748f9 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindSucceedingSiblingNodesFilter.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindSucceedingSiblingNodesFilter.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\Pagination\Pagination; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\Criteria\PropertyValueCriteriaInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\PropertyValue\PropertyValueCriteriaParser; -use Neos\ContentRepository\Core\Projection\ContentGraph\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; /** * Immutable filter DTO for {@see ContentSubgraphInterface::findSucceedingSiblingNodes()} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/SearchTerm.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTerm.php similarity index 69% rename from Neos.ContentRepository.Core/Classes/Projection/ContentGraph/SearchTerm.php rename to Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTerm.php index 733d3cf7dfd..8d1f7b07571 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/SearchTerm.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTerm.php @@ -12,11 +12,17 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\Projection\ContentGraph; +namespace Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm; /** * A search term for use in Filters for the {@see ContentSubgraphInterface} API. * + * The search is defined the following: + * - test all properties if one contains the term + * - the term is checked case-insensitive + * - an empty term will lead to no filtering + * - FIXME: define the search behaviour across non-string-typed properties + * * @api DTO for {@see ContentSubgraphInterface} */ final readonly class SearchTerm diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcher.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcher.php new file mode 100644 index 00000000000..1d3dff96350 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcher.php @@ -0,0 +1,56 @@ +properties->serialized(), $searchTerm); + } + + public static function matchesSerializedPropertyValues(SerializedPropertyValues $serializedPropertyValues, SearchTerm $searchTerm): bool + { + if ($searchTerm->term === '') { + return true; + } + foreach ($serializedPropertyValues as $serializedPropertyValue) { + if (self::matchesValue($serializedPropertyValue->value, $searchTerm)) { + return true; + } + } + return false; + } + + private static function matchesValue(mixed $value, SearchTerm $searchTerm): bool + { + if (is_array($value) || $value instanceof \ArrayObject) { + foreach ($value as $subValue) { + if (self::matchesValue($subValue, $searchTerm)) { + return true; + } + } + return false; + } + + return match (true) { + is_string($value) => mb_stripos($value, $searchTerm->term) !== false, + // the following behaviour might seem odd, but is implemented after how the doctrine adapter filtering is currently implemented + is_int($value), + is_float($value) => str_contains((string)$value, $searchTerm->term), + $value === true => str_contains('true', $searchTerm->term), + $value === false => str_contains('false', $searchTerm->term), + default => throw new \InvalidArgumentException(sprintf('Handling for type %s is not implemented.', get_debug_type($value))), + }; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Nodes.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Nodes.php index 4c4d3688a68..ef55a5f8afe 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Nodes.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Nodes.php @@ -124,6 +124,16 @@ public function merge(self $other): self return self::fromArray($nodes); } + public function prepend(Node $node): self + { + return new self([$node, ...$this->nodes]); + } + + public function append(Node $node): self + { + return new self([...$this->nodes, $node]); + } + public function reverse(): self { return new self(array_reverse($this->nodes)); diff --git a/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcherTest.php b/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcherTest.php new file mode 100644 index 00000000000..eaf22358166 --- /dev/null +++ b/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/Filter/SearchTerm/SearchTermMatcherTest.php @@ -0,0 +1,173 @@ + ['brown', self::value('the brown fox')]; + yield 'string found inside string (ci)' => ['BrOWn', self::value('the brown fox')]; + + yield 'string found inside multibyte string (ci)' => ['Äpfel', self::value('Auer schreit der bauer die äpfel sind zu sauer.')]; + + yield 'string matches full string' => ['Sheep', self::value('Sheep')]; + yield 'string matches full string (ci)' => ['sheep', self::value('Sheep')]; + + yield 'string found inside string with special chars' => ['ä b-c+#@', self::value('the example: "ä b-c+#@"')]; + } + + public static function matchingNumberLikeComparisonExamples(): iterable + { + yield 'string-number found inside string' => [ + '22', + self::value('feeling like 22 ;)'), + ]; + + yield 'string-number found inside string-number' => [ + '00', + self::value('007'), + ]; + + yield 'string-number found inside int' => [ + '23', + self::value(1234), + ]; + + yield 'string-number found inside float' => [ + '23', + self::value(1234.56), + ]; + + yield 'string-float matches float' => [ + '1234.56', + self::value(1234.56), + ]; + + yield 'string-int matches int' => [ + '0', + self::value(0), + ]; + } + + public static function matchingBooleanLikeComparisonExamples(): iterable + { + yield 'string-boolean inside string' => [ + 'true', + self::value('this is true'), + ]; + + yield 'string-true matches boolean' => [ + 'true', + self::value(true), + ]; + + yield 'string-false matches boolean' => [ + 'false', + self::value(false), + ]; + + yield 'string part matches boolean' => [ + 'ru', + self::value(true), + ]; + } + + public static function matchingArrayComparisonExamples(): iterable + { + // automates the following: + yield 'inside array: string found inside string' => ['foo', self::value(['foo'])]; + yield 'inside array-object: string found inside string' => ['foo', self::value(new \ArrayObject(['foo']))]; + + foreach([ + ...iterator_to_array(self::matchingStringComparisonExamples()), + ...iterator_to_array(self::matchingNumberLikeComparisonExamples()), + ...iterator_to_array(self::matchingBooleanLikeComparisonExamples()), + ] as $name => [$searchTerm, $properties]) { + /** @var SerializedPropertyValues $properties */ + yield 'inside nested array: ' . $name => [$searchTerm, SerializedPropertyValues::fromArray( + array_map( + fn (SerializedPropertyValue $value) => SerializedPropertyValue::create( + // arbitrary deep nested + [[$value->value]], + 'array' + ), + iterator_to_array($properties) + ) + )]; + } + } + + public function emptySearchTermAlwaysMatches(): iterable + { + yield '1 property' => ['', self::value('foo')]; + yield '1 empty property' => ['', self::value('foo')]; + yield '0 properties' => ['', SerializedPropertyValues::fromArray([])]; + } + + public function notMatchingExamples(): iterable + { + yield 'different chars' => ['aepfel', self::value('äpfel')]; + yield 'upper boolean string representation' => ['TRUE', self::value(true)]; + yield 'string not found inside string' => ['reptv', self::value('eras tour')]; + yield 'integer' => ['0999', self::value(999)]; + yield 'float with comma' => ['12,45', self::value(12.34)]; + yield 'array with unmatched string' => ['hello', self::value(['hi'])]; + yield 'array key is not considered matching' => ['key', self::value(['key' => 'foo'])]; + yield 'nested array key is not considered matching' => ['key', self::value([['key' => 'foo']])]; + } + + /** + * @test + * @dataProvider matchingStringComparisonExamples + * @dataProvider matchingNumberLikeComparisonExamples + * @dataProvider matchingBooleanLikeComparisonExamples + * @dataProvider matchingArrayComparisonExamples + * @dataProvider emptySearchTermAlwaysMatches + */ + public function searchTermMatchesProperties( + string $searchTerm, + SerializedPropertyValues $properties, + ) { + self::assertTrue( + SearchTermMatcher::matchesSerializedPropertyValues( + $properties, + SearchTerm::fulltext($searchTerm) + ) + ); + } + + /** + * @test + * @dataProvider notMatchingExamples + */ + public function searchTermDoesntMatchesProperties( + string $searchTerm, + SerializedPropertyValues $properties, + ) { + self::assertFalse( + SearchTermMatcher::matchesSerializedPropertyValues( + $properties, + SearchTerm::fulltext($searchTerm) + ) + ); + } + + private static function value(string|bool|float|int|array|\ArrayObject $value): SerializedPropertyValues + { + return SerializedPropertyValues::fromArray([ + 'test-property' => SerializedPropertyValue::create( + $value, + '' + ), + ]); + } +} diff --git a/Neos.Neos/Classes/Controller/Service/NodesController.php b/Neos.Neos/Classes/Controller/Service/NodesController.php index 5d35b1bb1ff..2e5090b31dc 100644 --- a/Neos.Neos/Classes/Controller/Service/NodesController.php +++ b/Neos.Neos/Classes/Controller/Service/NodesController.php @@ -23,8 +23,12 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\ExpandedNodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTerm; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\SearchTerm\SearchTermMatcher; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; +use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -110,6 +114,11 @@ public function indexAction( string $contextNode = null, array|string $nodeIdentifiers = [] ): void { + $searchTerm = SearchTerm::fulltext($searchTerm); + $nodeTypeCriteria = NodeTypeCriteria::create( + NodeTypeNames::fromStringArray($nodeTypes), + NodeTypeNames::createEmpty() + ); $nodeIds = $nodeIds ?: $nodeIdentifiers; $nodeIds = is_array($nodeIds) ? $nodeIds : [$nodeIds]; $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) @@ -140,6 +149,7 @@ public function indexAction( ); } + $nodes = []; if ($nodeIds === [] && (!is_null($nodeAddress) || !is_null($nodePath))) { if (!is_null($nodeAddress)) { $entryNode = $subgraph->findNodeById($nodeAddress->aggregateId); @@ -148,22 +158,28 @@ public function indexAction( $entryNode = $subgraph->findNodeByAbsolutePath($nodePath); } - $nodes = !is_null($entryNode) ? $subgraph->findDescendantNodes( - $entryNode->aggregateId, - FindDescendantNodesFilter::create( - nodeTypes: NodeTypeCriteria::create( - NodeTypeNames::fromStringArray($nodeTypes), - NodeTypeNames::createEmpty() - ), - searchTerm: $searchTerm, - ) - ) : []; + if (!is_null($entryNode)) { + $nodes = $subgraph->findDescendantNodes( + $entryNode->aggregateId, + FindDescendantNodesFilter::create( + nodeTypes: $nodeTypeCriteria, + searchTerm: $searchTerm, + ) + ); + if ( + SearchTermMatcher::matchesNode($entryNode, $searchTerm) + && ExpandedNodeTypeCriteria::create($nodeTypeCriteria, $contentRepository->getNodeTypeManager()) + ->matches($entryNode->nodeTypeName) + ) { + // include the starting node if it matches + $nodes = $nodes->prepend($entryNode); + } + } } else { - if (!empty($searchTerm)) { + if ($searchTerm->term !== '') { throw new \RuntimeException('Combination of $nodeIdentifiers and $searchTerm not supported'); } - $nodes = []; foreach ($nodeIds as $nodeAggregateId) { $node = $subgraph->findNodeById( NodeAggregateId::fromString($nodeAggregateId) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0dd318d2ae9..e2c8ce3fc5d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -165,6 +165,11 @@ parameters: count: 1 path: Neos.Neos/Classes/Controller/Module/User/UserSettingsController.php + - + message: "#^The internal method \"Neos\\\\ContentRepository\\\\Core\\\\Projection\\\\ContentGraph\\\\Filter\\\\NodeType\\\\ExpandedNodeTypeCriteria\\:\\:matches\" is called\\.$#" + count: 1 + path: Neos.Neos/Classes/Controller/Service/NodesController.php + - message: "#^The internal method \"Neos\\\\ContentRepository\\\\Core\\\\DimensionSpace\\\\WeightedDimensionSpacePoint\\:\\:getIdentityHash\" is called\\.$#" count: 3