diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index 40225e2c489..ea068498bdb 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -34,6 +34,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindAncestorNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindBackReferencesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindReferencesFilter; @@ -355,6 +356,50 @@ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, CountA ); } + public function findClosestNode(NodeAggregateId $entryNodeAggregateId, FindClosestNodeFilter $filter): ?Node + { + $queryBuilderInitial = $this->createQueryBuilder() + ->select('n.*, ph.name, ph.contentstreamid, ph.parentnodeanchor') + ->from($this->tableNamePrefix . '_node', 'n') + // we need to join with the hierarchy relation, because we need the node name. + ->innerJoin('n', $this->tableNamePrefix . '_hierarchyrelation', 'ph', 'n.relationanchorpoint = ph.childnodeanchor') + ->andWhere('ph.contentstreamid = :contentStreamId') + ->andWhere('ph.dimensionspacepointhash = :dimensionSpacePointHash') + ->andWhere('n.nodeaggregateid = :entryNodeAggregateId'); + $this->addRestrictionRelationConstraints($queryBuilderInitial, 'n', 'ph'); + + $queryBuilderRecursive = $this->createQueryBuilder() + ->select('p.*, h.name, h.contentstreamid, h.parentnodeanchor') + ->from('ancestry', 'c') + ->innerJoin('c', $this->tableNamePrefix . '_node', 'p', 'p.relationanchorpoint = c.parentnodeanchor') + ->innerJoin('p', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.childnodeanchor = p.relationanchorpoint') + ->where('h.contentstreamid = :contentStreamId') + ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash'); + $this->addRestrictionRelationConstraints($queryBuilderRecursive, 'p'); + + $queryBuilderCte = $this->createQueryBuilder() + ->select('*') + ->from('ancestry', 'p') + ->setMaxResults(1) + ->setParameter('contentStreamId', $this->contentStreamId->value) + ->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->setParameter('entryNodeAggregateId', $entryNodeAggregateId->value); + if ($filter->nodeTypeConstraints !== null) { + $this->addNodeTypeConstraints($queryBuilderCte, $filter->nodeTypeConstraints, 'p'); + } + $nodeRows = $this->fetchCteResults( + $queryBuilderInitial, + $queryBuilderRecursive, + $queryBuilderCte, + 'ancestry' + ); + return $this->nodeFactory->mapNodeRowsToNodes( + $nodeRows, + $this->dimensionSpacePoint, + $this->visibilityConstraints + )->first(); + } + public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter $filter): Nodes { ['queryBuilderInitial' => $queryBuilderInitial, 'queryBuilderRecursive' => $queryBuilderRecursive, 'queryBuilderCte' => $queryBuilderCte] = $this->buildDescendantNodesQueries($entryNodeAggregateId, $filter); @@ -666,7 +711,7 @@ private function buildSiblingsQuery(bool $preceding, NodeAggregateId $siblingNod /** * @return array{queryBuilderInitial: QueryBuilder, queryBuilderRecursive: QueryBuilder, queryBuilderCte: QueryBuilder} */ - private function buildAncestorNodesQueries(NodeAggregateId $entryNodeAggregateId, FindAncestorNodesFilter|CountAncestorNodesFilter $filter): array + private function buildAncestorNodesQueries(NodeAggregateId $entryNodeAggregateId, FindAncestorNodesFilter|CountAncestorNodesFilter|FindClosestNodeFilter $filter): array { $queryBuilderInitial = $this->createQueryBuilder() ->select('n.*, ph.name, ph.contentstreamid, ph.parentnodeanchor') diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php index b6b8223e78f..6f1bf311ec7 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php @@ -31,6 +31,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindBackReferencesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindReferencesFilter; @@ -457,6 +458,13 @@ public function countAncestorNodes( return 0; } + public function findClosestNode( + NodeAggregateId $entryNodeAggregateId, + FindClosestNodeFilter $filter + ): ?Node { + return null; + } + public function findDescendantNodes( NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter $filter diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ClosestNode.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ClosestNode.feature new file mode 100644 index 00000000000..908f37fa4f7 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/ClosestNode.feature @@ -0,0 +1,98 @@ +@contentrepository @adapters=DoctrineDBAL + # TODO implement for Postgres +Feature: Find nodes using the findClosestNode query + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, ch | ch->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': [] + 'Neos.ContentRepository.Testing:AbstractPage': + abstract: true + 'Neos.ContentRepository.Testing:SomeMixin': + abstract: true + 'Neos.ContentRepository.Testing:Homepage': + superTypes: + 'Neos.ContentRepository.Testing:AbstractPage': true + childNodes: + terms: + type: 'Neos.ContentRepository.Testing:Terms' + contact: + type: 'Neos.ContentRepository.Testing:Contact' + + 'Neos.ContentRepository.Testing:Terms': + superTypes: + 'Neos.ContentRepository.Testing:AbstractPage': true + properties: + text: + defaultValue: 'Terms default' + 'Neos.ContentRepository.Testing:Contact': + superTypes: + 'Neos.ContentRepository.Testing:AbstractPage': true + 'Neos.ContentRepository.Testing:SomeMixin': true + properties: + text: + defaultValue: 'Contact default' + 'Neos.ContentRepository.Testing:Page': + superTypes: + 'Neos.ContentRepository.Testing:AbstractPage': true + 'Neos.ContentRepository.Testing:SpecialPage': + superTypes: + 'Neos.ContentRepository.Testing:AbstractPage': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + And the graph projection is fully up to date + And I am in content stream "cs-identifier" and dimension space point {"language":"de"} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the graph projection is fully up to date + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | nodeTypeName | parentNodeAggregateId | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | home | home | Neos.ContentRepository.Testing:Homepage | lady-eleonode-rootford | {} | {"terms": "terms", "contact": "contact"} | + | a | a | Neos.ContentRepository.Testing:Page | home | {} | {} | + | a1 | a1 | Neos.ContentRepository.Testing:Page | a | {} | {} | + | a2 | a2 | Neos.ContentRepository.Testing:Page | a | {} | {} | + | a2a | a2a | Neos.ContentRepository.Testing:SpecialPage | a2 | {} | {} | + | a2a1 | a2a1 | Neos.ContentRepository.Testing:Page | a2a | {} | {} | + | a2a2 | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {} | {} | + | a2a2a | a2a2a | Neos.ContentRepository.Testing:Page | a2a2 | {} | {} | + | a2a2b | a2a2b | Neos.ContentRepository.Testing:Page | a2a2 | {} | {} | + | a2b | a2b | Neos.ContentRepository.Testing:Page | a2 | {} | {} | + | a2b1 | a2b1 | Neos.ContentRepository.Testing:Page | a2b | {} | {} | + | b | b | Neos.ContentRepository.Testing:Page | home | {} | {} | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2a2a" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2b" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + + Scenario: + # findClosestNode queries without results +# When I execute the findClosestNode query for entry node aggregate id "non-existing" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:Page"}' I expect no node to be returned +# # a2a2a is disabled +# When I execute the findClosestNode query for entry node aggregate id "a2a2a" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:Page"}' I expect no node to be returned +# # a2b is disabled +# When I execute the findClosestNode query for entry node aggregate id "a2b1" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:Page"}' I expect no node to be returned + + # findClosestNode queries with results + When I execute the findClosestNode query for entry node aggregate id "a2a2b" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:Page"}' I expect the node "a2a2b" to be returned + When I execute the findClosestNode query for entry node aggregate id "a2a2b" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:SpecialPage"}' I expect the node "a2a" to be returned + When I execute the findClosestNode query for entry node aggregate id "a2a2b" and filter '{"nodeTypeConstraints": "!Neos.ContentRepository.Testing:Page,!Neos.ContentRepository.Testing:SpecialPage"}' I expect the node "home" to be returned + When I execute the findClosestNode query for entry node aggregate id "a2a" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:SpecialPage"}' I expect the node "a2a" to be returned diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php index 5d81da82a05..85c2c94b614 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php @@ -227,6 +227,12 @@ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter return $this->wrappedContentSubgraph->countAncestorNodes($entryNodeAggregateId, $filter); } + public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\FindClosestNodeFilter $filter): ?Node + { + // TODO: Implement findClosestNode() method. + return $this->wrappedContentSubgraph->findClosestNode($entryNodeAggregateId, $filter); + } + public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter $filter): Nodes { // TODO: implement runtime caches diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php index 395538e96fa..55a5572edf8 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php @@ -109,6 +109,14 @@ public function findAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\ */ public function countAncestorNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountAncestorNodesFilter $filter): int; + /** + * Find the closest matching node, the entry node itself or the first ancestors of the $entryNodeAggregateId, that match the specified $filter and return it + * Note: in contrast to {@see findAncestorNodes()} the resulting node will be the entry node if it matches the filter! + * + * @return Node|null the closest node that matches the given $filter, or NULL if no matching node was found + */ + public function findClosestNode(NodeAggregateId $entryNodeAggregateId, Filter\FindClosestNodeFilter $filter): ?Node; + /** * Recursively find all nodes underneath the $entryNodeAggregateId that match the specified $filter and return them as a flat list * diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindClosestNodeFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindClosestNodeFilter.php new file mode 100644 index 00000000000..1db23c0d90f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindClosestNodeFilter.php @@ -0,0 +1,42 @@ +[^"]*)"(?: and filter '(?[^']*)')? I expect (?:the node "(?[^"]*)"|no node) to be returned?$/ + */ + public function iExecuteTheFindClosestNodeQueryIExpectTheFollowingNodes(string $entryNodeIdSerialized, string $filterSerialized = '', string $expectedNodeId = null): void + { + $entryNodeAggregateId = NodeAggregateId::fromString($entryNodeIdSerialized); + $filterValues = !empty($filterSerialized) ? json_decode($filterSerialized, true, 512, JSON_THROW_ON_ERROR) : []; + $filter = FindClosestNodeFilter::create(...$filterValues); + $subgraph = $this->getCurrentSubgraph(); + $actualNodeId = $subgraph->findClosestNode($entryNodeAggregateId, $filter)?->nodeAggregateId->value; + Assert::assertSame($expectedNodeId, $actualNodeId, 'findClosestNode returned an unexpected result'); + } + /** * @When I execute the countNodes query I expect the result to be :expectedResult */