Skip to content

Commit

Permalink
Merge pull request #4519 from neos/feature/4517-findClosestAncestorNode
Browse files Browse the repository at this point in the history
FEATURE: Add `ContentSubgraphInterface::findClosestNode()`
  • Loading branch information
bwaidelich authored Sep 21, 2023
2 parents 925ddc7 + bd9fe16 commit f82d8e2
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Projection\ContentGraph\Filter;

use Neos\ContentRepository\Core\Projection\ContentGraph\NodeTypeConstraints;

/**
* Immutable filter DTO for {@see ContentSubgraphInterface::findClosestNode()}
*
* Example:
*
* FindClosestAncestorNodeFilter::create(nodeTypeConstraints: 'Some.Included:NodeType,!Some.Excluded:NodeType');
*
* @api for the factory methods; NOT for the inner state.
*/
final class FindClosestNodeFilter
{
/**
* @internal (the properties themselves are readonly; only the write-methods are API.
*/
private function __construct(
public readonly NodeTypeConstraints $nodeTypeConstraints
) {
}

/**
* Creates an instance with the specified filter options
*
* Note: The signature of this method might be extended in the future, so it should always be used with named arguments
* @see https://www.php.net/manual/en/functions.arguments.php#functions.named-arguments
*/
public static function create(
NodeTypeConstraints|string $nodeTypeConstraints
): self {
if (is_string($nodeTypeConstraints)) {
$nodeTypeConstraints = NodeTypeConstraints::fromFilterString($nodeTypeConstraints);
}
return new self($nodeTypeConstraints);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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;
Expand Down Expand Up @@ -283,6 +284,19 @@ public function iExecuteTheFindAncestorNodesQueryIExpectTheFollowingNodes(string
Assert::assertSame($expectedTotalCount ?? count($expectedNodeIds), $actualCount, 'countAncestorNodes returned an unexpected result');
}

/**
* @When /^I execute the findClosestNode query for entry node aggregate id "(?<entryNodeIdSerialized>[^"]*)"(?: and filter '(?<filterSerialized>[^']*)')? I expect (?:the node "(?<expectedNodeId>[^"]*)"|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
*/
Expand Down

0 comments on commit f82d8e2

Please sign in to comment.