diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature index ceb7d58509c..7c5c5a7c002 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature @@ -72,7 +72,9 @@ Feature: Workspace discarding - complex chained functionality | workspaceName | "user-ws" | | nodesToDiscard | [{"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}] | | newContentStreamId | "user-cs-id-rebased" | - Then the last command should have thrown an exception of type "NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint" + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 11 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command DiscardWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature index 8c50e8da5f6..6e346c146a4 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature @@ -36,9 +36,11 @@ Feature: Workspace publication - complex chained functionality | nodeTypeName | "Neos.ContentRepository:Root" | And the following CreateNodeAggregateWithNode commands are executed: - | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | tetheredDescendantNodeAggregateIds | - | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | lady-eleonode-rootford | document | {"tethered": "nodewyn-tetherton"} | - | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | nodewyn-tetherton | grandchild | {} | + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | tetheredDescendantNodeAggregateIds | properties | + | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | lady-eleonode-rootford | {"tethered": "nodewyn-tetherton"} | | + | sir-nodebelig | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | | | + | nobody-node | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | | | + | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | nodewyn-tetherton | | | And the command CreateWorkspace is executed with payload: | Key | Value | @@ -46,6 +48,45 @@ Feature: Workspace publication - complex chained functionality | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-id" | + Scenario: Deleted nodes cannot be edited + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodebelig" | + | coveredDimensionSpacePoint | {"language": "de"} | + | nodeVariantSelectionStrategy | "allVariants" | + + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nobody-node" | + | coveredDimensionSpacePoint | {"language": "de"} | + | nodeVariantSelectionStrategy | "allVariants" | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "sir-nodebelig" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"text": "Modified text"} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "nobody-node" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"text": "Modified text"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-ws" | + | nodesToPublish | [{"dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-nodebelig"}] | + | newContentStreamId | "user-cs-id-rebased" | + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | 14 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + Scenario: Vary to generalization, then publish only the child node so that an exception is thrown. Ensure that the workspace recovers from this When the command CreateNodeVariant is executed with payload: | Key | Value | @@ -65,7 +106,9 @@ Feature: Workspace publication - complex chained functionality | workspaceName | "user-ws" | | nodesToPublish | [{"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "nody-mc-nodeface"}] | | newContentStreamId | "user-cs-id-rebased" | - Then the last command should have thrown an exception of type "NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint" + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command PublishWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature index a7ab1930150..27cc51b7e0b 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature @@ -138,7 +138,9 @@ Feature: Workspace discarding - basic functionality | Key | Value | | workspaceName | "user-ws-two" | | rebasedContentStreamId | "user-cs-two-rebased" | - Then the last command should have thrown an exception of type "WorkspaceRebaseFailed" + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | Then workspace user-ws-two has status OUTDATED diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php index 718186ac535..35dc102ecff 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php @@ -9,6 +9,8 @@ use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\RebaseableCommand; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandThatFailedDuringRebase; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\EventStore\Helper\InMemoryEventStore; @@ -41,15 +43,18 @@ * * @internal */ -final readonly class CommandSimulator +final class CommandSimulator { + private CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase; + public function __construct( - private ContentGraphProjectionInterface $contentRepositoryProjection, - private EventNormalizer $eventNormalizer, - private CommandBus $commandBus, - private InMemoryEventStore $inMemoryEventStore, - private WorkspaceName $workspaceNameToSimulateIn, + private readonly ContentGraphProjectionInterface $contentRepositoryProjection, + private readonly EventNormalizer $eventNormalizer, + private readonly CommandBus $commandBus, + private readonly InMemoryEventStore $inMemoryEventStore, + private readonly WorkspaceName $workspaceNameToSimulateIn, ) { + $this->commandsThatFailedDuringRebase = new CommandsThatFailedDuringRebase(); } /** @@ -74,7 +79,20 @@ private function handle(RebaseableCommand $rebaseableCommand): void // when https://github.com/neos/neos-development-collection/pull/5298 is merged $commandInWorkspace = $rebaseableCommand->originalCommand->createCopyForWorkspace($this->workspaceNameToSimulateIn); - $eventsToPublish = $this->commandBus->handle($commandInWorkspace); + try { + $eventsToPublish = $this->commandBus->handle($commandInWorkspace); + } catch (\Exception $exception) { + $this->commandsThatFailedDuringRebase = $this->commandsThatFailedDuringRebase->withAppended( + new CommandThatFailedDuringRebase( + $rebaseableCommand->originalSequenceNumber, + $rebaseableCommand->originalCommand, + $exception + ) + ); + + return; + } + if (!$eventsToPublish instanceof EventsToPublish) { throw new \RuntimeException(sprintf('CommandSimulator expects direct EventsToPublish to be returned when handling %s', $rebaseableCommand->originalCommand::class)); } @@ -109,7 +127,7 @@ private function handle(RebaseableCommand $rebaseableCommand): void // fetch all events that were now committed. Plus one because the first sequence number is one too otherwise we get one event to many. // (all elephants shall be placed shamefully placed on my head) - $eventStream = $this->inMemoryEventStore->load(VirtualStreamName::all())->withMinimumSequenceNumber( + $eventStream = $this->eventStream()->withMinimumSequenceNumber( $sequenceNumberBeforeCommit->next() ); @@ -136,4 +154,14 @@ public function eventStream(): EventStreamInterface { return $this->inMemoryEventStore->load(VirtualStreamName::all()); } + + public function hasCommandsThatFailed(): bool + { + return !$this->commandsThatFailedDuringRebase->isEmpty(); + } + + public function getCommandsThatFailed(): CommandsThatFailedDuringRebase + { + return $this->commandsThatFailedDuringRebase; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php index b61a5a7f37d..2979d3f3e97 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php @@ -11,6 +11,7 @@ use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\EventStore\Model\Event\EventId; use Neos\EventStore\Model\Event\EventMetadata; +use Neos\EventStore\Model\Event\SequenceNumber; /** * @internal @@ -20,11 +21,11 @@ public function __construct( public RebasableToOtherWorkspaceInterface $originalCommand, public EventMetadata $initiatingMetaData, - // todo SequenceNumber $originalSequenceNumber + public SequenceNumber $originalSequenceNumber ) { } - public static function extractFromEventMetaData(EventMetadata $eventMetadata): self + public static function extractFromEventMetaData(EventMetadata $eventMetadata, SequenceNumber $sequenceNumber): self { if (!isset($eventMetadata->value['commandClass'])) { throw new \RuntimeException('Command cannot be extracted from metadata, missing commandClass.', 1729847804); @@ -45,7 +46,8 @@ public static function extractFromEventMetaData(EventMetadata $eventMetadata): s $commandInstance = $commandToRebaseClass::fromArray($commandToRebasePayload); return new self( $commandInstance, - InitiatingEventMetadata::extractInitiatingMetadata($eventMetadata) + InitiatingEventMetadata::extractInitiatingMetadata($eventMetadata), + $sequenceNumber ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php index 1976e6d362b..5f4146321fe 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php @@ -30,7 +30,7 @@ public static function extractFromEventStream(EventStreamInterface $eventStream) $commands = []; foreach ($eventStream as $eventEnvelope) { if ($eventEnvelope->event->metadata && isset($eventEnvelope->event->metadata?->value['commandClass'])) { - $commands[$eventEnvelope->sequenceNumber->value] = RebaseableCommand::extractFromEventMetaData($eventEnvelope->event->metadata); + $commands[] = RebaseableCommand::extractFromEventMetaData($eventEnvelope->event->metadata, $eventEnvelope->sequenceNumber); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index 1fe932fc2be..aa12f889d56 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -253,29 +253,16 @@ private function publishWorkspace( ): \Generator { $commandSimulator = $this->commandSimulatorFactory->createSimulator($baseWorkspace->workspaceName); - $commandsThatFailed = $commandSimulator->run( - static function ($handle) use ($rebaseableCommands): CommandsThatFailedDuringRebase { - $commandsThatFailed = new CommandsThatFailedDuringRebase(); - foreach ($rebaseableCommands as $sequenceNumber => $rebaseableCommand) { - try { - $handle($rebaseableCommand); - } catch (\Exception $e) { - $commandsThatFailed = $commandsThatFailed->add( - new CommandThatFailedDuringRebase( - $sequenceNumber, - $rebaseableCommand->originalCommand, - $e - ) - ); - } + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); } - - return $commandsThatFailed; } ); - if (!$commandsThatFailed->isEmpty()) { - throw new WorkspaceRebaseFailed($commandsThatFailed, 'Publication with rebase failed', 1729845795); + if ($commandSimulator->hasCommandsThatFailed()) { + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); } yield new EventsToPublish( @@ -411,30 +398,17 @@ private function handleRebaseWorkspace( $commandSimulator = $this->commandSimulatorFactory->createSimulator($baseWorkspace->workspaceName); - $commandsThatFailed = $commandSimulator->run( - static function ($handle) use ($rebaseableCommands): CommandsThatFailedDuringRebase { - $commandsThatFailed = new CommandsThatFailedDuringRebase(); - foreach ($rebaseableCommands as $sequenceNumber => $rebaseableCommand) { - try { - $handle($rebaseableCommand); - } catch (\Exception $e) { - $commandsThatFailed = $commandsThatFailed->add( - new CommandThatFailedDuringRebase( - $sequenceNumber, - $rebaseableCommand->originalCommand, - $e - ) - ); - } + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); } - - return $commandsThatFailed; } ); if ( $command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FAIL - && !$commandsThatFailed->isEmpty() + && $commandSimulator->hasCommandsThatFailed() ) { yield $this->reopenContentStream( $workspace->currentContentStreamId, @@ -443,7 +417,7 @@ static function ($handle) use ($rebaseableCommands): CommandsThatFailedDuringReb ); // throw an exception that contains all the information about what exactly failed - throw new WorkspaceRebaseFailed($commandsThatFailed, 'Rebase failed', 1711713880); + throw WorkspaceRebaseFailed::duringRebase($commandSimulator->getCommandsThatFailed()); } // if we got so far without an exception (or if we don't care), we can switch the workspace's active content stream. @@ -556,27 +530,27 @@ private function handlePublishIndividualNodesFromWorkspace( $commandSimulator = $this->commandSimulatorFactory->createSimulator($baseWorkspace->workspaceName); - try { - $highestSequenceNumberForMatching = $commandSimulator->run( - static function ($handle) use ($commandSimulator, $matchingCommands, $remainingCommands): SequenceNumber { - foreach ($matchingCommands as $matchingCommand) { - $handle($matchingCommand); - } - $highestSequenceNumberForMatching = $commandSimulator->currentSequenceNumber(); - foreach ($remainingCommands as $remainingCommand) { - $handle($remainingCommand); - } - return $highestSequenceNumberForMatching; + $highestSequenceNumberForMatching = $commandSimulator->run( + static function ($handle) use ($commandSimulator, $matchingCommands, $remainingCommands): SequenceNumber { + foreach ($matchingCommands as $matchingCommand) { + $handle($matchingCommand); } - ); - } catch (\Exception $exception) { + $highestSequenceNumberForMatching = $commandSimulator->currentSequenceNumber(); + foreach ($remainingCommands as $remainingCommand) { + $handle($remainingCommand); + } + return $highestSequenceNumberForMatching; + } + ); + + if ($commandSimulator->hasCommandsThatFailed()) { yield $this->reopenContentStream( $workspace->currentContentStreamId, $currentWorkspaceContentStreamState, $commandHandlingDependencies ); - throw $exception; + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); } if ($highestSequenceNumberForMatching->equals(SequenceNumber::none())) { @@ -691,21 +665,21 @@ private function handleDiscardIndividualNodesFromWorkspace( $commandSimulator = $this->commandSimulatorFactory->createSimulator($baseWorkspace->workspaceName); - try { - $commandSimulator->run( - static function ($handle) use ($commandsToKeep): void { - foreach ($commandsToKeep as $matchingCommand) { - $handle($matchingCommand); - } + $commandSimulator->run( + static function ($handle) use ($commandsToKeep): void { + foreach ($commandsToKeep as $matchingCommand) { + $handle($matchingCommand); } - ); - } catch (\Exception $exception) { + } + ); + + if ($commandSimulator->hasCommandsThatFailed()) { yield $this->reopenContentStream( $workspace->currentContentStreamId, $currentWorkspaceContentStreamState, $commandHandlingDependencies ); - throw $exception; + throw WorkspaceRebaseFailed::duringDiscard($commandSimulator->getCommandsThatFailed()); } yield from $this->forkNewContentStreamAndApplyEvents( diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php index 42e499af079..c5c39df6428 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php @@ -15,19 +15,20 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\EventStore\Model\Event\SequenceNumber; /** - * @internal implementation detail of WorkspaceCommandHandler + * @api part of the exception exposed when rebasing failed */ final readonly class CommandThatFailedDuringRebase { /** - * @param int $sequenceNumber the event store sequence number of the event containing the command to be rebased + * @param SequenceNumber $sequenceNumber the event store sequence number of the event containing the command to be rebased * @param CommandInterface $command the command that failed * @param \Throwable $exception how the command failed */ public function __construct( - public int $sequenceNumber, + public SequenceNumber $sequenceNumber, public CommandInterface $command, public \Throwable $exception ) { diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php index b9f6e792b2d..24f1087ee81 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php @@ -19,7 +19,7 @@ * * @api part of the exception exposed when rebasing failed */ -final readonly class CommandsThatFailedDuringRebase implements \IteratorAggregate +final readonly class CommandsThatFailedDuringRebase implements \IteratorAggregate, \Countable { /** * @var array @@ -31,12 +31,14 @@ public function __construct(CommandThatFailedDuringRebase ...$items) $this->items = array_values($items); } - public function add(CommandThatFailedDuringRebase $item): self + public function withAppended(CommandThatFailedDuringRebase $item): self { - $items = $this->items; - $items[] = $item; + return new self(...[...$this->items, $item]); + } - return new self(...$items); + public function first(): ?CommandThatFailedDuringRebase + { + return $this->items[0] ?? null; } public function isEmpty(): bool @@ -48,4 +50,9 @@ public function getIterator(): \Traversable { yield from $this->items; } + + public function count(): int + { + return count($this->items); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php index 52b1bdd8d5d..b1bae671156 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php @@ -21,12 +21,48 @@ */ final class WorkspaceRebaseFailed extends \Exception { - public function __construct( + private function __construct( public readonly CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase, - string $message = "", - int $code = 0, - ?\Throwable $previous = null + string $message, + int $code, + ?\Throwable $previous, ) { parent::__construct($message, $code, $previous); } + + public static function duringRebase(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Rebase failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974936, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + public static function duringPublish(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Publication failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974980, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + public static function duringDiscard(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Discard failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974982, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + private static function renderMessage(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): string + { + $firstFailure = $commandsThatFailedDuringRebase->first(); + return sprintf('"%s" and %d further failures', $firstFailure?->exception->getMessage(), count($commandsThatFailedDuringRebase) - 1); + } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php index d20ebdab535..e7176a25d70 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php @@ -202,10 +202,10 @@ public function theFollowingCreateNodeAggregateWithNodeCommandsAreExecuted(Table ? $this->parsePropertyValuesJsonString($row['initialPropertyValues']) : null, ); - if (isset($row['tetheredDescendantNodeAggregateIds'])) { + if (!empty($row['tetheredDescendantNodeAggregateIds'])) { $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromJsonString($row['tetheredDescendantNodeAggregateIds'])); } - if (isset($row['nodeName'])) { + if (!empty($row['nodeName'])) { $command = $command->withNodeName(NodeName::fromString($row['nodeName'])); } $this->currentContentRepository->handle($command); diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index fb1762a0412..afe7487782d 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -34,6 +34,7 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Model\Event; use Neos\EventStore\Model\Event\StreamName; @@ -147,7 +148,6 @@ protected function publishEvent(string $eventType, StreamName $streamName, array /** * @Then /^the last command should have thrown an exception of type "([^"]*)"(?: with code (\d*))?$/ - * @throws \ReflectionException */ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null): void { @@ -164,6 +164,28 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int } } + /** + * @Then the last command should have thrown the WorkspaceRebaseFailed exception with: + */ + public function theLastCommandShouldHaveThrownTheWorkspaceRebaseFailedWith(TableNode $payloadTable) + { + /** @var WorkspaceRebaseFailed $exception */ + $exception = $this->lastCommandException; + Assert::assertNotNull($exception, 'Command did not throw exception'); + Assert::assertInstanceOf(WorkspaceRebaseFailed::class, $exception, sprintf('Actual exception: %s (%s): %s', get_class($exception), $exception->getCode(), $exception->getMessage())); + + $actualComparableHash = []; + foreach ($exception->commandsThatFailedDuringRebase as $commandsThatFailed) { + $actualComparableHash[] = [ + 'SequenceNumber' => (string)$commandsThatFailed->sequenceNumber->value, + 'Command' => (new \ReflectionClass($commandsThatFailed->command))->getShortName(), + 'Exception' => (new \ReflectionClass($commandsThatFailed->exception))->getShortName(), + ]; + } + + Assert::assertSame($payloadTable->getHash(), $actualComparableHash); + } + /** * @Then /^I expect exactly (\d+) events? to be published on stream "([^"]*)"$/ * @param int $numberOfEvents diff --git a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php index cb27d2b92db..dea005af9e1 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\WorkspaceIsNotEmptyException; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; @@ -78,7 +79,8 @@ public function countPendingWorkspaceChanges(ContentRepositoryId $contentReposit } /** - * @throws WorkspaceDoesNotExist | WorkspaceRebaseFailed + * @throws WorkspaceRebaseFailed is thrown if there are conflicts and the rebase strategy was {@see RebaseErrorHandlingStrategy::STRATEGY_FAIL} + * The workspace will be unchanged for this case. */ public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void { @@ -86,6 +88,10 @@ public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, Worksp $this->contentRepositoryRegistry->get($contentRepositoryId)->handle($rebaseCommand); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -98,6 +104,10 @@ public function publishWorkspace(ContentRepositoryId $contentRepositoryId, Works return new PublishingResult($numberOfPendingChanges, $crWorkspace->baseWorkspaceName); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -128,6 +138,10 @@ public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, W ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -170,6 +184,10 @@ public function discardAllWorkspaceChanges(ContentRepositoryId $contentRepositor return new DiscardingResult($numberOfChangesToBeDiscarded); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): DiscardingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -196,6 +214,10 @@ public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, W ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ public function discardChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): DiscardingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -222,6 +244,9 @@ public function discardChangesInDocument(ContentRepositoryId $contentRepositoryI ); } + /** + * @throws WorkspaceIsNotEmptyException in case a switch is attempted while the workspace still has pending changes + */ public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $newBaseWorkspaceName): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -234,19 +259,15 @@ public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, Wo ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ private function discardNodes( ContentRepository $contentRepository, WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIdsToDiscard ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? - */ - $contentRepository->handle( - RebaseWorkspace::create($workspaceName) - ); - $contentRepository->handle( DiscardIndividualNodesFromWorkspace::create( $workspaceName, @@ -255,19 +276,15 @@ private function discardNodes( ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ private function publishNodes( ContentRepository $contentRepository, WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIdsToPublish ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handlePublishIndividualNodesFromWorkspace ? - */ - $contentRepository->handle( - RebaseWorkspace::create($workspaceName) - ); - $contentRepository->handle( PublishIndividualNodesFromWorkspace::create( $workspaceName,