diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php index af185fb285e..36c24163ace 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -41,7 +41,6 @@ require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeAuthorizationTrait.php'); require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php'); require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/StructureAdjustmentsTrait.php'); -require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php'); require_once(__DIR__ . '/ProjectionIntegrityViolationDetectionTrait.php'); require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/EventSourcedTrait.php'); diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index 2ce39ca4e8f..5598c44c7a3 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -40,6 +40,7 @@ use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeGeneralizationVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodePeerVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeSpecializationVariantWasCreated; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Infrastructure\DbalClientInterface; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; @@ -160,6 +161,7 @@ public function canHandle(Event $event): bool $eventClassName = $this->eventNormalizer->getEventClassName($event); return in_array($eventClassName, [ RootNodeAggregateWithNodeWasCreated::class, + RootNodeAggregateDimensionsWereUpdated::class, NodeAggregateWithNodeWasCreated::class, NodeAggregateNameWasChanged::class, ContentStreamWasForked::class, @@ -204,6 +206,8 @@ private function apply(EventEnvelope $eventEnvelope, CatchUpHookInterface $catch if ($eventInstance instanceof RootNodeAggregateWithNodeWasCreated) { $this->whenRootNodeAggregateWithNodeWasCreated($eventInstance); + } elseif ($eventInstance instanceof RootNodeAggregateDimensionsWereUpdated) { + $this->whenRootNodeAggregateDimensionsWereUpdated($eventInstance); } elseif ($eventInstance instanceof NodeAggregateWithNodeWasCreated) { $this->whenNodeAggregateWithNodeWasCreated($eventInstance); } elseif ($eventInstance instanceof NodeAggregateNameWasChanged) { @@ -275,12 +279,12 @@ public function markStale(): void private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNodeWasCreated $event): void { $nodeRelationAnchorPoint = NodeRelationAnchorPoint::create(); - $dimensionSpacePoint = DimensionSpacePoint::fromArray([]); + $originDimensionSpacePoint = OriginDimensionSpacePoint::fromArray([]); $node = new NodeRecord( $nodeRelationAnchorPoint, $event->nodeAggregateId, - $dimensionSpacePoint->coordinates, - $dimensionSpacePoint->hash, + $originDimensionSpacePoint->coordinates, + $originDimensionSpacePoint->hash, SerializedPropertyValues::fromArray([]), $event->nodeTypeName, $event->nodeAggregateClassification @@ -298,6 +302,47 @@ private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNo }); } + /** + * @throws \Throwable + */ + private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDimensionsWereUpdated $event): void + { + $rootNodeAnchorPoint = $this->projectionContentGraph + ->getAnchorPointForNodeAndOriginDimensionSpacePointAndContentStream( + $event->nodeAggregateId, + /** the origin DSP of the root node is always the empty dimension ({@see whenRootNodeAggregateWithNodeWasCreated}) */ + OriginDimensionSpacePoint::fromArray([]), + $event->contentStreamId + ); + if ($rootNodeAnchorPoint === null) { + // should never happen. + return; + } + + $this->transactional(function () use ($rootNodeAnchorPoint, $event) { + // delete all hierarchy edges of the root node + $this->getDatabaseConnection()->executeUpdate(' + DELETE FROM ' . $this->tableNamePrefix . '_hierarchyrelation + WHERE + parentnodeanchor = :parentNodeAnchor + AND childnodeanchor = :childNodeAnchor + AND contentstreamid = :contentStreamId + ', [ + 'parentNodeAnchor' => (string)NodeRelationAnchorPoint::forRootEdge(), + 'childNodeAnchor' => (string)$rootNodeAnchorPoint, + 'contentStreamId' => (string)$event->contentStreamId + ]); + // recreate hierarchy edges for the root node + $this->connectHierarchy( + $event->contentStreamId, + NodeRelationAnchorPoint::forRootEdge(), + $rootNodeAnchorPoint, + $event->coveredDimensionSpacePoints, + null + ); + }); + } + /** * @throws \Throwable */ diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php index 1d692793c10..de835f37d07 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php @@ -20,6 +20,8 @@ use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\NodeRelationAnchorPoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphWithRuntimeCaches\ContentSubgraphWithRuntimeCaches; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregates; use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFoundException; use Neos\ContentRepository\Core\Infrastructure\DbalClientInterface; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; @@ -115,13 +117,40 @@ public function findNodeByIdAndOriginDimensionSpacePoint( ) : null; } - /** - * @throws \Exception - */ public function findRootNodeAggregateByType( ContentStreamId $contentStreamId, NodeTypeName $nodeTypeName ): NodeAggregate { + $rootNodeAggregates = $this->findRootNodeAggregates( + $contentStreamId, + FindRootNodeAggregatesFilter::nodeTypeName($nodeTypeName) + ); + + if ($rootNodeAggregates->count() > 1) { + $ids = []; + foreach ($rootNodeAggregates as $rootNodeAggregate) { + $ids[] = $rootNodeAggregate->nodeAggregateId->value; + } + throw new \RuntimeException(sprintf( + 'More than one root node aggregate of type "%s" found (IDs: %s).', + $nodeTypeName->value, + implode(', ', $ids) + )); + } + + $rootNodeAggregate = $rootNodeAggregates->first(); + + if ($rootNodeAggregate === null) { + throw new \RuntimeException('Root Node Aggregate not found'); + } + + return $rootNodeAggregate; + } + + public function findRootNodeAggregates( + ContentStreamId $contentStreamId, + FindRootNodeAggregatesFilter $filter, + ): NodeAggregates { $connection = $this->client->getConnection(); $query = 'SELECT n.*, h.contentstreamid, h.name, h.dimensionspacepoint AS covereddimensionspacepoint @@ -129,28 +158,29 @@ public function findRootNodeAggregateByType( JOIN ' . $this->tableNamePrefix . '_hierarchyrelation h ON h.childnodeanchor = n.relationanchorpoint WHERE h.contentstreamid = :contentStreamId - AND h.parentnodeanchor = :rootEdgeParentAnchorId - AND n.nodetypename = :nodeTypeName'; + AND h.parentnodeanchor = :rootEdgeParentAnchorId '; $parameters = [ 'contentStreamId' => (string)$contentStreamId, 'rootEdgeParentAnchorId' => (string)NodeRelationAnchorPoint::forRootEdge(), - 'nodeTypeName' => (string)$nodeTypeName, ]; - $nodeRow = $connection->executeQuery($query, $parameters)->fetchAssociative(); - - if (!is_array($nodeRow)) { - throw new \RuntimeException('Root Node Aggregate not found'); + if ($filter->nodeTypeName !== null) { + $query .= ' AND n.nodetypename = :nodeTypeName'; + $parameters['nodeTypeName'] = (string)$filter->nodeTypeName; } - /** @var NodeAggregate $nodeAggregate The factory will return a NodeAggregate since the array is not empty */ - $nodeAggregate = $this->nodeFactory->mapNodeRowsToNodeAggregate( - [$nodeRow], + + $nodeRows = $connection->executeQuery($query, $parameters)->fetchAllAssociative(); + + + /** @var \Traversable $nodeAggregates The factory will return a NodeAggregate since the array is not empty */ + $nodeAggregates = $this->nodeFactory->mapNodeRowsToNodeAggregates( + $nodeRows, VisibilityConstraints::withoutRestrictions() ); - return $nodeAggregate; + return NodeAggregates::fromArray(iterator_to_array($nodeAggregates)); } public function findNodeAggregatesByType( diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index cec017e0bda..08a929e6d68 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -23,6 +23,11 @@ use Neos\ContentRepository\Core\Infrastructure\DbalClientInterface; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountBackReferencesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountDescendantNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountReferencesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; @@ -84,20 +89,7 @@ public function __construct( public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes { - $queryBuilder = $this->createQueryBuilder() - ->select('n.*, h.name, h.contentstreamid') - ->from($this->tableNamePrefix . '_node', 'pn') - ->innerJoin('pn', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.parentnodeanchor = pn.relationanchorpoint') - ->innerJoin('pn', $this->tableNamePrefix . '_node', 'n', 'h.childnodeanchor = n.relationanchorpoint') - ->where('pn.nodeaggregateid = :parentNodeAggregateId')->setParameter('parentNodeAggregateId', $parentNodeAggregateId->value) - ->andWhere('h.contentstreamid = :contentStreamId')->setParameter('contentStreamId', $this->contentStreamId->value) - ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash')->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) - ->orderBy('h.position', 'ASC'); - if ($filter->nodeTypeConstraints !== null) { - $this->addNodeTypeConstraints($queryBuilder, $filter->nodeTypeConstraints); - } - $this->addRestrictionRelationConstraints($queryBuilder); - + $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter); if ($filter->limit !== null) { $queryBuilder->setMaxResults($filter->limit); } @@ -107,59 +99,43 @@ public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChild return $this->fetchNodes($queryBuilder); } + public function countChildNodes(NodeAggregateId $parentNodeAggregateId, CountChildNodesFilter $filter): int + { + $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter); + return $this->fetchCount($queryBuilder); + } + public function findReferences(NodeAggregateId $nodeAggregateId, FindReferencesFilter $filter): References { - $queryBuilder = $this->createQueryBuilder() - ->select('dn.*, dh.name, dh.contentstreamid, r.name AS referencename, r.properties AS referenceproperties') - ->from($this->tableNamePrefix . '_hierarchyrelation', 'sh') - ->innerJoin('sh', $this->tableNamePrefix . '_node', 'sn', 'sn.relationanchorpoint = sh.childnodeanchor') - ->innerJoin('sh', $this->tableNamePrefix . '_referencerelation', 'r', 'r.nodeanchorpoint = sn.relationanchorpoint') - ->innerJoin('sh', $this->tableNamePrefix . '_node', 'dn', 'dn.nodeaggregateid = r.destinationnodeaggregateid') - ->innerJoin('sh', $this->tableNamePrefix . '_hierarchyrelation', 'dh', 'dh.childnodeanchor = dn.relationanchorpoint') - ->where('sn.nodeaggregateid = :nodeAggregateId')->setParameter('nodeAggregateId', $nodeAggregateId->value) - ->andWhere('dh.dimensionspacepointhash = :dimensionSpacePointHash')->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) - ->andWhere('sh.dimensionspacepointhash = :dimensionSpacePointHash') - ->andWhere('dh.contentstreamid = :contentStreamId')->setParameter('contentStreamId', $this->contentStreamId->value) - ->andWhere('sh.contentstreamid = :contentStreamId'); + $queryBuilder = $this->buildReferencesQuery(false, $nodeAggregateId, $filter); if ($filter->referenceName === null) { $queryBuilder->addOrderBy('r.name'); } $queryBuilder->addOrderBy('r.position'); - $this->addRestrictionRelationConstraints($queryBuilder, 'dn', 'dh'); - $this->addRestrictionRelationConstraints($queryBuilder, 'sn', 'sh'); - if ($filter->referenceName !== null) { - $queryBuilder->andWhere('r.name = :referenceName')->setParameter('referenceName', $filter->referenceName->value); - } return $this->fetchReferences($queryBuilder); } + public function countReferences(NodeAggregateId $nodeAggregateId, CountReferencesFilter $filter): int + { + return $this->fetchCount($this->buildReferencesQuery(false, $nodeAggregateId, $filter)); + } + public function findBackReferences(NodeAggregateId $nodeAggregateId, FindBackReferencesFilter $filter): References { - $queryBuilder = $this->createQueryBuilder() - ->select('sn.*, sh.name, sh.contentstreamid, r.name AS referencename, r.properties AS referenceproperties') - ->from($this->tableNamePrefix . '_hierarchyrelation', 'sh') - ->innerJoin('sh', $this->tableNamePrefix . '_node', 'sn', 'sn.relationanchorpoint = sh.childnodeanchor') - ->innerJoin('sh', $this->tableNamePrefix . '_referencerelation', 'r', 'r.nodeanchorpoint = sn.relationanchorpoint') - ->innerJoin('sh', $this->tableNamePrefix . '_node', 'dn', 'dn.nodeaggregateid = r.destinationnodeaggregateid') - ->innerJoin('sh', $this->tableNamePrefix . '_hierarchyrelation', 'dh', 'dh.childnodeanchor = dn.relationanchorpoint') - ->where('dn.nodeaggregateid = :nodeAggregateId')->setParameter('nodeAggregateId', $nodeAggregateId->value) - ->andWhere('dh.dimensionspacepointhash = :dimensionSpacePointHash')->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) - ->andWhere('sh.dimensionspacepointhash = :dimensionSpacePointHash') - ->andWhere('dh.contentstreamid = :contentStreamId')->setParameter('contentStreamId', $this->contentStreamId->value) - ->andWhere('sh.contentstreamid = :contentStreamId'); + $queryBuilder = $this->buildReferencesQuery(true, $nodeAggregateId, $filter); if ($filter->referenceName === null) { $queryBuilder->addOrderBy('r.name'); } $queryBuilder->addOrderBy('r.position'); $queryBuilder->addOrderBy('sn.nodeaggregateid'); - $this->addRestrictionRelationConstraints($queryBuilder, 'dn', 'dh'); - $this->addRestrictionRelationConstraints($queryBuilder, 'sn', 'sh'); - if ($filter->referenceName !== null) { - $queryBuilder->andWhere('r.name = :referenceName')->setParameter('referenceName', $filter->referenceName->value); - } return $this->fetchReferences($queryBuilder); } + public function countBackReferences(NodeAggregateId $nodeAggregateId, CountBackReferencesFilter $filter): int + { + return $this->fetchCount($this->buildReferencesQuery(true, $nodeAggregateId, $filter)); + } + public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { $queryBuilder = $this->createQueryBuilder() @@ -319,48 +295,17 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, FindSubtreeFi public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter $filter): Nodes { - $queryBuilderInitial = $this->createQueryBuilder() - // @see https://mariadb.com/kb/en/library/recursive-common-table-expressions-overview/#cast-to-avoid-data-truncation - ->select('n.*, h.name, h.contentstreamid, CAST("ROOT" AS CHAR(50)) AS parentNodeAggregateId, 0 AS level, 0 AS position') - ->from($this->tableNamePrefix . '_node', 'n') - // we need to join with the hierarchy relation, because we need the node name. - ->innerJoin('n', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.childnodeanchor = n.relationanchorpoint') - ->innerJoin('n', $this->tableNamePrefix . '_node', 'p', 'p.relationanchorpoint = h.parentnodeanchor') - ->innerJoin('n', $this->tableNamePrefix . '_hierarchyrelation', 'ph', 'ph.childnodeanchor = p.relationanchorpoint') - ->where('h.contentstreamid = :contentStreamId') - ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash') - ->andWhere('ph.contentstreamid = :contentStreamId') - ->andWhere('ph.dimensionspacepointhash = :dimensionSpacePointHash') - ->andWhere('p.nodeaggregateid = :entryNodeAggregateId'); - $this->addRestrictionRelationConstraints($queryBuilderInitial); - - $queryBuilderRecursive = $this->createQueryBuilder() - ->select('c.*, h.name, h.contentstreamid, p.nodeaggregateid AS parentNodeAggregateId, p.level + 1 AS level, h.position') - ->from('tree', 'p') - ->innerJoin('p', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.parentnodeanchor = p.relationanchorpoint') - ->innerJoin('p', $this->tableNamePrefix . '_node', 'c', 'c.relationanchorpoint = h.childnodeanchor') - ->where('h.contentstreamid = :contentStreamId') - ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash'); - $this->addRestrictionRelationConstraints($queryBuilderRecursive, 'c'); - - $queryBuilderCte = $this->createQueryBuilder() - ->select('*') - ->from('tree') - ->orderBy('level') - ->addOrderBy('position') - ->setParameter('contentStreamId', $this->contentStreamId->value) - ->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) - ->setParameter('entryNodeAggregateId', $entryNodeAggregateId->value); - if ($filter->nodeTypeConstraints !== null) { - $this->addNodeTypeConstraints($queryBuilderCte, $filter->nodeTypeConstraints, ''); - } - if ($filter->searchTerm !== null) { - $queryBuilderCte->andWhere('JSON_SEARCH(properties, "one", :searchTermPrefix, NULL, "$.*.value") IS NOT NULL')->setParameter('searchTermPrefix', $filter->searchTerm->term . '%'); - } + ['queryBuilderInitial' => $queryBuilderInitial, 'queryBuilderRecursive' => $queryBuilderRecursive, 'queryBuilderCte' => $queryBuilderCte] = $this->buildDescendantNodesQueries($entryNodeAggregateId, $filter); $nodeRows = $this->fetchCteResults($queryBuilderInitial, $queryBuilderRecursive, $queryBuilderCte, 'tree'); return $this->nodeFactory->mapNodeRowsToNodes($nodeRows, $this->dimensionSpacePoint, $this->visibilityConstraints); } + public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, CountDescendantNodesFilter $filter): int + { + ['queryBuilderInitial' => $queryBuilderInitial, 'queryBuilderRecursive' => $queryBuilderRecursive, 'queryBuilderCte' => $queryBuilderCte] = $this->buildDescendantNodesQueries($entryNodeAggregateId, $filter); + return $this->fetchCteCountResult($queryBuilderInitial, $queryBuilderRecursive, $queryBuilderCte, 'tree'); + } + public function countNodes(): int { $queryBuilder = $this->createQueryBuilder() @@ -443,6 +388,48 @@ private function addNodeTypeConstraints(QueryBuilder $queryBuilder, NodeTypeCons } } + private function buildChildNodesQuery(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter|CountChildNodesFilter $filter): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder() + ->select('n.*, h.name, h.contentstreamid') + ->from($this->tableNamePrefix . '_node', 'pn') + ->innerJoin('pn', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.parentnodeanchor = pn.relationanchorpoint') + ->innerJoin('pn', $this->tableNamePrefix . '_node', 'n', 'h.childnodeanchor = n.relationanchorpoint') + ->where('pn.nodeaggregateid = :parentNodeAggregateId')->setParameter('parentNodeAggregateId', $parentNodeAggregateId->value) + ->andWhere('h.contentstreamid = :contentStreamId')->setParameter('contentStreamId', $this->contentStreamId->value) + ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash')->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->orderBy('h.position', 'ASC'); + if ($filter->nodeTypeConstraints !== null) { + $this->addNodeTypeConstraints($queryBuilder, $filter->nodeTypeConstraints); + } + $this->addRestrictionRelationConstraints($queryBuilder); + return $queryBuilder; + } + + private function buildReferencesQuery(bool $backReferences, NodeAggregateId $nodeAggregateId, FindReferencesFilter|FindBackReferencesFilter|CountReferencesFilter|CountBackReferencesFilter $filter): QueryBuilder + { + $sourceTablePrefix = $backReferences ? 'd' : 's'; + $destinationTablePrefix = $backReferences ? 's' : 'd'; + $queryBuilder = $this->createQueryBuilder() + ->select("{$destinationTablePrefix}n.*, {$destinationTablePrefix}h.name, {$destinationTablePrefix}h.contentstreamid, r.name AS referencename, r.properties AS referenceproperties") + ->from($this->tableNamePrefix . '_hierarchyrelation', 'sh') + ->innerJoin('sh', $this->tableNamePrefix . '_node', 'sn', 'sn.relationanchorpoint = sh.childnodeanchor') + ->innerJoin('sh', $this->tableNamePrefix . '_referencerelation', 'r', 'r.nodeanchorpoint = sn.relationanchorpoint') + ->innerJoin('sh', $this->tableNamePrefix . '_node', 'dn', 'dn.nodeaggregateid = r.destinationnodeaggregateid') + ->innerJoin('sh', $this->tableNamePrefix . '_hierarchyrelation', 'dh', 'dh.childnodeanchor = dn.relationanchorpoint') + ->where("{$sourceTablePrefix}n.nodeaggregateid = :nodeAggregateId")->setParameter('nodeAggregateId', $nodeAggregateId->value) + ->andWhere('dh.dimensionspacepointhash = :dimensionSpacePointHash')->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->andWhere('sh.dimensionspacepointhash = :dimensionSpacePointHash') + ->andWhere('dh.contentstreamid = :contentStreamId')->setParameter('contentStreamId', $this->contentStreamId->value) + ->andWhere('sh.contentstreamid = :contentStreamId'); + $this->addRestrictionRelationConstraints($queryBuilder, 'dn', 'dh'); + $this->addRestrictionRelationConstraints($queryBuilder, 'sn', 'sh'); + if ($filter->referenceName !== null) { + $queryBuilder->andWhere('r.name = :referenceName')->setParameter('referenceName', $filter->referenceName->value); + } + return $queryBuilder; + } + private function buildSiblingsQuery(bool $preceding, NodeAggregateId $siblingNodeAggregateId, ?NodeTypeConstraints $nodeTypeConstraints, ?int $limit, ?int $offset): QueryBuilder { $parentNodeAnchorSubQuery = $this->createQueryBuilder() @@ -485,6 +472,52 @@ private function buildSiblingsQuery(bool $preceding, NodeAggregateId $siblingNod return $queryBuilder; } + /** + * @return array{queryBuilderInitial: QueryBuilder, queryBuilderRecursive: QueryBuilder, queryBuilderCte: QueryBuilder} + */ + private function buildDescendantNodesQueries(NodeAggregateId $entryNodeAggregateId, FindDescendantNodesFilter|CountDescendantNodesFilter $filter): array + { + $queryBuilderInitial = $this->createQueryBuilder() + // @see https://mariadb.com/kb/en/library/recursive-common-table-expressions-overview/#cast-to-avoid-data-truncation + ->select('n.*, h.name, h.contentstreamid, CAST("ROOT" AS CHAR(50)) AS parentNodeAggregateId, 0 AS level, 0 AS position') + ->from($this->tableNamePrefix . '_node', 'n') + // we need to join with the hierarchy relation, because we need the node name. + ->innerJoin('n', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.childnodeanchor = n.relationanchorpoint') + ->innerJoin('n', $this->tableNamePrefix . '_node', 'p', 'p.relationanchorpoint = h.parentnodeanchor') + ->innerJoin('n', $this->tableNamePrefix . '_hierarchyrelation', 'ph', 'ph.childnodeanchor = p.relationanchorpoint') + ->where('h.contentstreamid = :contentStreamId') + ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash') + ->andWhere('ph.contentstreamid = :contentStreamId') + ->andWhere('ph.dimensionspacepointhash = :dimensionSpacePointHash') + ->andWhere('p.nodeaggregateid = :entryNodeAggregateId'); + $this->addRestrictionRelationConstraints($queryBuilderInitial); + + $queryBuilderRecursive = $this->createQueryBuilder() + ->select('c.*, h.name, h.contentstreamid, p.nodeaggregateid AS parentNodeAggregateId, p.level + 1 AS level, h.position') + ->from('tree', 'p') + ->innerJoin('p', $this->tableNamePrefix . '_hierarchyrelation', 'h', 'h.parentnodeanchor = p.relationanchorpoint') + ->innerJoin('p', $this->tableNamePrefix . '_node', 'c', 'c.relationanchorpoint = h.childnodeanchor') + ->where('h.contentstreamid = :contentStreamId') + ->andWhere('h.dimensionspacepointhash = :dimensionSpacePointHash'); + $this->addRestrictionRelationConstraints($queryBuilderRecursive, 'c'); + + $queryBuilderCte = $this->createQueryBuilder() + ->select('*') + ->from('tree') + ->orderBy('level') + ->addOrderBy('position') + ->setParameter('contentStreamId', $this->contentStreamId->value) + ->setParameter('dimensionSpacePointHash', $this->dimensionSpacePoint->hash) + ->setParameter('entryNodeAggregateId', $entryNodeAggregateId->value); + if ($filter->nodeTypeConstraints !== null) { + $this->addNodeTypeConstraints($queryBuilderCte, $filter->nodeTypeConstraints, ''); + } + if ($filter->searchTerm !== null) { + $queryBuilderCte->andWhere('JSON_SEARCH(properties, "one", :searchTermPrefix, NULL, "$.*.value") IS NOT NULL')->setParameter('searchTermPrefix', $filter->searchTerm->term . '%'); + } + return compact('queryBuilderInitial', 'queryBuilderRecursive', 'queryBuilderCte'); + } + /** * @param QueryBuilder $queryBuilder * @return Result @@ -526,6 +559,15 @@ private function fetchNodes(QueryBuilder $queryBuilder): Nodes return $this->nodeFactory->mapNodeRowsToNodes($nodeRows, $this->dimensionSpacePoint, $this->visibilityConstraints); } + private function fetchCount(QueryBuilder $queryBuilder): int + { + try { + return (int)$this->executeQuery($queryBuilder->select('COUNT(*)')->resetQueryPart('orderBy')->setFirstResult(0)->setMaxResults(1))->fetchOne(); + } catch (DbalDriverException | DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch count: %s', $e->getMessage()), 1679048349, $e); + } + } + private function fetchReferences(QueryBuilder $queryBuilder): References { try { @@ -550,4 +592,16 @@ private function fetchCteResults(QueryBuilder $queryBuilderInitial, QueryBuilder throw new \RuntimeException(sprintf('Failed to fetch CTE result: %s', $e->getMessage()), 1678358108, $e); } } + + private function fetchCteCountResult(QueryBuilder $queryBuilderInitial, QueryBuilder $queryBuilderRecursive, QueryBuilder $queryBuilderCte, string $cteTableName = 'cte'): int + { + $query = 'WITH RECURSIVE ' . $cteTableName . ' AS (' . $queryBuilderInitial->getSQL() . ' UNION ' . $queryBuilderRecursive->getSQL() . ') ' . $queryBuilderCte->select('COUNT(*)')->resetQueryPart('orderBy')->setFirstResult(0)->setMaxResults(1); + $parameters = array_merge($queryBuilderInitial->getParameters(), $queryBuilderRecursive->getParameters(), $queryBuilderCte->getParameters()); + $parameterTypes = array_merge($queryBuilderInitial->getParameterTypes(), $queryBuilderRecursive->getParameterTypes(), $queryBuilderCte->getParameterTypes()); + try { + return (int)$this->client->getConnection()->fetchOne($query, $parameters, $parameterTypes); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch CTE count result: %s', $e->getMessage()), 1679047841, $e); + } + } } diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentHypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentHypergraph.php index 27860a23f08..11307b9262a 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentHypergraph.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentHypergraph.php @@ -24,6 +24,8 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindRootNodeAggregatesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregates; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; @@ -106,7 +108,37 @@ public function findRootNodeAggregateByType( ContentStreamId $contentStreamId, NodeTypeName $nodeTypeName ): NodeAggregate { - throw new \BadMethodCallException('method findRootNodeAggregateByType is not implemented yet.', 1645782874); + $rootNodeAggregates = $this->findRootNodeAggregates( + $contentStreamId, + FindRootNodeAggregatesFilter::nodeTypeName($nodeTypeName) + ); + + if ($rootNodeAggregates->count() > 1) { + $ids = []; + foreach ($rootNodeAggregates as $rootNodeAggregate) { + $ids[] = $rootNodeAggregate->nodeAggregateId->value; + } + throw new \RuntimeException(sprintf( + 'More than one root node aggregate of type "%s" found (IDs: %s).', + $nodeTypeName->value, + implode(', ', $ids) + )); + } + + $rootNodeAggregate = $rootNodeAggregates->first(); + + if ($rootNodeAggregate === null) { + throw new \RuntimeException('Root Node Aggregate not found'); + } + + return $rootNodeAggregate; + } + + public function findRootNodeAggregates( + ContentStreamId $contentStreamId, + FindRootNodeAggregatesFilter $filter, + ): NodeAggregates { + throw new \BadMethodCallException('method findRootNodeAggregates is not implemented yet.', 1645782874); } /** diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php index 4b4816bfc36..1cf9b64e45a 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php @@ -26,6 +26,7 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; @@ -127,6 +128,12 @@ public function findChildNodes( ); } + public function countChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\CountChildNodesFilter $filter): int + { + // TODO: Implement countChildNodes() method. + return 0; + } + public function findReferences( NodeAggregateId $nodeAggregateId, FindReferencesFilter $filter @@ -158,6 +165,12 @@ public function findReferences( ); } + public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountReferencesFilter $filter): int + { + // TODO: Implement countReferences() method. + return 0; + } + public function findBackReferences( NodeAggregateId $nodeAggregateId, FindBackReferencesFilter $filter @@ -190,6 +203,12 @@ public function findBackReferences( ); } + public function countBackReferences(NodeAggregateId $nodeAggregateId, Filter\CountBackReferencesFilter $filter): int + { + // TODO: Implement countBackReferences() method. + return 0; + } + public function findParentNode(NodeAggregateId $childNodeAggregateId): ?Node { $query = HypergraphParentQuery::create($this->contentStreamIdentifier, $this->tableNamePrefix); @@ -408,6 +427,13 @@ public function findDescendantNodes( return Nodes::createEmpty(); } + + public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int + { + // TODO: Implement countDescendantNodes() method. + return 0; + } + /** * @throws \Doctrine\DBAL\Driver\Exception * @throws \Doctrine\DBAL\Exception diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php index b317fc65ef9..ef58483a56c 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Bootstrap/FeatureContext.php @@ -38,7 +38,6 @@ require_once(__DIR__ . '/../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeAuthorizationTrait.php'); require_once(__DIR__ . '/../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php'); require_once(__DIR__ . '/../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/StructureAdjustmentsTrait.php'); -require_once(__DIR__ . '/../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php'); require_once(__DIR__ . '/../../../../../Framework/Neos.Flow/Tests/Behavior/Features/Bootstrap/IsolatedBehatStepsTrait.php'); require_once(__DIR__ . '/../../../../../Framework/Neos.Flow/Tests/Behavior/Features/Bootstrap/SecurityOperationsTrait.php'); @@ -46,7 +45,6 @@ use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\EventSourcedTrait; use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\MigrationsTrait; use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\NodeOperationsTrait; -use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\ReadModelInstantiationTrait; use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\StructureAdjustmentsTrait; use Neos\ContentRepository\Core\Tests\Behavior\Features\Bootstrap\ProjectionIntegrityViolationDetectionTrait; use Neos\Flow\ObjectManagement\ObjectManagerInterface; @@ -68,7 +66,6 @@ class FeatureContext implements \Behat\Behat\Context\Context use EventSourcedTrait; use ProjectionIntegrityViolationDetectionTrait; use StructureAdjustmentsTrait; - use ReadModelInstantiationTrait; use MigrationsTrait; /** diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountBackReferences.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountBackReferences.feature new file mode 100644 index 00000000000..72b7ca6138e --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountBackReferences.feature @@ -0,0 +1,139 @@ +@contentrepository @adapters=DoctrineDBAL + # TODO implement for Postgres +Feature: Count nodes using the countBackReferences query + + Background: + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, ch | ch->de->mul, en->mul | + And I have the following NodeTypes configuration: + """ + 'Neos.ContentRepository:Root': [] + 'Neos.ContentRepository.Testing:AbstractPage': + abstract: true + properties: + text: + type: string + refs: + type: references + properties: + foo: + type: string + ref: + type: reference + properties: + foo: + type: string + '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 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"} | + | c | c | Neos.ContentRepository.Testing:Page | home | {"text": "c"} | {} | + | a | a | Neos.ContentRepository.Testing:Page | home | {"text": "a"} | {} | + | a1 | a1 | Neos.ContentRepository.Testing:Page | a | {"text": "a1"} | {} | + | a2 | a2 | Neos.ContentRepository.Testing:Page | a | {"text": "a2"} | {} | + | a2a | a2a | Neos.ContentRepository.Testing:SpecialPage | a2 | {"text": "a2a"} | {} | + | a2a1 | a2a1 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a1"} | {} | + | a2a2 | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a2"} | {} | + | a2a3 | a2a3 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a3"} | {} | + | a3 | a3 | Neos.ContentRepository.Testing:Page | a | {"text": "a3"} | {} | + | b | b | Neos.ContentRepository.Testing:Page | home | {"text": "b"} | {} | + | b1 | b1 | Neos.ContentRepository.Testing:Page | b | {"text": "b1"} | {} | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "c" | + | referenceName | "refs" | + | references | [{"target":"b1", "properties": {"foo": "foos"}}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a" | + | referenceName | "refs" | + | references | [{"target":"b1"}, {"target":"a2a2"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "b1" | + | referenceName | "ref" | + | references | [{"target":"a"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "b" | + | referenceName | "refs" | + | references | [{"target":"a3", "properties": {"foo": "bar"}}, {"target":"a2a1", "properties": {"foo": "baz"}}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a" | + | referenceName | "refs" | + | references | [{"target":"b1", "properties": {"foo": "bar"}}, {"target": "b"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a2" | + | referenceName | "ref" | + | references | [{"target":"a2a3"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a2a3" | + | referenceName | "ref" | + | references | [{"target":"a2"}] | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2a3" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + + Scenario: countBackReferences queries with empty results + When I execute the countBackReferences query for node aggregate id "non-existing" I expect the result 0 + When I execute the countBackReferences query for node aggregate id "home" I expect the result 0 + # "a2" is references node "a2a3" but "a2a3" is disabled so this reference should be ignored + When I execute the countBackReferences query for node aggregate id "a2" I expect the result 0 + # "a2a3" is references node "a2" but "a2a3" is disabled so this reference should be ignored + When I execute the countBackReferences query for node aggregate id "a2a3" I expect the result 0 + When I execute the countBackReferences query for node aggregate id "a" and filter '{"referenceName": "non-existing"}' I expect the result 0 + + Scenario: countBackReferences queries with results + When I execute the countBackReferences query for node aggregate id "a" I expect the result 1 + When I execute the countBackReferences query for node aggregate id "a3" and filter '{"referenceName": "refs"}' I expect the result 1 + When I execute the countBackReferences query for node aggregate id "b1" I expect the result 2 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountChildNodes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountChildNodes.feature new file mode 100644 index 00000000000..40891034da2 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountChildNodes.feature @@ -0,0 +1,91 @@ +@contentrepository @adapters=DoctrineDBAL + # TODO implement for Postgres +Feature: Count nodes using the countChildNodes query + + Background: + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, ch | ch->de->mul, en->mul | + And I have the following NodeTypes configuration: + """ + 'Neos.ContentRepository:Root': [] + 'Neos.ContentRepository.Testing:AbstractPage': + abstract: true + properties: + text: + type: string + '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 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 | nodeTypeName | parentNodeAggregateId | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | home | Neos.ContentRepository.Testing:Homepage | lady-eleonode-rootford | {} | {"terms": "terms", "contact": "contact"} | + | a | Neos.ContentRepository.Testing:Page | home | {"text": "a"} | {} | + | 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"} | {} | + | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a2"} | {} | + | a2a3 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a3"} | {} | + | b | Neos.ContentRepository.Testing:Page | home | {"text": "b"} | {} | + | b1 | Neos.ContentRepository.Testing:Page | b | {"text": "b1"} | {} | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2a3" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + + Scenario: Child nodes without filter + When I execute the countChildNodes query for parent node aggregate id "home" I expect the result 4 + When I execute the countChildNodes query for parent node aggregate id "a" I expect the result 2 + When I execute the countChildNodes query for parent node aggregate id "a1" I expect the result 0 + When I execute the countChildNodes query for parent node aggregate id "a2a" I expect the result 2 + + Scenario: Child nodes filtered by node type + When I execute the countChildNodes query for parent node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:AbstractPage"}' I expect the result 4 + When I execute the countChildNodes query for parent node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:SomeMixin"}' I expect the result 1 + When I execute the countChildNodes query for parent node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:SomeMixin"}' I expect the result 1 + When I execute the countChildNodes query for parent node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:AbstractPage,!Neos.ContentRepository.Testing:SomeMixin"}' I expect the result 3 + When I execute the countChildNodes query for parent node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:NonExistingNodeType"}' I expect the result 0 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountDescendantNodes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountDescendantNodes.feature new file mode 100644 index 00000000000..4e591d71312 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountDescendantNodes.feature @@ -0,0 +1,90 @@ +@contentrepository @adapters=DoctrineDBAL + # TODO implement for Postgres +Feature: Count nodes using the countDescendantNodes query + + Background: + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, ch | ch->de->mul, en->mul | + And I have the following NodeTypes configuration: + """ + 'Neos.ContentRepository:Root': [] + 'Neos.ContentRepository.Testing:AbstractPage': + abstract: true + properties: + text: + type: string + '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 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 | {"text": "a"} | {} | + | a1 | a1 | Neos.ContentRepository.Testing:Page | a | {"text": "a1"} | {} | + | a2 | a2 | Neos.ContentRepository.Testing:Page | a | {"text": "a2"} | {} | + | a2a | a2a | Neos.ContentRepository.Testing:SpecialPage | a2 | {"text": "a2a"} | {} | + | a2a1 | a2a1 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a1"} | {} | + | a2a2 | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a2"} | {} | + | a2a2a | a2a2a | Neos.ContentRepository.Testing:Page | a2a2 | {"text": "a2a2a"} | {} | + | a3 | a3 | Neos.ContentRepository.Testing:Page | a | {"text": "a3"} | {} | + | b | b | Neos.ContentRepository.Testing:Page | home | {"text": "b"} | {} | + | b1 | b1 | Neos.ContentRepository.Testing:Page | b | {"text": "b1"} | {} | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2a2a" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + + + Scenario: countDescendantNodes queries with empty results + When I execute the countDescendantNodes query for entry node aggregate id "non-existing" I expect the result to be 0 + When I execute the countDescendantNodes query for entry node aggregate id "home" and filter '{"searchTerm": "a2a2a"}' I expect the result to be 0 + When I execute the countDescendantNodes query for entry node aggregate id "home" and filter '{"searchTerm": "string"}' I expect the result to be 0 + + Scenario: countDescendantNodes queries with results + When I execute the countDescendantNodes query for entry node aggregate id "home" I expect the result to be 11 + When I execute the countDescendantNodes query for entry node aggregate id "home" and filter '{"nodeTypeConstraints": "Neos.ContentRepository.Testing:Page"}' I expect the result to be 8 + When I execute the countDescendantNodes query for entry node aggregate id "home" and filter '{"searchTerm": "a2"}' I expect the result to be 4 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountReferences.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountReferences.feature new file mode 100644 index 00000000000..ec380cc25f9 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/CountReferences.feature @@ -0,0 +1,142 @@ +@contentrepository @adapters=DoctrineDBAL + # TODO implement for Postgres +Feature: Count nodes using the countReferences query + + Background: + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, ch | ch->de->mul, en->mul | + And I have the following NodeTypes configuration: + """ + 'Neos.ContentRepository:Root': [] + 'Neos.ContentRepository.Testing:AbstractPage': + abstract: true + properties: + text: + type: string + refs: + type: references + properties: + foo: + type: string + ref: + type: reference + properties: + foo: + type: string + '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 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 | nodeTypeName | parentNodeAggregateId | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | home | Neos.ContentRepository.Testing:Homepage | lady-eleonode-rootford | {} | {"terms": "terms", "contact": "contact"} | + | c | Neos.ContentRepository.Testing:Page | home | {"text": "c"} | {} | + | a | Neos.ContentRepository.Testing:Page | home | {"text": "a"} | {} | + | 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"} | {} | + | a2a2 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a2"} | {} | + | a2a3 | Neos.ContentRepository.Testing:Page | a2a | {"text": "a2a3"} | {} | + | b | Neos.ContentRepository.Testing:Page | home | {"text": "b"} | {} | + | b1 | Neos.ContentRepository.Testing:Page | b | {"text": "b1"} | {} | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a" | + | referenceName | "refs" | + | references | [{"target":"b1"}, {"target":"a2a2"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "b1" | + | referenceName | "ref" | + | references | [{"target":"a"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "b" | + | referenceName | "refs" | + | references | [{"target":"a2", "properties": {"foo": "bar"}}, {"target":"a2a1", "properties": {"foo": "baz"}}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a" | + | referenceName | "ref" | + | references | [{"target":"b1", "properties": {"foo": "bar"}}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a2" | + | referenceName | "ref" | + | references | [{"target":"a2a3"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "a2a3" | + | referenceName | "ref" | + | references | [{"target":"a2"}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "c" | + | referenceName | "refs" | + | references | [{"target":"b1", "properties": {"foo": "foos"}}] | + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "c" | + | referenceName | "ref" | + | references | [{"target":"b"}] | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a2a3" | + | nodeVariantSelectionStrategy | "allVariants" | + And the graph projection is fully up to date + + Scenario: countReferences queries with empty results + When I execute the countReferences query for node aggregate id "a" and filter '{"referenceName": "non-existing"}' I expect the result 0 + When I execute the countReferences query for node aggregate id "non-existing" I expect the result 0 + # "a2" is referenced by "a2a3" but "a2a3" is disabled so this reference should be ignored + When I execute the countReferences query for node aggregate id "a2" I expect the result 0 + # "a2a3" is referenced by "a2" but "a2a3" is disabled so this reference should be ignored + When I execute the countReferences query for node aggregate id "a2a3" I expect the result 0 + + Scenario: countReferences queries with results + When I execute the countReferences query for node aggregate id "a" I expect the result 3 + When I execute the countReferences query for node aggregate id "a" and filter '{"referenceName": "ref"}' I expect the result 1 + When I execute the countReferences query for node aggregate id "c" I expect the result 2 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/FindBackReferences.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/FindBackReferences.feature index 4feefa51103..da1f79ef587 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/FindBackReferences.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/FindBackReferences.feature @@ -130,9 +130,9 @@ Feature: Find nodes using the findBackReferences query When I execute the findBackReferences query for node aggregate id "a2" I expect no references to be returned # "a2a3" is references node "a2" but "a2a3" is disabled so this reference should be ignored When I execute the findBackReferences query for node aggregate id "a2a3" I expect no references to be returned + When I execute the findBackReferences query for node aggregate id "a" and filter '{"referenceName": "non-existing"}' I expect no references to be returned Scenario: findBackReferences queries with results - When I execute the findBackReferences query for node aggregate id "a" and filter '{"referenceName": "non-existing"}' I expect no references to be returned When I execute the findBackReferences query for node aggregate id "a" I expect the references '[{"nodeAggregateId": "b1", "name": "ref", "properties": null}]' to be returned When I execute the findBackReferences query for node aggregate id "a3" and filter '{"referenceName": "refs"}' I expect the references '[{"nodeAggregateId": "b", "name": "refs", "properties": {"foo": {"value": "bar", "type": "string"}}}]' to be returned When I execute the findBackReferences query for node aggregate id "b1" I expect the references '[{"nodeAggregateId": "a", "name": "refs", "properties": {"foo": {"value": "bar", "type": "string"}}}, {"nodeAggregateId": "c", "name": "refs", "properties": {"foo": {"value": "foos", "type": "string"}}}]' to be returned diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/RootNodeAggregateDimensionUpdates/UpdateRootNodeAggregateDimensions_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/RootNodeAggregateDimensionUpdates/UpdateRootNodeAggregateDimensions_WithDimensions.feature new file mode 100644 index 00000000000..1559e903472 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/RootNodeAggregateDimensionUpdates/UpdateRootNodeAggregateDimensions_WithDimensions.feature @@ -0,0 +1,166 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Update Root Node aggregate dimensions + + I want to update a root node aggregate's dimensions when the dimension config changes. + + Background: + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de | | + And I have the following NodeTypes configuration: + """ + 'Neos.ContentRepository:Root': [] + """ + 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 the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + + Scenario: Initial setup of the root node (similar to 01/RootNodeCreation/03-...) + Then I expect exactly 2 events to be published on stream "ContentStream:cs-identifier" + And event at index 1 is of type "RootNodeAggregateWithNodeWasCreated" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + | coveredDimensionSpacePoints | [{"language":"mul"},{"language":"de"}] | + | nodeAggregateClassification | "root" | + And event metadata at index 1 is: + | Key | Expected | + + When the graph projection is fully up to date + Then I expect the node aggregate "lady-eleonode-rootford" to exist + And I expect this node aggregate to be classified as "root" + And I expect this node aggregate to be of type "Neos.ContentRepository:Root" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to cover dimension space points [{"language":"mul"},{"language":"de"}] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no parent node aggregates + And I expect this node aggregate to have no child node aggregates + + And I expect the graph projection to consist of exactly 1 node + And I expect a node identified by cs-identifier;lady-eleonode-rootford;{} to exist in the content graph + And I expect this node to be classified as "root" + And I expect this node to be of type "Neos.ContentRepository:Root" + And I expect this node to be unnamed + And I expect this node to have no properties + + When I am in dimension space point {"language":"mul"} + Then I expect the subgraph projection to consist of exactly 1 node + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + And I expect this node to be classified as "root" + And I expect this node to have no parent node + And I expect this node to have no child nodes + And I expect this node to have no preceding siblings + And I expect this node to have no succeeding siblings + And I expect this node to have no references + And I expect this node to not be referenced + + When I am in dimension space point {"language":"de"} + Then I expect the subgraph projection to consist of exactly 1 node + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + + + Scenario: Adding a dimension and updating the root node works + When the graph projection is fully up to date + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en | | + + # in "en", the root node does not exist. + When I am in dimension space point {"language":"en"} + Then I expect the subgraph projection to consist of exactly 0 nodes + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to no node + + And the command UpdateRootNodeAggregateDimensions is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + + Then I expect exactly 3 events to be published on stream "ContentStream:cs-identifier" + # the updated dimension config is persisted in the event stream + And event at index 2 is of type "RootNodeAggregateDimensionsWereUpdated" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "lady-eleonode-rootford" | + | coveredDimensionSpacePoints | [{"language":"mul"},{"language":"de"},{"language":"en"}] | + And event metadata at index 1 is: + | Key | Expected | + + When the graph projection is fully up to date + Then I expect the node aggregate "lady-eleonode-rootford" to exist + And I expect this node aggregate to be classified as "root" + And I expect this node aggregate to be of type "Neos.ContentRepository:Root" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to cover dimension space points [{"language":"mul"},{"language":"de"},{"language":"en"}] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no parent node aggregates + And I expect this node aggregate to have no child node aggregates + + And I expect the graph projection to consist of exactly 1 node + And I expect a node identified by cs-identifier;lady-eleonode-rootford;{} to exist in the content graph + And I expect this node to be classified as "root" + And I expect this node to be of type "Neos.ContentRepository:Root" + And I expect this node to be unnamed + And I expect this node to have no properties + + When I am in dimension space point {"language":"mul"} + Then I expect the subgraph projection to consist of exactly 1 node + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + And I expect this node to be classified as "root" + And I expect this node to have no parent node + And I expect this node to have no child nodes + And I expect this node to have no preceding siblings + And I expect this node to have no succeeding siblings + And I expect this node to have no references + And I expect this node to not be referenced + + When I am in dimension space point {"language":"de"} + Then I expect the subgraph projection to consist of exactly 1 node + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + + # now, the root node exists in "en" + When I am in dimension space point {"language":"en"} + Then I expect the subgraph projection to consist of exactly 1 node + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + + + Scenario: Adding a dimension updating the root node, removing dimension, updating the root node, works (dimension gone again) + When the graph projection is fully up to date + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en | | + And the command UpdateRootNodeAggregateDimensions is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + And the graph projection is fully up to date + + # now, the root node exists in "en" + When I am in dimension space point {"language":"en"} + Then I expect the subgraph projection to consist of exactly 1 nodes + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} + + # again, remove "en" + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, | | + And the command UpdateRootNodeAggregateDimensions is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + And the graph projection is fully up to date + + # now, the root node should not exist anymore in "en" + When I am in dimension space point {"language":"en"} + Then I expect the subgraph projection to consist of exactly 0 nodes + And I expect node aggregate identifier "lady-eleonode-rootford" to lead to no node diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php index bad4c0c66fc..7b941d86572 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php @@ -22,6 +22,7 @@ use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeGeneralizationVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodePeerVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeSpecializationVariantWasCreated; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated; @@ -81,6 +82,7 @@ public function __construct() NodeSpecializationVariantWasCreated::class, RootNodeAggregateWithNodeWasCreated::class, RootWorkspaceWasCreated::class, + RootNodeAggregateDimensionsWereUpdated::class, WorkspaceRebaseFailed::class, WorkspaceWasCreated::class, WorkspaceWasDiscarded::class, diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php index fcdf18e9981..307d1b67e52 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php @@ -460,11 +460,11 @@ protected function requireNodeAggregateToCoverDimensionSpacePoints( /** * @throws NodeAggregateIsRoot */ - protected function requireNodeAggregateToNotBeRoot(NodeAggregate $nodeAggregate): void + protected function requireNodeAggregateToNotBeRoot(NodeAggregate $nodeAggregate, ?string $extraReason = '.'): void { if ($nodeAggregate->classification->isRoot()) { throw new NodeAggregateIsRoot( - 'Node aggregate "' . $nodeAggregate->nodeAggregateId . '" is classified as root.', + 'Node aggregate "' . $nodeAggregate->nodeAggregateId . '" is classified as root ' . $extraReason, 1554586860 ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php index 27331726d8a..87fbaddcfb1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php @@ -27,10 +27,6 @@ */ final class NodeAggregateEventPublisher { - /** - * @throws \Neos\Flow\Property\Exception - * @throws \Neos\Flow\Security\Exception - */ public static function enrichWithCommand( CommandInterface $command, Events $events, diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php index bf0faf54045..0946be4e6fa 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php @@ -45,7 +45,8 @@ use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; use Neos\ContentRepository\Core\Feature\NodeVariation\NodeVariation; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; -use Neos\ContentRepository\Core\Feature\RootNodeCreation\RootNodeCreation; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\RootNodeHandling; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; @@ -57,7 +58,7 @@ final class NodeAggregateCommandHandler implements CommandHandlerInterface { use ConstraintChecks; - use RootNodeCreation; + use RootNodeHandling; use NodeCreation; use NodeDisabling; use NodeModification; @@ -129,6 +130,8 @@ public function handle(CommandInterface $command, ContentRepository $contentRepo CreateNodeVariant::class => $this->handleCreateNodeVariant($command, $contentRepository), CreateRootNodeAggregateWithNode::class => $this->handleCreateRootNodeAggregateWithNode($command, $contentRepository), + UpdateRootNodeAggregateDimensions::class + => $this->handleUpdateRootNodeAggregateDimensions($command, $contentRepository), DisableNodeAggregate::class => $this->handleDisableNodeAggregate($command, $contentRepository), EnableNodeAggregate::class => $this->handleEnableNodeAggregate($command, $contentRepository), ChangeNodeAggregateName::class => $this->handleChangeNodeAggregateName($command), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php index e06e56f8510..1e344c9fff1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php @@ -17,6 +17,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\Feature\NodeVariation\Exception\RootNodeCannotBeVaried; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; @@ -57,9 +58,11 @@ private function handleCreateNodeVariant( $command->nodeAggregateId, $contentRepository ); + // we do this check first, because it gives a more meaningful error message on what you need to do. + // we cannot use sentences with "." because the UI will only print the 1st sentence :/ + $this->requireNodeAggregateToNotBeRoot($nodeAggregate, 'and Root Node Aggregates cannot be varied; If this error happens, you most likely need to run ./flow content:refreshRootNodeDimensions to update the root node dimensions.'); $this->requireDimensionSpacePointToExist($command->sourceOrigin->toDimensionSpacePoint()); $this->requireDimensionSpacePointToExist($command->targetOrigin->toDimensionSpacePoint()); - $this->requireNodeAggregateToNotBeRoot($nodeAggregate); $this->requireNodeAggregateToBeUntethered($nodeAggregate); $this->requireNodeAggregateToOccupyDimensionSpacePoint($nodeAggregate, $command->sourceOrigin); $this->requireNodeAggregateToNotOccupyDimensionSpacePoint($nodeAggregate, $command->targetOrigin); diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php new file mode 100644 index 00000000000..1c743b08a5e --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php @@ -0,0 +1,66 @@ + $array + */ + public static function fromArray(array $array): self + { + return new self( + ContentStreamId::fromString($array['contentStreamId']), + NodeAggregateId::fromString($array['nodeAggregateId']) + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return get_object_vars($this); + } + + public function createCopyForContentStream(ContentStreamId $target): self + { + return new self( + $target, + $this->nodeAggregateId, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Event/RootNodeAggregateDimensionsWereUpdated.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Event/RootNodeAggregateDimensionsWereUpdated.php new file mode 100644 index 00000000000..e2aea0c41b3 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Event/RootNodeAggregateDimensionsWereUpdated.php @@ -0,0 +1,75 @@ +contentStreamId; + } + + public function getNodeAggregateId(): NodeAggregateId + { + return $this->nodeAggregateId; + } + + public function createCopyForContentStream(ContentStreamId $targetContentStreamId): self + { + return new self( + $targetContentStreamId, + $this->nodeAggregateId, + $this->coveredDimensionSpacePoints, + ); + } + + public static function fromArray(array $values): self + { + return new self( + ContentStreamId::fromString($values['contentStreamId']), + NodeAggregateId::fromString($values['nodeAggregateId']), + DimensionSpacePointSet::fromArray($values['coveredDimensionSpacePoints']), + ); + } + + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeCreation.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php similarity index 69% rename from Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeCreation.php rename to Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php index ffa331c09bb..f9c7c02a5d2 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeCreation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php @@ -19,9 +19,12 @@ use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; use Neos\ContentRepository\Core\NodeType\NodeType; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; +use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateIsNotRoot; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregatesTypeIsAmbiguous; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyExists; use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeIsNotOfTypeRoot; @@ -35,7 +38,7 @@ /** * @internal implementation detail of Command Handlers */ -trait RootNodeCreation +trait RootNodeHandling { abstract protected function getAllowedDimensionSubspace(): DimensionSpacePointSet; @@ -100,4 +103,43 @@ private function createRootWithNode( NodeAggregateClassification::CLASSIFICATION_ROOT, ); } + + /** + * @param UpdateRootNodeAggregateDimensions $command + * @return EventsToPublish + */ + private function handleUpdateRootNodeAggregateDimensions( + UpdateRootNodeAggregateDimensions $command, + ContentRepository $contentRepository + ): EventsToPublish { + $this->requireContentStreamToExist($command->contentStreamId, $contentRepository); + $nodeAggregate = $this->requireProjectedNodeAggregate( + $command->contentStreamId, + $command->nodeAggregateId, + $contentRepository + ); + if (!$nodeAggregate->classification->isRoot()) { + throw new NodeAggregateIsNotRoot('The node aggregate ' . $nodeAggregate->nodeAggregateId->value . ' is not classified as root, but should be for command UpdateRootNodeAggregateDimensions.', 1678647355); + } + + $events = Events::with( + new RootNodeAggregateDimensionsWereUpdated( + $command->contentStreamId, + $command->nodeAggregateId, + $this->getAllowedDimensionSubspace() + ) + ); + + $contentStreamEventStream = ContentStreamEventStreamName::fromContentStreamId( + $command->contentStreamId + ); + return new EventsToPublish( + $contentStreamEventStream->getEventStreamName(), + NodeAggregateEventPublisher::enrichWithCommand( + $command, + $events + ), + ExpectedVersion::ANY() + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php index 88c2b53954b..5f9676ecd63 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php @@ -47,12 +47,25 @@ public function getSubgraph( /** * @api + * Throws exception if no root aggregate found, because a Content Repository needs at least + * one root node to function. + * + * Also throws exceptions if multiple root node aggregates of the given $nodeTypeName were found, + * as this would lead to nondeterministic results in your code. */ public function findRootNodeAggregateByType( ContentStreamId $contentStreamId, NodeTypeName $nodeTypeName ): NodeAggregate; + /** + * @api + */ + public function findRootNodeAggregates( + ContentStreamId $contentStreamId, + Filter\FindRootNodeAggregatesFilter $filter, + ): NodeAggregates; + /** * @return iterable * @api diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php index 63bb6c55df0..de3a350f92f 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/ContentSubgraphWithRuntimeCaches.php @@ -15,6 +15,7 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphWithRuntimeCaches; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; @@ -69,18 +70,39 @@ public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChild return $childNodes; } + public function countChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\CountChildNodesFilter $filter): int + { + $childNodesCache = $this->inMemoryCache->getAllChildNodesByNodeIdCache(); + if ($childNodesCache->contains($parentNodeAggregateId, $filter->nodeTypeConstraints)) { + return $childNodesCache->countChildNodes($parentNodeAggregateId, $filter->nodeTypeConstraints); + } + return $this->wrappedContentSubgraph->countChildNodes($parentNodeAggregateId, $filter); + } + public function findReferences(NodeAggregateId $nodeAggregateId, FindReferencesFilter $filter): References { // TODO: implement runtime caches return $this->wrappedContentSubgraph->findReferences($nodeAggregateId, $filter); } + public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountReferencesFilter $filter): int + { + // TODO: implement runtime caches + return $this->wrappedContentSubgraph->countReferences($nodeAggregateId, $filter); + } + public function findBackReferences(NodeAggregateId $nodeAggregateId, FindBackReferencesFilter $filter): References { // TODO: implement runtime caches return $this->wrappedContentSubgraph->findBackReferences($nodeAggregateId, $filter); } + public function countBackReferences(NodeAggregateId $nodeAggregateId, Filter\CountBackReferencesFilter $filter): int + { + // TODO: implement runtime caches + return $this->wrappedContentSubgraph->countBackReferences($nodeAggregateId, $filter); + } + public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { $cache = $this->inMemoryCache->getNodeByNodeAggregateIdCache(); @@ -180,6 +202,12 @@ public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, FindD return $this->wrappedContentSubgraph->findDescendantNodes($entryNodeAggregateId, $filter); } + public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int + { + // TODO: implement runtime caches + return $this->wrappedContentSubgraph->countDescendantNodes($entryNodeAggregateId, $filter); + } + public function countNodes(): int { // TODO: implement runtime caches diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/InMemoryCache/AllChildNodesByNodeIdCache.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/InMemoryCache/AllChildNodesByNodeIdCache.php index d424b8b79f9..2caf7772cb5 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/InMemoryCache/AllChildNodesByNodeIdCache.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphWithRuntimeCaches/InMemoryCache/AllChildNodesByNodeIdCache.php @@ -68,4 +68,11 @@ public function findChildNodes( $nodeTypeConstraintsKey = $nodeTypeConstraints !== null ? (string)$nodeTypeConstraints : '*'; return $this->childNodes[$parentNodeAggregateId->value][$nodeTypeConstraintsKey] ?? Nodes::createEmpty(); } + + public function countChildNodes( + NodeAggregateId $parentNodeAggregateId, + ?NodeTypeConstraints $nodeTypeConstraints + ): int { + return $this->findChildNodes($parentNodeAggregateId, $nodeTypeConstraints)->count(); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php index 19251670bd8..e51493532bd 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php @@ -54,10 +54,16 @@ interface ContentSubgraphInterface extends \JsonSerializable public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node; /** - * Find direct child nodes of the specified parent node + * Find direct child nodes of the specified parent node that match the given $filter */ public function findChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\FindChildNodesFilter $filter): Nodes; + /** + * Count direct child nodes of the specified parent node that match the given $filter + * @see findChildNodes + */ + public function countChildNodes(NodeAggregateId $parentNodeAggregateId, Filter\CountChildNodesFilter $filter): int; + /** * Find the direct parent of a node specified by its aggregate id * @@ -89,6 +95,12 @@ public function findChildNodeConnectedThroughEdgeName(NodeAggregateId $parentNod */ public function findDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\FindDescendantNodesFilter $filter): Nodes; + /** + * Count all nodes underneath the $entryNodeAggregateId that match the specified $filter + * @see findDescendantNodes + */ + public function countDescendantNodes(NodeAggregateId $entryNodeAggregateId, Filter\CountDescendantNodesFilter $filter): int; + /** * Recursively find all nodes underneath the $entryNodeAggregateId that match the specified $filter and return them as a tree * @@ -108,6 +120,12 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, Filter\FindSu */ public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindReferencesFilter $filter): References; + /** + * Count all "outgoing" references of a given node that match the specified $filter + * @see findReferences + */ + public function countReferences(NodeAggregateId $nodeAggregateId, Filter\CountReferencesFilter $filter): int; + /** * Find all "incoming" references of a given node that match the specified $filter * If nodes "A" and "B" both have a reference to "C", the node "C" has two incoming references. @@ -116,6 +134,12 @@ public function findReferences(NodeAggregateId $nodeAggregateId, Filter\FindRefe */ public function findBackReferences(NodeAggregateId $nodeAggregateId, Filter\FindBackReferencesFilter $filter): References; + /** + * Count all "incoming" references of a given node that match the specified $filter + * @see findBackReferences + */ + public function countBackReferences(NodeAggregateId $nodeAggregateId, Filter\CountBackReferencesFilter $filter): int; + /** * Find a single node underneath $startingNodeAggregateId that matches the specified $path * diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php new file mode 100644 index 00000000000..9af7e1dc45c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountBackReferencesFilter.php @@ -0,0 +1,63 @@ +with(referenceName: 'someName'); + * + * // create an instance from an existing FindBackReferencesFilter instance + * CountBackReferencesFilter::fromFindBackReferencesFilter($filter); + * + * @api for the factory methods; NOT for the inner state. + */ +final class CountBackReferencesFilter +{ + /** + * @internal (the properties themselves are readonly; only the write-methods are API. + */ + private function __construct( + public readonly ?ReferenceName $referenceName, + ) { + } + + public static function create(): self + { + return new self(null); + } + + public static function fromFindBackReferencesFilter(FindBackReferencesFilter $filter): self + { + return new self($filter->referenceName); + } + + public static function referenceName(ReferenceName|string $referenceName): self + { + return self::create()->with(referenceName: $referenceName); + } + + /** + * Returns a new instance with the specified additional 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 function with( + ReferenceName|string $referenceName = null, + ): self { + if (is_string($referenceName)) { + $referenceName = ReferenceName::fromString($referenceName); + } + return new self( + $referenceName ?? $this->referenceName, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php new file mode 100644 index 00000000000..ecb867b1ed1 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountChildNodesFilter.php @@ -0,0 +1,63 @@ +with(nodeTypeConstraint: 'Some.Included:NodeType,!Some.Excluded:NodeType'); + * + * // create an instance from an existing FindChildNodesFilter instance + * CountChildNodesFilter::fromFindChildNodesFilter($filter); + * + * @api for the factory methods; NOT for the inner state. + */ +final class CountChildNodesFilter +{ + /** + * @internal (the properties themselves are readonly; only the write-methods are API. + */ + private function __construct( + public readonly ?NodeTypeConstraints $nodeTypeConstraints, + ) { + } + + public static function create(): self + { + return new self(null); + } + + public static function fromFindChildNodesFilter(FindChildNodesFilter $filter): self + { + return new self($filter->nodeTypeConstraints); + } + + /** + * Returns a new instance with the specified additional 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 function with( + NodeTypeConstraints|string $nodeTypeConstraints = null, + ): self { + if (is_string($nodeTypeConstraints)) { + $nodeTypeConstraints = NodeTypeConstraints::fromFilterString($nodeTypeConstraints); + } + return new self( + $nodeTypeConstraints ?? $this->nodeTypeConstraints, + ); + } + + public function withNodeTypeConstraints(NodeTypeConstraints|string $nodeTypeConstraints): self + { + return $this->with(nodeTypeConstraints: $nodeTypeConstraints); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php new file mode 100644 index 00000000000..8fc7068e9bc --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountDescendantNodesFilter.php @@ -0,0 +1,75 @@ +with(nodeTypeConstraint: 'Some.Included:NodeType,!Some.Excluded:NodeType', searchTerm: 'foo'); + * + * // create an instance from an existing FindChildNodesFilter instance + * CountDescendantNodesFilter::fromFindChildNodesFilter($filter); + * + * @api for the factory methods; NOT for the inner state. + */ +final class CountDescendantNodesFilter +{ + /** + * @internal (the properties themselves are readonly; only the write-methods are API. + */ + private function __construct( + public readonly ?NodeTypeConstraints $nodeTypeConstraints, + public readonly ?SearchTerm $searchTerm, + ) { + } + + public static function create(): self + { + return new self(null, null); + } + + public static function fromFindDescendantNodesFilter(FindDescendantNodesFilter $filter): self + { + return new self($filter->nodeTypeConstraints, $filter->searchTerm); + } + + public static function nodeTypeConstraints(NodeTypeConstraints|string $nodeTypeConstraints): self + { + return self::create()->with(nodeTypeConstraints: $nodeTypeConstraints); + } + + /** + * Returns a new instance with the specified additional 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 function with( + NodeTypeConstraints|string $nodeTypeConstraints = null, + SearchTerm|string $searchTerm = null, + ): self { + if (is_string($nodeTypeConstraints)) { + $nodeTypeConstraints = NodeTypeConstraints::fromFilterString($nodeTypeConstraints); + } + if (is_string($searchTerm)) { + $searchTerm = SearchTerm::fulltext($searchTerm); + } + return new self( + $nodeTypeConstraints ?? $this->nodeTypeConstraints, + $searchTerm ?? $this->searchTerm, + ); + } + + public function withSearchTerm(SearchTerm|string $searchTerm): self + { + return $this->with(searchTerm: $searchTerm); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php new file mode 100644 index 00000000000..26948c1bc1e --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/CountReferencesFilter.php @@ -0,0 +1,63 @@ +with(referenceName: 'someName'); + * + * // create an instance from an existing FindReferencesFilter instance + * CountReferencesFilter::fromFindReferencesFilter($filter); + * + * @api for the factory methods; NOT for the inner state. + */ +final class CountReferencesFilter +{ + /** + * @internal (the properties themselves are readonly; only the write-methods are API. + */ + private function __construct( + public readonly ?ReferenceName $referenceName, + ) { + } + + public static function create(): self + { + return new self(null); + } + + public static function fromFindReferencesFilter(FindReferencesFilter $filter): self + { + return new self($filter->referenceName); + } + + public static function referenceName(ReferenceName|string $referenceName): self + { + return self::create()->with(referenceName: $referenceName); + } + + /** + * Returns a new instance with the specified additional 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 function with( + ReferenceName|string $referenceName = null, + ): self { + if (is_string($referenceName)) { + $referenceName = ReferenceName::fromString($referenceName); + } + return new self( + $referenceName ?? $this->referenceName, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindRootNodeAggregatesFilter.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindRootNodeAggregatesFilter.php new file mode 100644 index 00000000000..7742310c205 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Filter/FindRootNodeAggregatesFilter.php @@ -0,0 +1,52 @@ +with(nodeTypeName: $nodeTypeName); + * + * @api for the factory methods; NOT for the inner state. + */ +final class FindRootNodeAggregatesFilter +{ + /** + * @internal (the properties themselves are readonly; only the write-methods are API) + */ + private function __construct( + public readonly ?NodeTypeName $nodeTypeName, + ) { + } + + public static function create(): self + { + return new self(null); + } + + public static function nodeTypeName(NodeTypeName $nodeTypeName): self + { + return self::create()->with(nodeTypeName: $nodeTypeName); + } + + /** + * Returns a new instance with the specified additional 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 function with( + NodeTypeName $nodeTypeName = null, + ): self { + return new self( + $nodeTypeName ?? $this->nodeTypeName, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregates.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregates.php new file mode 100644 index 00000000000..e7befe6b45a --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregates.php @@ -0,0 +1,87 @@ + + * + * @api + */ +final class NodeAggregates implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $nodeAggregates; + + /** + * @param iterable $collection + */ + private function __construct(iterable $collection) + { + $nodes = []; + foreach ($collection as $item) { + if (!$item instanceof NodeAggregate) { + throw new \InvalidArgumentException( + 'Nodes can only consist of ' . NodeAggregate::class . ' objects.', + 1618044512 + ); + } + $nodes[] = $item; + } + + $this->nodeAggregates = $nodes; + } + + /** + * @param array $nodeAggregates + */ + public static function fromArray(array $nodeAggregates): self + { + return new self($nodeAggregates); + } + + public static function createEmpty(): self + { + return new self([]); + } + + /** + * @return \ArrayIterator|NodeAggregate[] + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->nodeAggregates); + } + + public function count(): int + { + return count($this->nodeAggregates); + } + + /** + * @return NodeAggregate|null + */ + public function first(): ?NodeAggregate + { + if (count($this->nodeAggregates) > 0) { + $array = $this->nodeAggregates; + return reset($array); + } + return null; + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsNotRoot.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsNotRoot.php new file mode 100644 index 00000000000..b4f8689f5df --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsNotRoot.php @@ -0,0 +1,23 @@ +rootNodeAggregateId = $nodeAggregateId; } + /** + * @When /^the command UpdateRootNodeAggregateDimensions is executed with payload:$/ + * @param TableNode $payloadTable + * @throws ContentStreamDoesNotExistYet + * @throws \Exception + */ + public function theCommandUpdateRootNodeAggregateDimensionsIsExecutedWithPayload(TableNode $payloadTable) + { + $commandArguments = $this->readPayloadTable($payloadTable); + $contentStreamId = isset($commandArguments['contentStreamId']) + ? ContentStreamId::fromString($commandArguments['contentStreamId']) + : $this->getCurrentContentStreamId(); + $nodeAggregateId = NodeAggregateId::fromString($commandArguments['nodeAggregateId']); + + $command = new UpdateRootNodeAggregateDimensions( + $contentStreamId, + $nodeAggregateId, + ); + + $this->lastCommandOrEventResult = $this->getContentRepository()->handle($command); + $this->rootNodeAggregateId = $nodeAggregateId; + } + /** * @When /^the command CreateNodeAggregateWithNode is executed with payload:$/ * @param TableNode $payloadTable diff --git a/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeTraversalTrait.php b/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeTraversalTrait.php index e1428a5ee0b..45fb79967af 100644 --- a/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeTraversalTrait.php +++ b/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeTraversalTrait.php @@ -15,6 +15,10 @@ use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountBackReferencesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountChildNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountDescendantNodesFilter; +use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\CountReferencesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindPrecedingSiblingNodesFilter; @@ -62,6 +66,26 @@ public function iExecuteTheFindChildNodesQueryIExpectTheFollowingNodes(string $p } } + /** + * @When I execute the countChildNodes query for parent node aggregate id :parentNodeIdSerialized and filter :filterSerialized I expect the result :expectedResult + * @When I execute the countChildNodes query for parent node aggregate id :parentNodeIdSerialized I expect the result :expectedResult + */ + public function iExecuteTheCountChildNodesQueryIExpectTheResult(string $parentNodeIdSerialized, string $filterSerialized = null, int $expectedResult = null): void + { + $parentNodeAggregateId = NodeAggregateId::fromString($parentNodeIdSerialized); + $filter = CountChildNodesFilter::create(); + if ($filterSerialized !== null) { + $filterValues = json_decode($filterSerialized, true, 512, JSON_THROW_ON_ERROR); + $filter = $filter->with(...$filterValues); + } + + /** @var ContentSubgraphInterface $subgraph */ + foreach ($this->getCurrentSubgraphs() as $subgraph) { + $actualResult = $subgraph->countChildNodes($parentNodeAggregateId, $filter); + Assert::assertSame($expectedResult, $actualResult); + } + } + /** * @When I execute the findReferences query for node aggregate id :nodeIdSerialized and filter :filterSerialized I expect the references :referencesSerialized to be returned * @When I execute the findReferences query for node aggregate id :nodeIdSerialized I expect the references :referencesSerialized to be returned @@ -85,6 +109,26 @@ public function iExecuteTheFindReferencesQueryIExpectTheFollowingReferences(stri } } + /** + * @When I execute the countReferences query for node aggregate id :nodeIdSerialized and filter :filterSerialized I expect the result :expectedResult + * @When I execute the countReferences query for node aggregate id :nodeIdSerialized I expect the result :expectedResult + */ + public function iExecuteTheCountReferencesQueryIExpectTheResult(string $nodeIdSerialized, string $filterSerialized = null, int $expectedResult = null): void + { + $nodeAggregateId = NodeAggregateId::fromString($nodeIdSerialized); + $filter = CountReferencesFilter::create(); + if ($filterSerialized !== null) { + $filterValues = json_decode($filterSerialized, true, 512, JSON_THROW_ON_ERROR); + $filter = $filter->with(...$filterValues); + } + + /** @var ContentSubgraphInterface $subgraph */ + foreach ($this->getCurrentSubgraphs() as $subgraph) { + $actualResult = $subgraph->countReferences($nodeAggregateId, $filter); + Assert::assertSame($expectedResult, $actualResult); + } + } + /** * @When I execute the findBackReferences query for node aggregate id :nodeIdSerialized and filter :filterSerialized I expect the references :referencesSerialized to be returned * @When I execute the findBackReferences query for node aggregate id :nodeIdSerialized I expect the references :referencesSerialized to be returned @@ -108,6 +152,26 @@ public function iExecuteTheFindBackReferencesQueryIExpectTheFollowingReferences( } } + /** + * @When I execute the countBackReferences query for node aggregate id :nodeIdSerialized and filter :filterSerialized I expect the result :expectedResult + * @When I execute the countBackReferences query for node aggregate id :nodeIdSerialized I expect the result :expectedResult + */ + public function iExecuteTheCountBackReferencesQueryIExpectTheResult(string $nodeIdSerialized, string $filterSerialized = null, int $expectedResult = null): void + { + $nodeAggregateId = NodeAggregateId::fromString($nodeIdSerialized); + $filter = CountBackReferencesFilter::create(); + if ($filterSerialized !== null) { + $filterValues = json_decode($filterSerialized, true, 512, JSON_THROW_ON_ERROR); + $filter = $filter->with(...$filterValues); + } + + /** @var ContentSubgraphInterface $subgraph */ + foreach ($this->getCurrentSubgraphs() as $subgraph) { + $actualResult = $subgraph->countBackReferences($nodeAggregateId, $filter); + Assert::assertSame($expectedResult, $actualResult); + } + } + /** * @When I execute the findNodeById query for node aggregate id :nodeIdSerialized I expect no node to be returned * @When I execute the findNodeById query for node aggregate id :nodeIdSerialized I expect the node :expectedNodeIdSerialized to be returned @@ -303,6 +367,26 @@ public function iExecuteTheFindDescendantNodesQueryIExpectTheFollowingNodes(stri } } + /** + * @When I execute the countDescendantNodes query for entry node aggregate id :entryNodeIdSerialized and filter :filterSerialized I expect the result to be :expectedResult + * @When I execute the countDescendantNodes query for entry node aggregate id :entryNodeIdSerialized I expect the result to be :expectedResult + */ + public function iExecuteTheCountDescendantNodesQueryIExpectTheResult(string $entryNodeIdSerialized, string $filterSerialized = null, int $expectedResult = 0): void + { + $entryNodeAggregateId = NodeAggregateId::fromString($entryNodeIdSerialized); + $filter = CountDescendantNodesFilter::create(); + if ($filterSerialized !== null) { + $filterValues = json_decode($filterSerialized, true, 512, JSON_THROW_ON_ERROR); + $filter = $filter->with(...$filterValues); + } + + /** @var ContentSubgraphInterface $subgraph */ + foreach ($this->getCurrentSubgraphs() as $subgraph) { + $actualResult = $subgraph->countDescendantNodes($entryNodeAggregateId, $filter); + Assert::assertSame($expectedResult, $actualResult); + } + } + /** * @When I execute the countNodes query I expect the result to be :expectedResult */ diff --git a/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php b/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php deleted file mode 100644 index cfa65b80cca..00000000000 --- a/Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php +++ /dev/null @@ -1,151 +0,0 @@ -theReadModelWithNodeAggregateIdXIsInstantiated($rawNodeAggregateId); - } catch (\Exception $exception) { - $this->lastInstantiationException = $exception; - } - } - - /** - * @When /^the read model with node aggregate identifier "([^"]*)" is instantiated$/ - * @param string $rawNodeAggregateId - */ - public function theReadModelWithNodeAggregateIdXIsInstantiated(string $rawNodeAggregateId): void - { - foreach ($this->getActiveContentGraphs() as $adapterName => $contentGraph) { - assert($contentGraph instanceof ContentGraphInterface); - $subgraph = $contentGraph->getSubgraph( - $this->contentStreamId, - $this->dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); - - $node = $subgraph->findNodeById(NodeAggregateId::fromString($rawNodeAggregateId)); - - $this->currentReadModel = $node; - } - } - - /** - * @Then /^I expect the instantiation to have thrown an exception of type "([^"]*)" with code (\d*)$/ - * @param string $expectedExceptionName - */ - public function iExpectTheInstantiationToHaveThrownAnExceptionOfType(string $expectedExceptionName): void - { - Assert::assertNotNull($this->lastInstantiationException, 'Instantiation did not throw an exception'); - $lastInstantiationExceptionShortName = (new \ReflectionClass($this->lastInstantiationException))->getShortName(); - Assert::assertSame( - $expectedExceptionName, - $lastInstantiationExceptionShortName, - sprintf( - 'Expected exception %s, actual exception: %s (%s): %s', - $expectedExceptionName, - get_class($this->lastInstantiationException), - $this->lastInstantiationException->getCode(), - $this->lastInstantiationException->getMessage() - ) - ); - } - - /** - * @Then /^I expect this read model to be an instance of "([^"]*)"$/ - * @param string $expectedClassName - */ - public function iExpectThisReadModelToBeAnInstanceOfX(string $expectedClassName): void - { - Assert::assertInstanceOf( - $expectedClassName, - $this->currentReadModel, - 'The current read model is not of expected type "' . $expectedClassName . '" but of type "' . get_class($this->currentReadModel) . '"' - ); - } - - /** - * @Then /^I expect this read model to have the properties:$/ - * @param TableNode $payloadTable - */ - public function iExpectThisReadModelToHaveTheProperties(TableNode $payloadTable): void - { - Assert::assertNotNull($this->currentReadModel, 'Current read model could not be found.'); - - $expectedProperties = $this->readPayloadTable($payloadTable); - - $properties = $this->currentReadModel->properties; - foreach ($expectedProperties as $propertyName => $expectedPropertyValue) { - Assert::assertTrue(isset($properties[$propertyName]), 'Property "' . $propertyName . '" not found'); - if ($expectedPropertyValue === 'PostalAddress:dummy') { - $expectedPropertyValue = PostalAddress::dummy(); - } elseif ($expectedPropertyValue === 'PostalAddress:anotherDummy') { - $expectedPropertyValue = PostalAddress::anotherDummy(); - } - if (is_string($expectedPropertyValue)) { - if ($expectedPropertyValue === 'Date:now') { - // we accept 10s offset for the projector to be fine - $expectedPropertyValue = new \DateTimeImmutable(); - $expectedDateInterval = new \DateInterval('PT10S'); - Assert::assertLessThan($properties[$propertyName], $expectedPropertyValue->sub($expectedDateInterval), 'Node property ' . $propertyName . ' does not match. Expected: ' . json_encode($expectedPropertyValue) . '; Actual: ' . json_encode($properties[$propertyName])); - continue; - } elseif (\mb_strpos($expectedPropertyValue, 'Date:') === 0) { - $expectedPropertyValue = \DateTimeImmutable::createFromFormat(\DateTimeInterface::W3C, \mb_substr($expectedPropertyValue, 5)); - } elseif (\mb_strpos($expectedPropertyValue, 'URI:') === 0) { - $expectedPropertyValue = new Uri(\mb_substr($expectedPropertyValue, 4)); - } elseif ($expectedPropertyValue === 'IMG:dummy') { - $expectedPropertyValue = $this->requireDummyImage(); - } elseif ($expectedPropertyValue === '[IMG:dummy]') { - $expectedPropertyValue = [$this->requireDummyImage()]; - } - } - Assert::assertEquals($expectedPropertyValue, $properties[$propertyName], 'Node property ' . $propertyName . ' does not match. Expected: ' . json_encode($expectedPropertyValue) . '; Actual: ' . json_encode($properties[$propertyName])); - } - } -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/ContentCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/ContentCommandController.php new file mode 100644 index 00000000000..42d6943fab1 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Command/ContentCommandController.php @@ -0,0 +1,174 @@ +contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::fromString($workspace)); + + $this->outputFormatted('Refreshing root node dimensions in workspace %s (content repository %s)', [$workspace->workspaceName->name, $contentRepositoryId->value]); + $this->outputFormatted('Resolved content stream %s', [$workspace->currentContentStreamId]); + + $rootNodeAggregates = $contentRepository->getContentGraph()->findRootNodeAggregates( + $workspace->currentContentStreamId, + FindRootNodeAggregatesFilter::create() + ); + + foreach ($rootNodeAggregates as $rootNodeAggregate) { + $this->outputFormatted('Refreshing dimensions for root node aggregate %s (of type %s)', [ + $rootNodeAggregate->nodeAggregateId->value, + $rootNodeAggregate->nodeTypeName->value + ]); + $contentRepository->handle( + new UpdateRootNodeAggregateDimensions( + $workspace->currentContentStreamId, + $rootNodeAggregate->nodeAggregateId + ) + )->block(); + } + $this->outputFormatted('Done!'); + } + + public function moveDimensionSpacePointCommand(string $source, string $target, string $contentRepository = 'default', string $workspace = WorkspaceName::WORKSPACE_NAME_LIVE): void + { + // TODO: CLI arguments: $contentRepositoryId => $contentRepository (in other CLI commands) + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $source = DimensionSpacePoint::fromJsonString($source); + $target = DimensionSpacePoint::fromJsonString($target); + + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::fromString($workspace)); + + $this->outputFormatted('Moving %s to %s in workspace %s (content repository %s)', [$source, $target, $workspace->workspaceName, $contentRepositoryId]); + $this->outputFormatted('Resolved content stream %s', [$workspace->currentContentStreamId]); + + $contentRepository->handle( + new MoveDimensionSpacePoint( + $workspace->currentContentStreamId, + $source, + $target + ) + )->block(); + $this->outputFormatted('Done!'); + } + + public function createVariantsRecursivelyCommand(string $source, string $target, string $contentRepository = 'default', string $workspace = WorkspaceName::WORKSPACE_NAME_LIVE): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $source = DimensionSpacePoint::fromJsonString($source); + $target = OriginDimensionSpacePoint::fromJsonString($target); + + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::fromString($workspace)); + + $this->outputFormatted('Creating %s to %s in workspace %s (content repository %s)', [$source, $target, $workspace->workspaceName->name, $contentRepositoryId->value]); + $this->outputFormatted('Resolved content stream %s', [$workspace->currentContentStreamId]); + + $sourceSubgraph = $contentRepository->getContentGraph()->getSubgraph( + $workspace->currentContentStreamId, + $source, + VisibilityConstraints::withoutRestrictions() + ); + + $rootNodeAggregates = $contentRepository->getContentGraph() + ->findRootNodeAggregates($workspace->currentContentStreamId, FindRootNodeAggregatesFilter::create()); + + + foreach ($rootNodeAggregates as $rootNodeAggregate) { + CatchUpTriggerWithSynchronousOption::synchronously(fn() => + $this->createVariantRecursivelyInternal( + 0, + $rootNodeAggregate->nodeAggregateId, + $sourceSubgraph, + $target, + $workspace->currentContentStreamId, + $contentRepository, + ) + ); + } + + $this->outputFormatted('Done!'); + } + + private function createVariantRecursivelyInternal(int $level, NodeAggregateId $parentNodeAggregateId, ContentSubgraphInterface $sourceSubgraph, OriginDimensionSpacePoint $target, ContentStreamId $contentStreamId, ContentRepository $contentRepository) { + $childNodes = $sourceSubgraph->findChildNodes( + $parentNodeAggregateId, + FindChildNodesFilter::create() + ); + + foreach ($childNodes as $childNode) { + if ($childNode->classification->isRegular()) { + if ($childNode->nodeType->isOfType('Neos.Neos:Document')) { + $this->output("%s- %s\n", [ + str_repeat(' ', $level), + $childNode->getProperty('uriPathSegment') ?? $childNode->nodeAggregateId->value + ]); + } + try { + // Tethered nodes' variants are automatically created when the parent is translated. + $contentRepository->handle(new CreateNodeVariant( + $contentStreamId, + $childNode->nodeAggregateId, + $childNode->originDimensionSpacePoint, + $target + ))->block(); + } catch (DimensionSpacePointIsAlreadyOccupied $e) { + if ($childNode->nodeType->isOfType('Neos.Neos:Document')) { + $this->output("%s (already exists)\n", [ + str_repeat(' ', $level) + ]); + } + } + } + + $this->createVariantRecursivelyInternal( + $level + 1, + $childNode->nodeAggregateId, + $sourceSubgraph, + $target, + $contentStreamId, + $contentRepository + ); + } + } +} diff --git a/Neos.Neos/Classes/Controller/Module/Administration/SitesController.php b/Neos.Neos/Classes/Controller/Module/Administration/SitesController.php index e5368daa5c2..c6cb292fc97 100755 --- a/Neos.Neos/Classes/Controller/Module/Administration/SitesController.php +++ b/Neos.Neos/Classes/Controller/Module/Administration/SitesController.php @@ -14,22 +14,17 @@ namespace Neos\Neos\Controller\Module\Administration; -use Neos\ContentRepository\Core\SharedModel\Exception\NodeNameIsAlreadyOccupied; -use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFoundException; -use Neos\ContentRepository\Core\Feature\NodeAggregateCommandHandler; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; -use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceFinder; +use Neos\ContentRepository\Core\SharedModel\Exception\NodeNameIsAlreadyOccupied; +use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFoundException; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; -use Neos\ContentRepository\Core\Factory\ContentRepositoryId; -use Neos\Flow\Annotations as Flow; use Neos\Error\Messages\Message; -use Neos\Flow\Log\Utility\LogEnvironment; +use Neos\Flow\Annotations as Flow; use Neos\Flow\Package; use Neos\Flow\Package\PackageManager; use Neos\Flow\Session\SessionInterface; @@ -44,7 +39,6 @@ use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\SiteService; -use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\SiteKickstarter\Generator\SitePackageGeneratorInterface; @@ -178,59 +172,59 @@ public function editAction(Site $site) */ public function updateSiteAction(Site $site, $newSiteNodeName) { - if ($site->getNodeName() !== $newSiteNodeName) { - $this->redirect('index'); - } - $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); - - $liveWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::forLive()); - if (!$liveWorkspace instanceof Workspace) { - throw new \InvalidArgumentException( - 'Cannot update a site without the live workspace being present.', - 1651958443 - ); - } - - try { - $sitesNode = $contentRepository->getContentGraph()->findRootNodeAggregateByType( - $liveWorkspace->currentContentStreamId, - NodeTypeName::fromString('Neos.Neos:Sites') - ); - } catch (\Exception $exception) { - throw new \InvalidArgumentException( - 'Cannot update a site without the sites note being present.', - 1651958452 - ); - } + if ($site->getNodeName()->value !== $newSiteNodeName) { + $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); + + $liveWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::forLive()); + if (!$liveWorkspace instanceof Workspace) { + throw new \InvalidArgumentException( + 'Cannot update a site without the live workspace being present.', + 1651958443 + ); + } - $currentUser = $this->domainUserService->getCurrentUser(); - if (is_null($currentUser)) { - throw new \InvalidArgumentException( - 'Cannot update a site without a current user', - 1651958722 - ); - } + try { + $sitesNode = $contentRepository->getContentGraph()->findRootNodeAggregateByType( + $liveWorkspace->currentContentStreamId, + NodeTypeName::fromString('Neos.Neos:Sites') + ); + } catch (\Exception $exception) { + throw new \InvalidArgumentException( + 'Cannot update a site without the sites note being present.', + 1651958452 + ); + } - foreach ($contentRepository->getWorkspaceFinder()->findAll() as $workspace) { - // technically, due to the name being the "identifier", there might be more than one :/ - /** @var NodeAggregate[] $siteNodeAggregates */ - /** @var Workspace $workspace */ - $siteNodeAggregates = $contentRepository->getContentGraph()->findChildNodeAggregatesByName( - $workspace->currentContentStreamId, - $sitesNode->nodeAggregateId, - $site->getNodeName()->toNodeName() - ); + $currentUser = $this->domainUserService->getCurrentUser(); + if (is_null($currentUser)) { + throw new \InvalidArgumentException( + 'Cannot update a site without a current user', + 1651958722 + ); + } - foreach ($siteNodeAggregates as $siteNodeAggregate) { - $contentRepository->handle(new ChangeNodeAggregateName( + foreach ($contentRepository->getWorkspaceFinder()->findAll() as $workspace) { + // technically, due to the name being the "identifier", there might be more than one :/ + /** @var NodeAggregate[] $siteNodeAggregates */ + /** @var Workspace $workspace */ + $siteNodeAggregates = $contentRepository->getContentGraph()->findChildNodeAggregatesByName( $workspace->currentContentStreamId, - $siteNodeAggregate->nodeAggregateId, - NodeName::fromString($newSiteNodeName), - )); + $sitesNode->nodeAggregateId, + $site->getNodeName()->toNodeName() + ); + + foreach ($siteNodeAggregates as $siteNodeAggregate) { + $contentRepository->handle(new ChangeNodeAggregateName( + $workspace->currentContentStreamId, + $siteNodeAggregate->nodeAggregateId, + NodeName::fromString($newSiteNodeName), + )); + } } + + $site->setNodeName($newSiteNodeName); } - $site->setNodeName($newSiteNodeName); $this->siteRepository->update($site); $this->addFlashMessage( @@ -429,7 +423,7 @@ public function createSiteNodeAction($packageKey, $siteName, $nodeType) $this->addFlashMessage( $this->getModuleLabel( 'sites.successfullyCreatedSite.body', - [$site->getName(), $site->getNodeName(), $nodeType, $packageKey] + [$site->getName(), $site->getNodeName()->value, $nodeType, $packageKey] ), '', Message::SEVERITY_OK, diff --git a/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php b/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php index ac276293b54..b097fe55cf3 100644 --- a/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php +++ b/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\Feature\NodeMove\Dto\CoverageNodeMoveMapping; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\ParentNodeMoveDestination; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\SucceedingSiblingNodeMoveDestination; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -120,6 +121,7 @@ public function canHandle(Event $event): bool return in_array($eventClassName, [ RootWorkspaceWasCreated::class, RootNodeAggregateWithNodeWasCreated::class, + RootNodeAggregateDimensionsWereUpdated::class, NodeAggregateWithNodeWasCreated::class, NodeAggregateTypeWasChanged::class, NodePeerVariantWasCreated::class, @@ -155,6 +157,7 @@ private function apply(\Neos\EventStore\Model\EventEnvelope $eventEnvelope): voi match ($eventInstance::class) { RootWorkspaceWasCreated::class => $this->whenRootWorkspaceWasCreated($eventInstance), RootNodeAggregateWithNodeWasCreated::class => $this->whenRootNodeAggregateWithNodeWasCreated($eventInstance), + RootNodeAggregateDimensionsWereUpdated::class => $this->whenRootNodeAggregateDimensionsWereUpdated($eventInstance), NodeAggregateWithNodeWasCreated::class => $this->whenNodeAggregateWithNodeWasCreated($eventInstance), NodeAggregateTypeWasChanged::class => $this->whenNodeAggregateTypeWasChanged($eventInstance), NodePeerVariantWasCreated::class => $this->whenNodePeerVariantWasCreated($eventInstance), @@ -225,6 +228,29 @@ private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNo } } + private function whenRootNodeAggregateDimensionsWereUpdated(RootNodeAggregateDimensionsWereUpdated $event): void + { + if (!$this->isLiveContentStream($event->contentStreamId)) { + return; + } + + $this->dbal->delete( + $this->tableNamePrefix . '_uri', + [ + 'nodeAggregateId' => $event->nodeAggregateId + ] + ); + + foreach ($event->coveredDimensionSpacePoints as $dimensionSpacePoint) { + $this->insertNode([ + 'uriPath' => '', + 'nodeAggregateIdPath' => $event->nodeAggregateId, + 'dimensionSpacePointHash' => $dimensionSpacePoint->hash, + 'nodeAggregateId' => $event->nodeAggregateId, + ]); + } + } + private function whenNodeAggregateWithNodeWasCreated(NodeAggregateWithNodeWasCreated $event): void { if (!$this->isLiveContentStream($event->contentStreamId)) { @@ -617,7 +643,8 @@ private function moveNode( $node->getDimensionSpacePointHash() )); if ($newParentNode === null) { - // This should never happen really.. + // This happens if the parent node does not exist in the moved variant. + // Can happen if the content dimension configuration was updated, and dimension migrations were not run. return; } @@ -625,6 +652,7 @@ private function moveNode( if ($this->isNodeExplicitlyDisabled($node)) { $disabledDelta++; } + $this->updateNodeQuery( /** @codingStandardsIgnoreStart */ 'SET @@ -643,7 +671,26 @@ private function moveNode( 'sourceNodeAggregateIdPathOffset' => (int)strrpos($node->getNodeAggregateIdPath(), '/') + 1, 'newParentUriPath' => $newParentNode->getUriPath(), - 'sourceUriPathOffset' => (int)strrpos($node->getUriPath(), '/') + 1, + // we have to distinguish two cases here: + // - standard case: we want to move the nodes with URI /foo/bar into /target + // -> we want to strip the common prefix of the node (and all descendants) + // and then prepend the suffix with the new parent. Example: + // + // /foo/bar -> /target (+ /bar) => /target/bar + // /foo/bar/baz => /target (+ /bar/baz) => /target/bar/baz + // + // + // - move directly underneath ROOT node of CR. + // the 1st level underneath the root node (in Neos) is the Site node, which needs to have + // an empty uriPath. + // + // This is why we set the offset to the complete length, to create an empty string for the moved node + // in the SQL query above. Example: + // + // /foo/bar -> / (+ /) => / + // /foo/bar/baz => / (+ /baz) => /baz + // + 'sourceUriPathOffset' => $newParentNode->isRoot() ? strlen($node->getUriPath()) + 1 : ((int)strrpos($node->getUriPath(), '/') + 1), 'dimensionSpacePointHash' => $node->getDimensionSpacePointHash(), 'childNodeAggregateIdPathPrefix' => $node->getNodeAggregateIdPath() . '/%', ] diff --git a/Neos.Neos/Documentation/References/Signals/Flow.rst b/Neos.Neos/Documentation/References/Signals/Flow.rst index 5455d201ba9..3aab8cd9d45 100644 --- a/Neos.Neos/Documentation/References/Signals/Flow.rst +++ b/Neos.Neos/Documentation/References/Signals/Flow.rst @@ -3,7 +3,7 @@ Flow Signals Reference ====================== -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Flow Signals Reference: AbstractAdvice (``Neos\Flow\Aop\Advice\AbstractAdvice``)`: diff --git a/Neos.Neos/Documentation/References/Validators/Flow.rst b/Neos.Neos/Documentation/References/Validators/Flow.rst index 7fef07d1928..6a689be6f2e 100644 --- a/Neos.Neos/Documentation/References/Validators/Flow.rst +++ b/Neos.Neos/Documentation/References/Validators/Flow.rst @@ -3,7 +3,7 @@ Flow Validator Reference ======================== -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Flow Validator Reference: AggregateBoundaryValidator`: diff --git a/Neos.Neos/Documentation/References/Validators/Media.rst b/Neos.Neos/Documentation/References/Validators/Media.rst index 0c05a136414..3c2a2d1d7f6 100644 --- a/Neos.Neos/Documentation/References/Validators/Media.rst +++ b/Neos.Neos/Documentation/References/Validators/Media.rst @@ -3,7 +3,7 @@ Media Validator Reference ========================= -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Media Validator Reference: ImageOrientationValidator`: diff --git a/Neos.Neos/Documentation/References/Validators/Party.rst b/Neos.Neos/Documentation/References/Validators/Party.rst index e8f6f090d52..c1c50a6c563 100644 --- a/Neos.Neos/Documentation/References/Validators/Party.rst +++ b/Neos.Neos/Documentation/References/Validators/Party.rst @@ -3,7 +3,7 @@ Party Validator Reference ========================= -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Party Validator Reference: AimAddressValidator`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/ContentRepository.rst b/Neos.Neos/Documentation/References/ViewHelpers/ContentRepository.rst index c954d6f776c..7025c08a16d 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/ContentRepository.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/ContentRepository.rst @@ -3,6 +3,6 @@ Content Repository ViewHelper Reference ####################################### -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index d9107e29136..cd27ffcdae8 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst @@ -3,7 +3,7 @@ FluidAdaptor ViewHelper Reference ################################# -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index bd06c75809e..bb839981cb6 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst @@ -3,7 +3,7 @@ Form ViewHelper Reference ######################### -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Form ViewHelper Reference: neos.form:form`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Fusion.rst b/Neos.Neos/Documentation/References/ViewHelpers/Fusion.rst index a13f07e3e89..323759182be 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Fusion.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Fusion.rst @@ -3,7 +3,7 @@ Fusion ViewHelper Reference ########################### -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Fusion ViewHelper Reference: fusion:render`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst index 881787d9172..5832bba72ee 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst @@ -3,7 +3,7 @@ Media ViewHelper Reference ########################## -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Media ViewHelper Reference: neos.media:fileTypeIcon`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst index cf97488e853..e89ee448a54 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst @@ -3,7 +3,7 @@ Neos ViewHelper Reference ######################### -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`Neos ViewHelper Reference: neos:backend.authenticationProviderLabel`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst index b16ed752468..f6aeea219b4 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst @@ -3,7 +3,7 @@ TYPO3 Fluid ViewHelper Reference ################################ -This reference was automatically generated from code on 2023-03-16 +This reference was automatically generated from code on 2023-03-17 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index e39ab246ce8..007c31f0e06 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -51,7 +51,6 @@ require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/NodeAuthorizationTrait.php'); require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ProjectionIntegrityViolationDetectionTrait.php'); require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/StructureAdjustmentsTrait.php'); -require_once(__DIR__ . '/../../../../../Neos.ContentRepository.Core/Tests/Behavior/Features/Bootstrap/ReadModelInstantiationTrait.php'); require_once(__DIR__ . '/../../../../../../Application/Neos.Behat/Tests/Behat/FlowContextTrait.php'); require_once(__DIR__ . '/../../../../../../Framework/Neos.Flow/Tests/Behavior/Features/Bootstrap/IsolatedBehatStepsTrait.php'); diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature index cb911e3b77a..5715514c520 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature @@ -256,3 +256,145 @@ Feature: Routing functionality with multiple content dimensions When I am on URL "/" And the node "carl-destinode" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"de"}' should resolve to URL "/de/nody/karl-de" And the node "carl-destinode" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"at"}' should resolve to URL "/at/nody/karl-de" + + + Scenario: Create new Dimension value and adjust root node, then root node resolving should still work. + # new "fr" language + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | market | DE, CH | CH->DE | + | language | en, de, gsw, fr | gsw->de->en | + And the sites configuration is: + """ + Neos: + Neos: + sites: + '*': + contentRepository: default + contentDimensions: + resolver: + factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory + options: + segments: + - + dimensionIdentifier: language + dimensionValueMapping: + de: de + gsw: gsw + en: '' + fr: 'fr' + - + dimensionIdentifier: market + dimensionValueMapping: + DE: '' + """ + And the command UpdateRootNodeAggregateDimensions is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + And the graph projection is fully up to date + # create variant for fr and sites node + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"market":"DE", "language":"en"} | + | targetOrigin | {"market":"DE", "language":"fr"} | + And the graph projection is fully up to date + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"market":"DE", "language":"en"} | + | targetOrigin | {"market":"DE", "language":"fr"} | + + And the graph projection is fully up to date + And the command SetNodeProperties is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"market":"DE", "language":"fr"} | + | propertyValues | {"uriPathSegment": "nody-fr"} | + + And the graph projection is fully up to date + + When I am on URL "/" + Then the node "sir-david-nodenborough" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"fr"}' should resolve to URL "/fr/" + Then the node "nody-mc-nodeface" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"fr"}' should resolve to URL "/fr/nody-fr" + + + Scenario: Create new Dimension value and adjust root node, then root node resolving should still work. + # new "fr" language + Given I have the following content dimensions: + | Identifier | Values | Generalizations | + | market | DE, CH | CH->DE | + | language | en, de, gsw, fr | gsw->de->en | + And the sites configuration is: + """ + Neos: + Neos: + sites: + '*': + contentRepository: default + contentDimensions: + resolver: + factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory + options: + segments: + - + dimensionIdentifier: language + dimensionValueMapping: + de: de + gsw: gsw + en: '' + fr: 'fr' + - + dimensionIdentifier: market + dimensionValueMapping: + DE: '' + """ + And the command UpdateRootNodeAggregateDimensions is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + And the graph projection is fully up to date + # create variant for fr and sites node + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"market":"DE", "language":"en"} | + | targetOrigin | {"market":"DE", "language":"fr"} | + And the graph projection is fully up to date + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"market":"DE", "language":"en"} | + | targetOrigin | {"market":"DE", "language":"fr"} | + + And the graph projection is fully up to date + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"market":"DE", "language":"fr"} | + | propertyValues | {"uriPathSegment": "nody-fr"} | + And the graph projection is fully up to date + + + When the command MoveNodeAggregate is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | dimensionSpacePoint | {"market":"DE", "language":"fr"} | + | newParentNodeAggregateId | "lady-eleonode-rootford" | + | newSucceedingSiblingNodeAggregateId | null | + | relationDistributionStrategy | "scatter" | + And The documenturipath projection is up to date + + And the graph projection is fully up to date + + When I am on URL "/" + Then the node "sir-david-nodenborough" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"en"}' should resolve to URL "/" + # moved node + Then the node "nody-mc-nodeface" in content stream "cs-identifier" and dimension '{"market":"DE", "language":"fr"}' should resolve to URL "/fr/"