diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index b273c36a402..b6b760811e6 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -49,6 +49,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\References; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtrees; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; @@ -304,7 +305,11 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, FindSubtreeFi $this->dimensionSpacePoint, $this->visibilityConstraints ); - $subtree = new Subtree((int)$nodeData['level'], $node, array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? array_reverse($subtreesByParentNodeId[$nodeAggregateId]) : []); + $subtree = Subtree::create( + (int)$nodeData['level'], + $node, + array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? Subtrees::fromArray(array_reverse($subtreesByParentNodeId[$nodeAggregateId])) : Subtrees::createEmpty() + ); if ($subtree->level === 0) { return $subtree; } diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php index 3a8068b68dd..e991c81d29e 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php @@ -33,6 +33,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Reference; use Neos\ContentRepository\Core\Projection\ContentGraph\References; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtrees; use Neos\ContentRepository\Core\Projection\ContentGraph\Timestamps; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; @@ -154,7 +155,11 @@ public function mapNodeRowsToSubtree( $nodeAggregateId = $nodeRow['nodeaggregateid']; $parentNodeAggregateId = $nodeRow['parentnodeaggregateid']; $node = $this->mapNodeRowToNode($nodeRow, $visibilityConstraints); - $subtree = new Subtree((int)$nodeRow['level'], $node, array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? array_reverse($subtreesByParentNodeId[$nodeAggregateId]) : []); + $subtree = Subtree::create( + (int)$nodeRow['level'], + $node, + array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? Subtrees::fromArray(array_reverse($subtreesByParentNodeId[$nodeAggregateId])) : Subtrees::createEmpty() + ); if ($subtree->level === 0) { return $subtree; } diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature deleted file mode 100644 index c8c036f9ed2..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature +++ /dev/null @@ -1,90 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Copy nodes (without dimensions) - - Background: - Given using no content dimensions - And using the following node types: - """yaml - 'Neos.ContentRepository.Testing:Document': - references: - ref: [] - """ - And using identifier "default", I define a content repository - And I am in content repository "default" - And the command CreateRootWorkspace is executed with payload: - | Key | Value | - | workspaceName | "live" | - | newContentStreamId | "cs-identifier" | - And I am in workspace "live" - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "lady-eleonode-rootford" | - | nodeTypeName | "Neos.ContentRepository:Root" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "sir-david-nodenborough" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "document" | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "sir-david-nodenborough" | - | nodeName | "child-document" | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "sir-nodeward-nodington-iii" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "esquire" | - | nodeAggregateClassification | "regular" | - - Scenario: Copy - When I am in workspace "live" and dimension space point {} - # node to copy (currentNode): "sir-nodeward-nodington-iii" - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} - When the command CopyNodesRecursively is executed, copying the current node aggregate with payload: - | Key | Value | - | targetDimensionSpacePoint | {} | - | targetParentNodeAggregateId | "nody-mc-nodeface" | - | targetNodeName | "target-nn" | - | targetSucceedingSiblingnodeAggregateId | null | - | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | - - Then I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} - - Scenario: Copy References - When I am in workspace "live" and dimension space point {} - And the command SetNodeReferences is executed with payload: - | Key | Value | - | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | - | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | - - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} - And the command CopyNodesRecursively is executed, copying the current node aggregate with payload: - | Key | Value | - | targetDimensionSpacePoint | {} | - | targetParentNodeAggregateId | "nody-mc-nodeface" | - | targetNodeName | "target-nn" | - | targetSucceedingSiblingnodeAggregateId | null | - | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | - - And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} - And I expect this node to have the following references: - | Name | Node | Properties | - | ref | cs-identifier;sir-david-nodenborough;{} | null | diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php b/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php new file mode 100644 index 00000000000..c76eb1955e7 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php @@ -0,0 +1,62 @@ +handle($command); + * } + * + * @implements \IteratorAggregate + */ +final readonly class Commands implements \IteratorAggregate, \Countable +{ + /** @var array */ + private array $items; + + private function __construct( + CommandInterface ...$items + ) { + $this->items = array_values($items); + } + + public static function create(CommandInterface ...$items): self + { + return new self(...$items); + } + + public static function createEmpty(): self + { + return new self(); + } + + /** @param array $array */ + public static function fromArray(array $array): self + { + return new self(...$array); + } + + public function append(CommandInterface $command): self + { + return new self(...[...$this->items, $command]); + } + + public function merge(self $other): self + { + return new self(...$this->items, ...$other->items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php index e655014cd0f..e3219fa695a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php @@ -14,19 +14,17 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication\Command; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeAggregateIdMapping; use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeSubtreeSnapshot; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping; /** * CopyNodesRecursively command @@ -35,10 +33,10 @@ * The node will be appended as child node of the given `parentNodeId` which must cover the given * `dimensionSpacePoint`. * - * @api commands are the write-API of the ContentRepository + * @internal + * @deprecated with Neos 9 Beta 16, please use Neos's {@see \Neos\Neos\Domain\Service\NodeDuplicationService} instead. */ final readonly class CopyNodesRecursively implements - CommandInterface, \JsonSerializable, MatchableWithNodeIdToPublishOrDiscardInterface, RebasableToOtherWorkspaceInterface diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php index cce5dd61e29..d102d58e26a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php @@ -170,7 +170,7 @@ private function handleCopyNodesRecursively( private function requireNewNodeAggregateIdsToNotExist( ContentGraphInterface $contentGraph, - Dto\NodeAggregateIdMapping $nodeAggregateIdMapping + \Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping $nodeAggregateIdMapping ): void { foreach ($nodeAggregateIdMapping->getAllNewNodeAggregateIds() as $nodeAggregateId) { $this->requireProjectedNodeAggregateToNotExist( @@ -191,7 +191,7 @@ private function createEventsForNodeToInsert( ?NodeAggregateId $targetSucceedingSiblingNodeAggregateId, ?NodeName $targetNodeName, NodeSubtreeSnapshot $nodeToInsert, - Dto\NodeAggregateIdMapping $nodeAggregateIdMapping, + \Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping $nodeAggregateIdMapping, array &$events, ): void { $events[] = new NodeAggregateWithNodeWasCreated( diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php index abb0858233e..3bae2f7828f 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php @@ -70,7 +70,7 @@ public static function fromRootNodeTypeNameAndRelativePath( public static function fromLeafNodeAndAncestors(Node $leafNode, Nodes $ancestors): self { if ($leafNode->classification->isRoot()) { - return new self($leafNode->nodeTypeName, NodePath::forRoot()); + return new self($leafNode->nodeTypeName, NodePath::createEmpty()); } $rootNode = $ancestors->first(); if (!$rootNode || !$rootNode->classification->isRoot()) { @@ -147,7 +147,7 @@ public function appendPathSegment(NodeName $nodeName): self */ public function isRoot(): bool { - return $this->path->isRoot(); + return $this->path->isEmpty(); } /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php index 6ef34fe7d8a..43be4c36620 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php @@ -17,7 +17,9 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; /** - * The relative node path is a collection of node names {@see NodeName}. If it contains no elements, it is considered root. + * The relative node path is a collection of node names {@see NodeName}. + * + * If it contains no elements, it is considered root in combination with {@see AbsoluteNodePath}. * * Example: * root path: '' is resolved to [] @@ -48,7 +50,7 @@ private function __construct(NodeName ...$nodeNames) $this->nodeNames = $nodeNames; } - public static function forRoot(): self + public static function createEmpty(): self { return new self(); } @@ -57,7 +59,7 @@ public static function fromString(string $path): self { $path = ltrim($path, '/'); if ($path === '') { - return self::forRoot(); + return self::createEmpty(); } return self::fromPathSegments( @@ -92,7 +94,7 @@ public static function fromNodeNames(NodeName ...$nodeNames): self return new self(...$nodeNames); } - public function isRoot(): bool + public function isEmpty(): bool { return $this->getLength() === 0; } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php index 1f0ea1f5033..db0b5af29ed 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php @@ -5,17 +5,25 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph; /** - * @api returned by {@see ContentSubgraphInterface} + * @api returned by {@see ContentSubgraphInterface::findSubtree()} */ final readonly class Subtree { - /** - * @param array $children - */ - public function __construct( + private function __construct( public int $level, public Node $node, - public array $children + public Subtrees $children ) { } + + /** + * @internal + */ + public static function create( + int $level, + Node $node, + Subtrees $children + ): self { + return new self($level, $node, $children); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php new file mode 100644 index 00000000000..2a946946b82 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php @@ -0,0 +1,48 @@ + + */ +final readonly class Subtrees implements \IteratorAggregate, \Countable +{ + /** @var array */ + private array $items; + + private function __construct( + Subtree ...$items + ) { + $this->items = $items; + } + + /** + * @internal + */ + public static function createEmpty(): self + { + return new self(); + } + + /** + * @internal + * @param array $items + */ + public static function fromArray(array $items): self + { + return new self(...$items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/NodePathTest.php b/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/NodePathTest.php index 79f20e2d5b7..9eb5da58ddf 100644 --- a/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/NodePathTest.php +++ b/Neos.ContentRepository.Core/Tests/Unit/Projection/ContentGraph/NodePathTest.php @@ -22,7 +22,7 @@ class NodePathTest extends TestCase */ public function testDeserialization( string $serializedPath, - bool $expectedRootState, + bool $expectedEmptyState, /** @var array $expectedParts */ array $expectedParts, int $expectedLength @@ -30,7 +30,7 @@ public function testDeserialization( $subject = NodePath::fromString($serializedPath); self::assertSame($serializedPath, $subject->serializeToString()); - self::assertSame($expectedRootState, $subject->isRoot()); + self::assertSame($expectedEmptyState, $subject->isEmpty()); self::assertEquals($expectedParts, $subject->getParts()); self::assertSame($expectedLength, $subject->getLength()); } @@ -39,7 +39,7 @@ public static function serializedPathProvider(): iterable { yield 'nonRoot' => [ 'serializedPath' => 'child/grandchild', - 'expectedRootState' => false, + 'isEmpty' => false, 'expectedParts' => [ NodeName::fromString('child'), NodeName::fromString('grandchild'), @@ -49,7 +49,7 @@ public static function serializedPathProvider(): iterable yield 'root' => [ 'serializedPath' => '', - 'expectedRootState' => true, + 'isEmpty' => true, 'expectedParts' => [], 'expectedLength' => 0 ]; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index 68e1075f1c1..a6cac6dc8b2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -61,7 +61,6 @@ trait CRTestSuiteTrait use ContentStreamClosing; use NodeCreation; - use NodeCopying; use SubtreeTagging; use NodeModification; use NodeMove; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index 5d1b7157069..0afbf60c150 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -29,7 +29,6 @@ use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; @@ -191,7 +190,9 @@ protected function addDefaultCommandArgumentValues(string $commandClassName, arr } } if ($commandClassName === CreateNodeAggregateWithNode::class || $commandClassName === SetNodeReferences::class) { - if (is_array($commandArguments['references'] ?? null)) { + if (is_string($commandArguments['references'] ?? null)) { + $commandArguments['references'] = iterator_to_array($this->mapRawNodeReferencesToNodeReferencesToWrite(json_decode($commandArguments['references'], true, 512, JSON_THROW_ON_ERROR))); + } elseif (is_array($commandArguments['references'] ?? null)) { $commandArguments['references'] = iterator_to_array($this->mapRawNodeReferencesToNodeReferencesToWrite($commandArguments['references'])); } } @@ -243,7 +244,6 @@ protected static function resolveShortCommandName(string $shortCommandName): str ChangeBaseWorkspace::class, ChangeNodeAggregateName::class, ChangeNodeAggregateType::class, - CopyNodesRecursively::class, CreateNodeAggregateWithNode::class, CreateNodeVariant::class, CreateRootNodeAggregateWithNode::class, diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php index 56437a286c6..01cbc8ccf87 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php @@ -100,7 +100,7 @@ public function migratePayloadToWorkspaceNameCommand(string $contentRepository = * Needed for feature "Stabilize WorkspaceName value object": https://github.com/neos/neos-development-collection/pull/5193 * * Included in August 2024 - before final Neos 9.0 release - * + * * @param string $contentRepository Identifier of the Content Repository to migrate */ public function migratePayloadToValidWorkspaceNamesCommand(string $contentRepository = 'default'): void @@ -133,4 +133,39 @@ public function reorderNodeAggregateWasRemovedCommand(string $contentRepository $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); $eventMigrationService->reorderNodeAggregateWasRemoved($this->outputLine(...)); } + + /** + * Migrates "nodeAggregateClassification":"tethered" to "regular", in case for copied tethered nodes. + * + * Needed for #5350: https://github.com/neos/neos-development-collection/issues/5350 + * + * Included in November 2024 - before final Neos 9.0 release + * + * @param string $contentRepository Identifier of the Content Repository to migrate + */ + public function migrateCopyTetheredNodeCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->migrateCopyTetheredNode($this->outputLine(...)); + } + + /** + * Status information if content streams still contain legacy copy node events + * + * Needed for #5371: https://github.com/neos/neos-development-collection/pull/5371 + * + * Included in November 2024 - before final Neos 9.0 release + * + * NOTE: To reduce the number of matched content streams and to cleanup the event store run + * `./flow contentStream:removeDangling` and `./flow contentStream:pruneRemovedFromEventStream` + * + * @param string $contentRepository Identifier of the Content Repository to check + */ + public function copyNodesStatusCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->copyNodesStatus($this->outputLine(...)); + } } diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index be7cfcc6292..1e7364dcee2 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -25,6 +25,7 @@ use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\Feature\WorkspaceEventStreamName; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\Command\MigrateEventsCommandController; @@ -810,6 +811,93 @@ public function reorderNodeAggregateWasRemoved(\Closure $outputFn): void $outputFn(sprintf('Reordered %d removals. Please replay and rebase your other workspaces.', count($eventsToReorder))); } + public function migrateCopyTetheredNode(\Closure $outputFn): void + { + $this->eventsModified = []; + + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId) + . '_bkp_' . date('Y_m_d_H_i_s'); + $outputFn(sprintf('Backup: copying events table to %s', $backupEventTableName)); + + $this->copyEventTable($backupEventTableName); + + $streamName = VirtualStreamName::all(); + $eventStream = $this->eventStore->load($streamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeAggregateWithNodeWasCreated')))); + foreach ($eventStream as $eventEnvelope) { + $outputRewriteNotice = fn(string $message) => $outputFn(sprintf('%s@%s %s', $eventEnvelope->sequenceNumber->value, $eventEnvelope->event->type->value, $message)); + if ($eventEnvelope->event->type->value !== 'NodeAggregateWithNodeWasCreated') { + throw new \RuntimeException(sprintf('Unhandled event: %s', $eventEnvelope->event->type->value)); + } + + $eventMetaData = $eventEnvelope->event->metadata?->value; + // a copy is basically a NodeAggregateWithNodeWasCreated with CopyNodesRecursively command, so we skip others: + if (!$eventMetaData || ($eventMetaData['commandClass'] ?? null) !== CopyNodesRecursively::class) { + continue; + } + + $eventData = self::decodeEventPayload($eventEnvelope); + if ($eventData['nodeAggregateClassification'] !== NodeAggregateClassification::CLASSIFICATION_TETHERED->value) { + // this copy is okay + continue; + } + + $eventData['nodeAggregateClassification'] = NodeAggregateClassification::CLASSIFICATION_REGULAR->value; + $this->updateEventPayload($eventEnvelope->sequenceNumber, $eventData); + + $eventMetaData['commandPayload']['nodeTreeToInsert']['nodeAggregateClassification'] = NodeAggregateClassification::CLASSIFICATION_REGULAR->value; + + $this->updateEventMetaData($eventEnvelope->sequenceNumber, $eventMetaData); + $outputRewriteNotice(sprintf('Copied tethered node "%s" of type "%s" (name: %s) was migrated', $eventData['nodeAggregateId'], $eventData['nodeTypeName'], json_encode($eventData['nodeName']))); + } + + if (!count($this->eventsModified)) { + $outputFn('Migration was not necessary.'); + return; + } + + $outputFn(); + $outputFn(sprintf('Migration applied to %s events. Please replay the projections `./flow cr:projectionReplayAll`', count($this->eventsModified))); + } + + public function copyNodesStatus(\Closure $outputFn): void + { + $unpublishedCopyNodesInWorkspaces = []; + + $streamName = VirtualStreamName::all(); + $eventStream = $this->eventStore->load($streamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeAggregateWithNodeWasCreated')))); + foreach ($eventStream as $eventEnvelope) { + $eventMetaData = $eventEnvelope->event->metadata?->value; + // a copy is basically a NodeAggregateWithNodeWasCreated with CopyNodesRecursively command, so we skip others: + if (!$eventMetaData || ($eventMetaData['commandClass'] ?? null) !== CopyNodesRecursively::class) { + continue; + } + + $eventData = self::decodeEventPayload($eventEnvelope); + + if ($eventData['workspaceName'] !== 'live') { + $unpublishedCopyNodesInWorkspaces[$eventData['contentStreamId'] . ' (' . $eventData['workspaceName'] . ')'][] = sprintf( + '@%s copy node %s to %s', + $eventEnvelope->sequenceNumber->value, + $eventMetaData['commandPayload']['nodeTreeToInsert']['nodeAggregateId'], + $eventMetaData['commandPayload']['targetParentNodeAggregateId'] + ); + } + } + + if ($unpublishedCopyNodesInWorkspaces === []) { + $outputFn('Everything regarding copy nodes okay.'); + return; + } + $outputFn('WARNING: %d content streams contain unpublished legacy copy node events. They MUST be published before migrating to Neos 9 (stable) and will not be publishable afterward.', [count($unpublishedCopyNodesInWorkspaces)]); + foreach ($unpublishedCopyNodesInWorkspaces as $contentStream => $unpublishedCopyNodesInWorkspace) { + $outputFn('Content stream %s', [$contentStream]); + foreach ($unpublishedCopyNodesInWorkspace as $unpublishedCopyNode) { + $outputFn(' - %s', [$unpublishedCopyNode]); + } + } + $outputFn('NOTE: To reduce the number of matched content streams and to cleanup the event store run `./flow contentStream:removeDangling` and `./flow contentStream:pruneRemovedFromEventStream`'); + } + /** ------------------------ */ /** diff --git a/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php b/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php new file mode 100644 index 00000000000..a75ae98ad96 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php @@ -0,0 +1,24 @@ + */ - protected array $nodeAggregateIds = []; + private array $nodeAggregateIds = []; /** * @param array $nodeAggregateIds */ - public function __construct(array $nodeAggregateIds) + private function __construct(array $nodeAggregateIds) { foreach ($nodeAggregateIds as $oldNodeAggregateId => $newNodeAggregateId) { $oldNodeAggregateId = NodeAggregateId::fromString($oldNodeAggregateId); @@ -54,12 +51,25 @@ public function __construct(array $nodeAggregateIds) } } + public static function createEmpty(): self + { + return new self([]); + } + + public function withNewNodeAggregateId(NodeAggregateId $oldNodeAggregateId, NodeAggregateId $newNodeAggregateId): self + { + $nodeAggregateIds = $this->nodeAggregateIds; + $nodeAggregateIds[$oldNodeAggregateId->value] = $newNodeAggregateId; + return new self($nodeAggregateIds); + } + /** * Create a new id mapping, *GENERATING* new ids. */ public static function generateForNodeSubtreeSnapshot(NodeSubtreeSnapshot $nodeSubtreeSnapshot): self { $nodeAggregateIdMapping = []; + /** @phpstan-ignore neos.cr.internal */ $nodeSubtreeSnapshot->walk( function (NodeSubtreeSnapshot $nodeSubtreeSnapshot) use (&$nodeAggregateIdMapping) { // here, we create new random NodeAggregateIds. @@ -71,13 +81,13 @@ function (NodeSubtreeSnapshot $nodeSubtreeSnapshot) use (&$nodeAggregateIdMappin } /** - * @param array $array + * @param array $array */ public static function fromArray(array $array): self { $nodeAggregateIds = []; foreach ($array as $oldNodeAggregateId => $newNodeAggregateId) { - $nodeAggregateIds[$oldNodeAggregateId] = NodeAggregateId::fromString($newNodeAggregateId); + $nodeAggregateIds[$oldNodeAggregateId] = $newNodeAggregateId instanceof NodeAggregateId ? $newNodeAggregateId : NodeAggregateId::fromString($newNodeAggregateId); } return new self($nodeAggregateIds); diff --git a/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php b/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php new file mode 100644 index 00000000000..3efcb5d94be --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php @@ -0,0 +1,135 @@ +getNewNodeAggregateId($subtree->node->aggregateId) ?? NodeAggregateId::create(), + $targetWorkspaceName, + $targetOriginDimensionSpacePoint, + $nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $nodeAggregateIdMapping, + NodePath::createEmpty(), + NodeAggregateIdsByNodePaths::createEmpty() + ) + ); + } + + public function forTetheredChildNode(Subtree $subtree): self + { + $nodeName = $subtree->node->name; + if (!$subtree->node->classification->isTethered() || $nodeName === null) { + throw new \InvalidArgumentException(sprintf('Node "%s" must be tethered if given to "forTetheredChildNode".', $subtree->node->aggregateId->value)); + } + + $nodeAggregateId = $this->tetheredNodeAggregateIds->getNodeAggregateId(NodePath::fromNodeNames($nodeName)); + + if ($nodeAggregateId === null) { + throw new \InvalidArgumentException(sprintf('Name "%s" doesnt seem to be a point to a tethered node of "%s", could not determine deterministic node aggregate id.', $nodeName->value, $this->aggregateId->value)); + } + + return new self( + $nodeAggregateId, + $this->workspaceName, + $this->originDimensionSpacePoint, + $this->nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $this->nodeAggregateIdMapping, + NodePath::createEmpty(), + // we don't have to keep the relative $this->tetheredNodeAggregateIds for the current $nodName as we will just recalculate them from the subtree + NodeAggregateIdsByNodePaths::createEmpty() + ), + ); + } + + public function forRegularChildNode(Subtree $subtree): self + { + return new self( + $this->nodeAggregateIdMapping->getNewNodeAggregateId( + $subtree->node->aggregateId + ) ?? NodeAggregateId::create(), + $this->workspaceName, + $this->originDimensionSpacePoint, + $this->nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $this->nodeAggregateIdMapping, + NodePath::createEmpty(), + NodeAggregateIdsByNodePaths::createEmpty() + ), + ); + } + + private static function getTetheredDescendantNodeAggregateIds(Subtrees $subtreeChildren, NodeAggregateIdMapping $nodeAggregateIdMapping, NodePath $nodePath, NodeAggregateIdsByNodePaths $tetheredNodeAggregateIds): NodeAggregateIdsByNodePaths + { + foreach ($subtreeChildren as $childSubtree) { + if (!$childSubtree->node->classification->isTethered() || !$childSubtree->node->name) { + continue; + } + + $deterministicCopyAggregateId = $nodeAggregateIdMapping->getNewNodeAggregateId($childSubtree->node->aggregateId) ?? NodeAggregateId::create(); + + $childNodePath = $nodePath->appendPathSegment($childSubtree->node->name); + + $tetheredNodeAggregateIds = $tetheredNodeAggregateIds->add( + $childNodePath, + $deterministicCopyAggregateId + ); + + $tetheredNodeAggregateIds = self::getTetheredDescendantNodeAggregateIds($childSubtree->children, $nodeAggregateIdMapping, $childNodePath, $tetheredNodeAggregateIds); + } + + return $tetheredNodeAggregateIds; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php b/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php new file mode 100644 index 00000000000..dbd7d21460e --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php @@ -0,0 +1,290 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph($sourceDimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); + + $targetParentNode = $subgraph->findNodeById($targetParentNodeAggregateId); + if ($targetParentNode === null) { + throw new NodeAggregateCurrentlyDoesNotExist(sprintf('The target parent node aggregate "%s" does not exist', $targetParentNodeAggregateId->value), 1732006769); + } + + $subtree = $subgraph->findSubtree($sourceNodeAggregateId, FindSubtreeFilter::create()); + if ($subtree === null) { + throw new NodeAggregateCurrentlyDoesNotExist(sprintf('The source node aggregate "%s" does not exist', $sourceNodeAggregateId->value), 1732006772); + } + + $commands = $this->calculateCopyNodesRecursively( + $subtree, + $subgraph, + $workspaceName, + $targetDimensionSpacePoint, + $targetParentNodeAggregateId, + $targetSucceedingSiblingNodeAggregateId, + $nodeAggregateIdMapping + ); + + foreach ($commands as $command) { + $contentRepository->handle($command); + } + } + + /** + * @internal implementation detail of {@see NodeDuplicationService::copyNodesRecursively}, exposed for EXPERIMENTAL use cases, the API can change any time! + */ + public function calculateCopyNodesRecursively( + Subtree $subtreeToCopy, + ContentSubgraphInterface $subgraph, + WorkspaceName $targetWorkspaceName, + OriginDimensionSpacePoint $targetDimensionSpacePoint, + NodeAggregateId $targetParentNodeAggregateId, + ?NodeAggregateId $targetSucceedingSiblingNodeAggregateId, + ?NodeAggregateIdMapping $nodeAggregateIdMapping = null + ): Commands { + $transientNodeCopy = TransientNodeCopy::forEntry( + $subtreeToCopy, + $targetWorkspaceName, + $targetDimensionSpacePoint, + $nodeAggregateIdMapping ?? NodeAggregateIdMapping::createEmpty() + ); + + $createCopyOfNodeCommand = CreateNodeAggregateWithNode::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $subtreeToCopy->node->nodeTypeName, + $targetDimensionSpacePoint, + $targetParentNodeAggregateId, + succeedingSiblingNodeAggregateId: $targetSucceedingSiblingNodeAggregateId, + // todo skip properties not in schema + initialPropertyValues: PropertyValuesToWrite::fromArray( + iterator_to_array($subtreeToCopy->node->properties) + ), + references: $this->serializeProjectedReferences( + $subgraph->findReferences($subtreeToCopy->node->aggregateId, FindReferencesFilter::create()) + ) + ); + + $createCopyOfNodeCommand = $createCopyOfNodeCommand->withTetheredDescendantNodeAggregateIds( + $transientNodeCopy->tetheredNodeAggregateIds + ); + + $commands = Commands::create($createCopyOfNodeCommand); + + foreach ($subtreeToCopy->node->tags->withoutInherited() as $explicitTag) { + $commands = $commands->append( + TagSubtree::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $transientNodeCopy->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS, + $explicitTag + ) + ); + } + + foreach ($subtreeToCopy->children as $childSubtree) { + if ($subtreeToCopy->node->classification->isTethered() && $childSubtree->node->classification->isTethered()) { + // TODO we assume here that the child node is tethered because the grandparent specifies that. + // this is not always fully correct and we could loosen the constraint by checking the node type schema + throw new TetheredNodesCannotBePartiallyCopied(sprintf('Cannot copy tethered node %s because child node %s is also tethered. Only standalone tethered nodes can be copied.', $subtreeToCopy->node->aggregateId->value, $childSubtree->node->aggregateId->value), 1731264887); + } + $commands = $this->commandsForSubtreeRecursively($transientNodeCopy, $childSubtree, $subgraph, $commands); + } + + return $commands; + } + + private function commandsForSubtreeRecursively(TransientNodeCopy $transientParentNode, Subtree $subtree, ContentSubgraphInterface $subgraph, Commands $commands): Commands + { + if ($subtree->node->classification->isTethered()) { + /** + * Case Node is tethered + */ + $transientNodeCopy = $transientParentNode->forTetheredChildNode( + $subtree + ); + + if ($subtree->node->properties->count() > 0) { + $setPropertiesOfTetheredNodeCommand = SetNodeProperties::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $transientParentNode->originDimensionSpacePoint, + // todo skip properties not in schema + PropertyValuesToWrite::fromArray( + iterator_to_array($subtree->node->properties) + ), + ); + + $commands = $commands->append($setPropertiesOfTetheredNodeCommand); + } + + $references = $subgraph->findReferences($subtree->node->aggregateId, FindReferencesFilter::create()); + if ($references->count() > 0) { + $setReferencesOfTetheredNodeCommand = SetNodeReferences::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $transientParentNode->originDimensionSpacePoint, + $this->serializeProjectedReferences( + $references + ), + ); + + $commands = $commands->append($setReferencesOfTetheredNodeCommand); + } + } else { + /** + * Case Node is a regular child + */ + $transientNodeCopy = $transientParentNode->forRegularChildNode( + $subtree + ); + + $createCopyOfNodeCommand = CreateNodeAggregateWithNode::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $subtree->node->nodeTypeName, + $transientParentNode->originDimensionSpacePoint, + $transientParentNode->aggregateId, + // todo succeedingSiblingNodeAggregateId + // todo skip properties not in schema + initialPropertyValues: PropertyValuesToWrite::fromArray( + iterator_to_array($subtree->node->properties) + ), + references: $this->serializeProjectedReferences( + $subgraph->findReferences($subtree->node->aggregateId, FindReferencesFilter::create()) + ) + ); + + $createCopyOfNodeCommand = $createCopyOfNodeCommand->withTetheredDescendantNodeAggregateIds( + $transientNodeCopy->tetheredNodeAggregateIds + ); + + $commands = $commands->append($createCopyOfNodeCommand); + } + + /** + * common logic + */ + + foreach ($subtree->node->tags->withoutInherited() as $explicitTag) { + $commands = $commands->append( + TagSubtree::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $transientNodeCopy->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS, + $explicitTag + ) + ); + } + + foreach ($subtree->children as $childSubtree) { + $commands = $this->commandsForSubtreeRecursively($transientNodeCopy, $childSubtree, $subgraph, $commands); + } + + return $commands; + } + + private function serializeProjectedReferences(References $references): NodeReferencesToWrite + { + $serializedReferencesByName = []; + foreach ($references as $reference) { + if (!isset($serializedReferencesByName[$reference->name->value])) { + $serializedReferencesByName[$reference->name->value] = []; + } + $serializedReferencesByName[$reference->name->value][] = NodeReferenceToWrite::fromTargetAndProperties($reference->node->aggregateId, $reference->properties?->count() > 0 ? PropertyValuesToWrite::fromArray(iterator_to_array($reference->properties)) : PropertyValuesToWrite::createEmpty()); + } + + $serializedReferences = []; + foreach ($serializedReferencesByName as $name => $referenceObjects) { + $serializedReferences[] = NodeReferencesForName::fromReferences(ReferenceName::fromString($name), $referenceObjects); + } + + return NodeReferencesToWrite::fromArray($serializedReferences); + } +} diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 6bd844d8a5a..74bfd788aa4 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -10,7 +10,6 @@ use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; @@ -152,7 +151,6 @@ public function canExecuteCommand(CommandInterface $command): Privilege private function nodeThatRequiresEditPrivilegeForCommand(CommandInterface $command): ?NodeAddress { return match ($command::class) { - CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId), CreateNodeAggregateWithNode::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId), CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId), DisableNodeAggregate::class, diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index bd87a6fd454..4580d4b5dc0 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -50,6 +50,8 @@ class FeatureContext implements BehatContext use ContentRepositorySecurityTrait; use UserServiceTrait; + use NodeDuplicationTrait; + protected Environment $environment; protected ContentRepositoryRegistry $contentRepositoryRegistry; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php similarity index 53% rename from Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php rename to Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php index 5b020a33873..2b3c00eb496 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php @@ -12,61 +12,70 @@ declare(strict_types=1); -namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeAggregateIdMapping; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; +use Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping; +use Neos\Neos\Domain\Service\NodeDuplicationService; /** * The node copying trait for behavioral tests */ -trait NodeCopying +trait NodeDuplicationTrait { use CRTestSuiteRuntimeVariables; + use ExceptionsTrait; + + /** + * @template T of object + * @param class-string $className + * + * @return T + */ + abstract private function getObject(string $className): object; abstract protected function readPayloadTable(TableNode $payloadTable): array; /** - * @When /^the command CopyNodesRecursively is executed, copying the current node aggregate with payload:$/ + * @When /^copy nodes recursively is executed with payload:$/ */ - public function theCommandCopyNodesRecursivelyIsExecutedCopyingTheCurrentNodeAggregateWithPayload(TableNode $payloadTable): void + public function copyNodesRecursivelyIsExecutedWithPayload(TableNode $payloadTable): void { $commandArguments = $this->readPayloadTable($payloadTable); - $subgraph = $this->currentContentRepository->getContentGraph($this->currentWorkspaceName)->getSubgraph( - $this->currentDimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); + + $workspaceName = isset($commandArguments['workspaceName']) + ? WorkspaceName::fromString($commandArguments['workspaceName']) + : $this->currentWorkspaceName; + + $sourceNodeAggregateId = NodeAggregateId::fromString($commandArguments['sourceNodeAggregateId']); + $sourceDimensionSpacePoint = isset($commandArguments['sourceDimensionSpacePoint']) + ? DimensionSpacePoint::fromArray($commandArguments['sourceDimensionSpacePoint']) + : $this->currentDimensionSpacePoint; + $targetDimensionSpacePoint = isset($commandArguments['targetDimensionSpacePoint']) ? OriginDimensionSpacePoint::fromArray($commandArguments['targetDimensionSpacePoint']) : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); + $targetSucceedingSiblingNodeAggregateId = isset($commandArguments['targetSucceedingSiblingNodeAggregateId']) ? NodeAggregateId::fromString($commandArguments['targetSucceedingSiblingNodeAggregateId']) : null; - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - - $command = CopyNodesRecursively::createFromSubgraphAndStartNode( - $subgraph, - $workspaceName, - $this->currentNode, - $targetDimensionSpacePoint, - NodeAggregateId::fromString($commandArguments['targetParentNodeAggregateId']), - $targetSucceedingSiblingNodeAggregateId + $this->tryCatchingExceptions( + fn () => $this->getObject(NodeDuplicationService::class)->copyNodesRecursively( + $this->currentContentRepository->id, + $workspaceName, + $sourceDimensionSpacePoint, + $sourceNodeAggregateId, + $targetDimensionSpacePoint, + NodeAggregateId::fromString($commandArguments['targetParentNodeAggregateId']), + $targetSucceedingSiblingNodeAggregateId, + NodeAggregateIdMapping::fromArray($commandArguments['nodeAggregateIdMapping'] ?? []) + ) ); - if (isset($commandArguments['targetNodeName'])) { - $command = $command->withTargetNodeName(NodeName::fromString($commandArguments['targetNodeName'])); - } - $command = $command->withNodeAggregateIdMapping(NodeAggregateIdMapping::fromArray($commandArguments['nodeAggregateIdMapping'])); - - $this->currentContentRepository->handle($command); } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature new file mode 100644 index 00000000000..edfdda081ca --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature @@ -0,0 +1,55 @@ +@contentrepository +Feature: Copy nodes constraint checks + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Document': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | nody-mc-nodeface | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | sir-nodeward-nodington-iii | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + + Scenario: Coping fails if the source node does not exist + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "not-existing" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + + Then an exception of type NodeAggregateCurrentlyDoesNotExist should be thrown with code 1732006772 + + Scenario: Coping fails if the target node does not exist + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nody-mc-nodeface" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "not-existing" | + + Then an exception of type NodeAggregateCurrentlyDoesNotExist should be thrown with code 1732006769 + + Scenario: Coping fails if the source node is a root + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "lady-eleonode-rootford" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + + Then an exception of type NodeTypeIsOfTypeRoot should be thrown with code 1541765806 diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature new file mode 100644 index 00000000000..4bb4ea277c7 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature @@ -0,0 +1,54 @@ +@contentrepository +Feature: Copy nodes constraint checks + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': [] + 'Neos.ContentRepository.Testing:TetheredDocument': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + 'Neos.ContentRepository.Testing:Document': + childNodes: + tethered-document: + type: 'Neos.ContentRepository.Testing:TetheredDocument' + 'Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren': [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | node-mc-nodeface | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | {} | + | node-wan-kenody | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | {"tethered-document": "nodewyn-tetherton", "tethered-document/tethered": "nodimer-tetherton"} | + | sir-david-nodenburg | lady-eleonode-rootford | Neos.ContentRepository.Testing:TetheredDocument | {"tethered": "davids-tether"} | + + Scenario: Coping fails if the leaf of a nested tethered node is attempted to be copied + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + And I expect the node aggregate "nodimer-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "node-mc-nodeface" | + + Then an exception of type TetheredNodesCannotBePartiallyCopied should be thrown with message: + """ + Cannot copy tethered node nodewyn-tetherton because child node nodimer-tetherton is also tethered. Only standalone tethered nodes can be copied. + """ diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature new file mode 100644 index 00000000000..f061b789a00 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature @@ -0,0 +1,221 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Copy nodes (without dimensions) + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Document': + properties: + title: + type: string + array: + type: array + uri: + type: GuzzleHttp\Psr7\Uri + date: + type: DateTimeImmutable + references: + ref: [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | sir-david-nodenborough | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | nody-mc-nodeface | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | + | node-wan-kenodi | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | sir-nodeward-nodington-iii | node-wan-kenodi | Neos.ContentRepository.Testing:Document | + + Scenario: Simple singular node aggregate is copied + When I am in workspace "live" and dimension space point {} + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + Then I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Document" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["nody-mc-nodeface"] + + Scenario: Singular node aggregate is copied with (complex) properties + When I am in workspace "live" and dimension space point {} + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | propertyValues | {"title": "Original Text", "array": {"givenName":"Nody", "familyName":"McNodeface"}, "uri": {"__type": "GuzzleHttp\\\\Psr7\\\\Uri", "value": "https://neos.de"}, "date": {"__type": "DateTimeImmutable", "value": "2001-09-22T12:00:00+00:00"}} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to have the following serialized properties: + | Key | Type | Value | + | title | string | "Original Text" | + | array | array | {"givenName":"Nody","familyName":"McNodeface"} | + | date | DateTimeImmutable | "2001-09-22T12:00:00+00:00" | + | uri | GuzzleHttp\Psr7\Uri | "https://neos.de" | + + Scenario: Singular node aggregate is copied with references + When I am in workspace "live" and dimension space point {} + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Singular node aggregate is copied with subtree tags (and disabled state) + When I am in workspace "live" and dimension space point {} + + Given the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "node-wan-kenodi" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "parent-tag" | + + And VisibilityConstraints are set to "withoutRestrictions" + + # we inherit the tag here but DONT copy it! + Then I expect the node with aggregate identifier "sir-nodeward-nodington-iii" to inherit the tag "parent-tag" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to disable dimension space points [{}] + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to be exactly explicitly tagged "disabled,tag1" + + Scenario: Node aggregate is copied children recursively + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | {} | + | child-a1 | child-a | Neos.ContentRepository.Testing:Document | {"title": "I am Node A1"} | + | child-a2 | child-a | Neos.ContentRepository.Testing:Document | {} | + | child-b | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | {} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy", "child-b": "child-b-copy", "child-a1": "child-a1-copy", "child-a2": "child-a2-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to have the child node aggregates ["child-a-copy","child-b-copy"] + + And I expect the node aggregate "child-a-copy" to exist + And I expect this node aggregate to have the child node aggregates ["child-a1-copy","child-a2-copy"] + + And I expect the node aggregate "child-b-copy" to exist + And I expect this node aggregate to have no child node aggregates + + And I expect node aggregate identifier "child-a1-copy" to lead to node cs-identifier;child-a1-copy;{} + And I expect this node to have the following serialized properties: + | Key | Type | Value | + | title | string | "I am Node A1" | + + Scenario: References are copied for child nodes + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | references | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy"} | + + And I expect node aggregate identifier "child-a-copy" to lead to node cs-identifier;child-a-copy;{} + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Subtree tags are copied for child nodes + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "child-a" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy"} | + + And I expect node aggregate identifier "child-a-copy" to lead to node cs-identifier;child-a-copy;{} + And I expect this node to be exactly explicitly tagged "tag1" diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature new file mode 100644 index 00000000000..90359de22c4 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature @@ -0,0 +1,213 @@ +Feature: Copy nodes with tethered nodes + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': + properties: + title: + type: string + references: + ref: [] + 'Neos.ContentRepository.Testing:DocumentWithTethered': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + 'Neos.ContentRepository.Testing:RootDocument': + childNodes: + tethered-document: + type: 'Neos.ContentRepository.Testing:DocumentWithTethered' + 'Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren': [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + When I am in workspace "live" and dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | sir-david-nodenborough | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | {} | + | nody-mc-nodeface | sir-david-nodenborough | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | | + | nodimus-primus | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | | + | sir-nodeward-nodington-i | nodimus-primus | Neos.ContentRepository.Testing:DocumentWithTethered | {"tethered": "nodewyn-tetherton"} | + | node-wan-kenodi | sir-david-nodenborough | Neos.ContentRepository.Testing:RootDocument | {"tethered-document": "tethered-document", "tethered-document/tethered": "tethered-document-child"} | + + Scenario: Coping a tethered node turns it into a regular node + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + # must not be tethered! + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + 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 disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + Scenario: Coping a node with tethered node keeps the child node tethered + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-i" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-i": "sir-nodeward-nodington-ii", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-ii" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["nodewyn-tetherton-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-nodeward-nodington-ii"] + + Scenario: Coping a node with nested tethered nodes keeps the child nodes tethered + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "node-wan-kenodi" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"node-wan-kenodi": "node-wan-kenodi-copy", "tethered-document": "tethered-document-copy", "tethered-document-child": "tethered-document-child-copy"} | + + And I expect the node aggregate "node-wan-kenodi-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:RootDocument" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["tethered-document-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "tethered-document-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered-document" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["tethered-document-child-copy"] + + And I expect the node aggregate "tethered-document-child-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + + Scenario: Coping a regular node with a child node that has a tethered child node + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodimus-primus" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"nodimus-primus": "nodimus-primus-copy", "sir-nodeward-nodington-i": "sir-nodeward-nodington-i-copy", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "nodimus-primus-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["sir-nodeward-nodington-i-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "sir-nodeward-nodington-i-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["nodewyn-tetherton-copy"] + And I expect this node aggregate to have the parent node aggregates ["nodimus-primus-copy"] + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-nodeward-nodington-i-copy"] + + Scenario: Properties and references are copied for tethered child nodes + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nodewyn-tetherton" | + | propertyValues | {"title": "Original Text"} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-i" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-i": "sir-nodeward-nodington-ii", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect node aggregate identifier "nodewyn-tetherton-copy" to lead to node cs-identifier;nodewyn-tetherton-copy;{} + And I expect this node to have the following properties: + | Key | Value | + | title | "Original Text" | + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Properties are copied for deeply nested tethered nodes + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "tethered-document-child" | + | propertyValues | {"title": "Original Text"} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-david-nodenborough" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nodimus-primus" | + | nodeAggregateIdMapping | {"sir-david-nodenborough": "sir-david-nodenborough-copy", "node-wan-kenodi": "node-wan-kenodi-copy", "tethered-document": "tethered-document-copy", "tethered-document-child": "tethered-document-child-copy"} | + + And I expect node aggregate identifier "tethered-document-child-copy" to lead to node cs-identifier;tethered-document-child-copy;{} + And I expect this node to have the following properties: + | Key | Value | + | title | "Original Text" |