diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php index f1614583730..bbff12e3e8d 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php @@ -72,11 +72,6 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraph return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $currentContentStreamId); } - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraph - { - return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $contentStreamId); - } - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace { $workspaceQuery = $this->getBasicWorkspaceQuery() diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index 3cbd95c3799..27be78643a2 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -17,6 +17,7 @@ use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\NodeRelationAnchorPoint; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\DimensionSpacePointsRepository; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\ProjectionContentGraph; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; @@ -241,6 +242,21 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void } } + public function inSimulation(\Closure $fn): mixed + { + if ($this->dbal->isTransactionActive()) { + throw new \RuntimeException(sprintf('Invoking %s is not allowed to be invoked recursively. Current transaction nesting %d.', __FUNCTION__, $this->dbal->getTransactionNestingLevel())); + } + $this->dbal->beginTransaction(); + $this->dbal->setRollbackOnly(); + try { + return $fn(); + } finally { + // unsets rollback only flag and allows the connection to work regular again + $this->dbal->rollBack(); + } + } + private function whenContentStreamWasClosed(ContentStreamWasClosed $event): void { $this->updateContentStreamStatus($event->contentStreamId, ContentStreamStatus::CLOSED); @@ -748,7 +764,6 @@ private function whenWorkspaceWasPartiallyDiscarded(WorkspaceWasPartiallyDiscard private function whenWorkspaceWasPartiallyPublished(WorkspaceWasPartiallyPublished $event): void { - // TODO: How do we test this method? – It's hard to design a BDD testcase that fails if this method is commented out... $this->updateWorkspaceContentStreamId($event->sourceWorkspaceName, $event->newSourceContentStreamId); // the new content stream is in use now @@ -760,7 +775,6 @@ private function whenWorkspaceWasPartiallyPublished(WorkspaceWasPartiallyPublish private function whenWorkspaceWasPublished(WorkspaceWasPublished $event): void { - // TODO: How do we test this method? – It's hard to design a BDD testcase that fails if this method is commented out... $this->updateWorkspaceContentStreamId($event->sourceWorkspaceName, $event->newSourceContentStreamId); // the new content stream is in use now @@ -909,12 +923,14 @@ private function copyReferenceRelations( private static function initiatingDateTime(EventEnvelope $eventEnvelope): \DateTimeImmutable { - $initiatingTimestamp = $eventEnvelope->event->metadata?->get('initiatingTimestamp'); - $result = $initiatingTimestamp !== null ? \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $initiatingTimestamp) : $eventEnvelope->recordedAt; - if (!$result instanceof \DateTimeImmutable) { + if ($eventEnvelope->event->metadata?->has(InitiatingEventMetadata::INITIATING_TIMESTAMP) !== true) { + return $eventEnvelope->recordedAt; + } + $initiatingTimestamp = InitiatingEventMetadata::getInitiatingTimestamp($eventEnvelope->event->metadata); + if ($initiatingTimestamp === null) { throw new \RuntimeException(sprintf('Failed to extract initiating timestamp from event "%s"', $eventEnvelope->event->id->value), 1678902291); } - return $result; + return $initiatingTimestamp; } private function createNodeWithHierarchy( diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php b/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php index 9086e9dc5af..b6f1015661a 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php @@ -42,11 +42,6 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter return new ContentHyperGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNamePrefix, $workspaceName, $contentStreamId); } - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraphInterface - { - return new ContentHyperGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNamePrefix, $workspaceName, $contentStreamId); - } - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace { // TODO: Implement findWorkspaceByName() method. diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php index 75b373c9c22..8c6ff9118b8 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php @@ -213,6 +213,21 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void }; } + public function inSimulation(\Closure $fn): mixed + { + if ($this->dbal->isTransactionActive()) { + throw new \RuntimeException(sprintf('Invoking %s is not allowed to be invoked recursively. Current transaction nesting %d.', __FUNCTION__, $this->dbal->getTransactionNestingLevel())); + } + $this->dbal->beginTransaction(); + $this->dbal->setRollbackOnly(); + try { + return $fn(); + } finally { + // unsets rollback only flag and allows the connection to work regular again + $this->dbal->rollBack(); + } + } + public function getCheckpointStorage(): DbalCheckpointStorage { return $this->checkpointStorage; diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature index 02601b06f70..4b44b32d2e7 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature @@ -359,3 +359,19 @@ Feature: Behavior of Node timestamp properties "created", "originalCreated", "la And I expect the node "b" to have the following timestamps: | created | originalCreated | lastModified | originalLastModified | | 2023-03-16 15:00:00 | 2023-03-16 12:00:00 | | | + + Scenario: Original created when rebasing and partially publishing nodes + And I am in workspace "user-test" and dimension space point {"language":"de"} + Then I expect the node "a" to have the following timestamps: + | created | originalCreated | lastModified | originalLastModified | + | 2023-03-16 12:00:00 | 2023-03-16 12:00:00 | null | null | + + Given the current date and time is "2023-03-16T14:00:00+01:00" + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebaseErrorHandlingStrategy | "force" | + + Then I expect the node "a" to have the following timestamps: + | created | originalCreated | lastModified | originalLastModified | + | 2023-03-16 14:00:00 | 2023-03-16 12:00:00 | null | null | 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/W10-IndividualNodeDiscarding/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature index 24a0de90eb7..94c2077c647 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature @@ -29,44 +29,38 @@ Feature: Discard individual nodes (basics) | Key | Value | | workspaceName | "live" | | newContentStreamId | "cs-identifier" | - And I am in workspace "live" + 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 event NodeAggregateWithNodeWasCreated was published with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t1"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t1"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | - | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "sir-david-nodenborough" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t2"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t2"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-nodeward-nodington-iii" | | nodeTypeName | "Neos.ContentRepository.Testing:Image" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"image": {"type": "string", "value": "Initial image"}} | - | nodeAggregateClassification | "regular" | + | initialPropertyValues | {"image": "Initial image"} | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeTypeName | "Neos.ContentRepository.Testing:Image" | + | parentNodeAggregateId | "lady-eleonode-rootford" | # Create user workspace And the command CreateWorkspace is executed with payload: @@ -104,7 +98,7 @@ Feature: Discard individual nodes (basics) | workspaceName | "user-test" | | nodesToDiscard | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | newContentStreamId | "user-cs-identifier-new" | - + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} @@ -119,28 +113,45 @@ Feature: Discard individual nodes (basics) And I expect this node to have the following properties: | Key | Value | | image | "Initial image" | - - Scenario: It is possible to discard no node + Scenario: Discard no node, non existing ones or unchanged nodes is a no-op + # no node When the command DiscardIndividualNodesFromWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodesToDiscard | [] | | newContentStreamId | "user-cs-identifier-new" | + Then I expect the content stream "user-cs-identifier-new" to not exist + + # unchanged or non existing nodes + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToDiscard | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing-node"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | newContentStreamId | "user-cs-identifier-new-two" | + # all nodes are still on the original user cs When I am in workspace "user-test" and dimension space point {} - Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t1" | - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-new;nody-mc-nodeface;{} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t2" | - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier-new;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier;sir-nodeward-nodington-iii;{} And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + # assert that content stream is still open by writing to it: + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {} | + | propertyValues | {"image": "Bla bli blub"} | + Scenario: It is possible to discard all nodes When the command DiscardIndividualNodesFromWorkspace is executed with payload: | Key | Value | @@ -148,6 +159,14 @@ Feature: Discard individual nodes (basics) | nodesToDiscard | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "nody-mc-nodeface"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | newContentStreamId | "user-cs-identifier-new" | + # when discarding all nodes we expect a full discard via WorkspaceWasDiscarded + Then I expect exactly 2 events to be published on stream with prefix "Workspace:user-test" + And event at index 1 is of type "WorkspaceWasDiscarded" with payload: + | Key | Expected | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier-new" | + | previousContentStreamId | "user-cs-identifier" | + When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} And I expect this node to have the following properties: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature new file mode 100644 index 00000000000..e080cf4d6bb --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature @@ -0,0 +1,120 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Rebasing with no conflict + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': {} + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + 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 the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + + # Create user workspace + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Then workspaces live,user-test have status UP_TO_DATE + + Scenario: Rebase is a no-op if there are no changes + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-rebased" to not exist + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} + + # only if the force flag is used we enforce a fork: + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + | rebaseErrorHandlingStrategy | "force" | + Then I expect the content stream "user-cs-identifier" to not exist + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-rebased;sir-david-nodenborough;{} + + Scenario: Rebase only the base contains changes + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + Then workspaces user-test has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-identifier" to not exist + Then workspaces live,user-test have status UP_TO_DATE + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-rebased;sir-nodeward-nodington-iii;{} + + + Scenario: Rebase workspace and base contains changes + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "nordisch-nodel" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + Then workspaces user-test has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-identifier" to not exist + + Then workspaces live,user-test have status UP_TO_DATE + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "nordisch-nodel" to lead to no node + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-rebased;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "nordisch-nodel" to lead to node user-cs-rebased;nordisch-nodel;{} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature index 3272dcc1af5..39eca525c82 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature @@ -47,7 +47,7 @@ Feature: Rebasing auto-created nodes works Scenario: complex scenario (to reproduce the bug) -- see the feature description # USER workspace: create a new node with auto-created child nodes - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodeAggregateId | "nody-mc-nodeface" | @@ -69,8 +69,19 @@ Feature: Rebasing auto-created nodes works | propertyValues | {"text": {"value":"Modified","type":"string"}} | | propertiesToUnset | {} | + # ensure that live is outdated so the rebase is required: + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "changington-van-live" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + + # rebase of SetSerializedNodeProperties When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | # This should properly work; no error. diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature index ad3ee6db36c..d7d79c62e87 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature @@ -95,6 +95,7 @@ Feature: Workspace based content publishing When the command PublishWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{} @@ -102,7 +103,7 @@ Feature: Workspace based content publishing | Key | Value | | text | "Modified" | - Scenario: modify the property in the nested workspace, do modification in live workspace; publish afterwards will not work because rebase is missing; then rebase and publish + Scenario: modify the property in the nested workspace, do modification in live workspace; publish afterwards will rebase the changes When the command SetNodeProperties is executed with payload: | Key | Value | @@ -117,22 +118,11 @@ Feature: Workspace based content publishing | originDimensionSpacePoint | {} | | propertyValues | {"text": "Modified in live workspace"} | - # PUBLISHING without rebase: error - When the command PublishWorkspace is executed with payload and exceptions are caught: - | Key | Value | - | workspaceName | "user-test" | - - Then the last command should have thrown an exception of type "BaseWorkspaceHasBeenModifiedInTheMeantime" - - # REBASING + Publishing: works now (TODO soft constraint check for old value) - When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | - | rebasedContentStreamId | "rebased-cs-id" | - - And the command PublishWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + # PUBLISHING (with rebase internally) + When the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | When I am in workspace "live" and dimension space point {} @@ -175,3 +165,48 @@ Feature: Workspace based content publishing And I expect this node to have the following properties: | Key | Value | | text | "Modified anew" | + + Scenario: Publish is a no-op if there are no changes + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | + + # the user and live workspace are unchanged + Then I expect exactly 1 event to be published on stream "Workspace:user-test" + Then I expect exactly 3 event to be published on stream "ContentStream:user-cs-identifier" + And event at index 2 is of type "ContentStreamWasReopened" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier" | + + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + # checks for the live workspace (same as above) + Then I expect exactly 4 events to be published on stream "ContentStream:cs-identifier" + Then I expect exactly 1 event to be published on stream "Workspace:live" + + Scenario: Publish is a no-op if there are no changes (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Original" | + Then I expect the content stream "user-cs-new" to not exist 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/W8-IndividualNodePublication/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature index f5e31578cf4..dfa2d30a523 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature @@ -7,7 +7,10 @@ Feature: Individual node publication Given using no content dimensions And using the following node types: """yaml - 'Neos.ContentRepository.Testing:Content': {} + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string 'Neos.ContentRepository.Testing:Document': childNodes: child1: @@ -53,9 +56,34 @@ Feature: Individual node publication | Key | Value | | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I am in workspace "live" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{} to exist in the content graph + + Scenario: Partial publish is a no-op if the workspace doesnt contain any changes (and the workspace is outdated) + + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Original text"} | + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing"}] | + | contentStreamIdForRemainingPart | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + Then I expect the content stream "user-cs-new" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature index 85adbd6c3ab..398d368f0a5 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature @@ -29,44 +29,39 @@ Feature: Publishing individual nodes (basics) | Key | Value | | workspaceName | "live" | | newContentStreamId | "cs-identifier" | - And I am in workspace "live" + 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 event NodeAggregateWithNodeWasCreated was published with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t1"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t1"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | - | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "sir-david-nodenborough" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t2"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t2"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-nodeward-nodington-iii" | | nodeTypeName | "Neos.ContentRepository.Testing:Image" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"image": {"type": "string", "value": "Initial image"}} | - | nodeAggregateClassification | "regular" | + | initialPropertyValues | {"image": "Initial image"} | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Initial text"} | # Create user workspace And the command CreateWorkspace is executed with payload: @@ -104,7 +99,7 @@ Feature: Publishing individual nodes (basics) | workspaceName | "user-test" | | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} @@ -134,12 +129,21 @@ Feature: Publishing individual nodes (basics) | Key | Value | | image | "Modified image" | - Scenario: It is possible to publish no node + Scenario: Publish no node, non existing ones or unchanged nodes is a no-op + # no node When the command PublishIndividualNodesFromWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodesToPublish | [] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + Then I expect the content stream "user-cs-identifier-remaining" to not exist + + # unchanged or non existing nodes + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing-node"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining-two" | When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} @@ -155,20 +159,57 @@ Feature: Publishing individual nodes (basics) | Key | Value | | image | "Initial image" | + # all nodes are still on the original user cs When I am in workspace "user-test" and dimension space point {} - Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-remaining;sir-david-nodenborough;{} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t1" | - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-remaining;nody-mc-nodeface;{} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t2" | - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier-remaining;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier;sir-nodeward-nodington-iii;{} And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + # assert that content stream is still open by writing to it: + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {} | + | propertyValues | {"image": "Bla bli blub"} | + + Scenario: Tag the same node in live and in the user workspace so that a rebase will omit the user change + When the command TagSubtree is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + When the command TagSubtree is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-unchanged" | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node cs-identifier;sir-unchanged;{} + And I expect this node to be exactly explicitly tagged "tag1" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node user-cs-identifier-remaining;sir-unchanged;{} + And I expect this node to be exactly explicitly tagged "tag1" + Then workspace user-test has status UP_TO_DATE + Scenario: It is possible to publish all nodes When the command PublishIndividualNodesFromWorkspace is executed with payload: | Key | Value | @@ -176,6 +217,15 @@ Feature: Publishing individual nodes (basics) | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "nody-mc-nodeface"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + # when publishing all nodes we expect a full discard via WorkspaceWasPublished + Then I expect exactly 2 events to be published on stream with prefix "Workspace:user-test" + And event at index 1 is of type "WorkspaceWasPublished" with payload: + | Key | Expected | + | sourceWorkspaceName | "user-test" | + | targetWorkspaceName | "live" | + | newSourceContentStreamId | "user-cs-identifier-remaining" | + | previousSourceContentStreamId | "user-cs-identifier" | + When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: @@ -203,3 +253,65 @@ Feature: Publishing individual nodes (basics) And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + + Scenario: Publish individual nodes commits exactly the expected events on each stream + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + + Then I expect exactly 8 events to be published on stream "ContentStream:cs-identifier" + And event at index 6 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-david-nodenborough" | + And event at index 7 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + + Then I expect exactly 4 events to be published on stream "ContentStream:user-cs-identifier-remaining" + And event at index 0 is of type "ContentStreamWasForked" with payload: + | Key | Expected | + | newContentStreamId | "user-cs-identifier-remaining" | + And event at index 1 is of type "ContentStreamWasClosed" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + And event at index 2 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + | nodeAggregateId | "nody-mc-nodeface" | + And event at index 3 is of type "ContentStreamWasReopened" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + + Scenario: Partial publish keeps remaining changes if nothing matches (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing"}] | + | contentStreamIdForRemainingPart | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node user-cs-identifier;sir-unchanged;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Initial text" | + + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Modified t1" | + + Then I expect the content stream "user-cs-new" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature index f08d5bf47d4..30c5898bb7d 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature @@ -102,7 +102,6 @@ Feature: Publishing hide/show scenario of nodes | workspaceName | "user-test" | | nodesToPublish | [{"nodeAggregateId": "sir-david-nodenborough", "workspaceName": "user-test", "dimensionSpacePoint": {}}] | | contentStreamIdForRemainingPart | "remaining-cs-id" | - | contentStreamIdForMatchingPart | "matching-cs-id" | When I am in workspace "live" Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node 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 b6846735757..0e180f7d0dd 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 @@ -63,6 +63,7 @@ Feature: Workspace discarding - basic functionality | Key | Value | | workspaceName | "user-test" | | newContentStreamId | "user-cs-identifier-modified" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-modified;nody-mc-nodeface;{} @@ -137,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 @@ -147,3 +150,26 @@ Feature: Workspace discarding - basic functionality | newContentStreamId | "user-cs-two-discarded" | Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + + Scenario: Discard is a no-op if there are no changes (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + + And the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-two-discarded" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Original" | + Then I expect the content stream "user-cs-two-discarded" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature index 67fa06bbd93..dadd9e7e112 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature @@ -41,6 +41,7 @@ Feature: If content streams are not in use anymore by the workspace, they can be When the command RebaseWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | + | rebaseErrorHandlingStrategy | "force" | When I am in workspace "user-test" and dimension space point {} Then the current content stream has status "IN_USE_BY_WORKSPACE" @@ -61,6 +62,7 @@ Feature: If content streams are not in use anymore by the workspace, they can be | Key | Value | | workspaceName | "user-test" | | rebasedContentStreamId | "user-cs-identifier-rebased" | + | rebaseErrorHandlingStrategy | "force" | # now, we have one unused content stream (the old content stream of the user-test workspace) When I prune unused content streams @@ -79,6 +81,8 @@ Feature: If content streams are not in use anymore by the workspace, they can be When the command RebaseWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | + | rebaseErrorHandlingStrategy | "force" | + # now, we have one unused content stream (the old content stream of the user-test workspace) When I prune unused content streams @@ -106,6 +110,7 @@ Feature: If content streams are not in use anymore by the workspace, they can be When the command RebaseWorkspace is executed with payload: | Key | Value | | workspaceName | "review" | + | rebaseErrorHandlingStrategy | "force" | When I prune unused content streams And I prune removed content streams from the event stream diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php index 3d959ebcd51..92673a00c82 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php @@ -4,10 +4,8 @@ namespace Neos\ContentRepository\Core\CommandHandler; -use Neos\ContentRepository\Core\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\EventStore\EventsToPublishFailed; /** * Implementation Detail of {@see ContentRepository::handle}, which does the command dispatching to the different @@ -15,29 +13,41 @@ * * @internal */ -final class CommandBus +final readonly class CommandBus { /** * @var CommandHandlerInterface[] */ private array $handlers; - public function __construct(CommandHandlerInterface ...$handlers) - { + public function __construct( + // todo pass $commandHandlingDependencies in each command handler instead of into the commandBus + private CommandHandlingDependencies $commandHandlingDependencies, + CommandHandlerInterface ...$handlers + ) { $this->handlers = $handlers; } /** * @return EventsToPublish|\Generator */ - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator + public function handle(CommandInterface $command): EventsToPublish|\Generator { - // TODO fail if multiple handlers can handle the same command + // multiple handlers must not handle the same command foreach ($this->handlers as $handler) { if ($handler->canHandle($command)) { - return $handler->handle($command, $commandHandlingDependencies); + return $handler->handle($command, $this->commandHandlingDependencies); } } throw new \RuntimeException(sprintf('No handler found for Command "%s"', get_debug_type($command)), 1649582778); } + + public function withAdditionalHandlers(CommandHandlerInterface ...$handlers): self + { + return new self( + $this->commandHandlingDependencies, + ...$this->handlers, + ...$handlers, + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php index 9a82c678b15..b36d5d3ab75 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php @@ -4,14 +4,12 @@ namespace Neos\ContentRepository\Core\CommandHandler; -use Neos\ContentRepository\Core\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\EventsToPublish; /** * Common interface for all Content Repository command handlers * - * Note: The Content Repository instance is passed to the handle() method for it to do soft-constraint checks or - * trigger "sub commands" + * The {@see CommandHandlingDependencies} are available during handling to do soft-constraint checks * * @internal no public API, because commands are no extension points of the CR */ @@ -20,6 +18,11 @@ interface CommandHandlerInterface public function canHandle(CommandInterface $command): bool; /** + * "simple" command handlers return EventsToPublish directly + * + * For the case of the workspace command handler that need to publish to many streams and "close" the content-stream directly, + * it's allowed to yield the events to interact with the control flow of event publishing. + * * @return EventsToPublish|\Generator */ public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator; diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php new file mode 100644 index 00000000000..09b97dc9293 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php @@ -0,0 +1,74 @@ +contentGraphReadModel->findContentStreamById($contentStreamId); + if ($contentStream === null) { + throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902051); + } + return $contentStream->version; + } + + public function contentStreamExists(ContentStreamId $contentStreamId): bool + { + $cs = $this->contentGraphReadModel->findContentStreamById($contentStreamId); + return $cs !== null && !$cs->removed; + } + + public function getContentStreamStatus(ContentStreamId $contentStreamId): ContentStreamStatus + { + $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); + if ($contentStream === null) { + throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902219); + } + return $contentStream->status; + } + + public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace + { + return $this->contentGraphReadModel->findWorkspaceByName($workspaceName); + } + + /** + * @throws WorkspaceDoesNotExist if the workspace does not exist + */ + public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface + { + return $this->contentGraphReadModel->getContentGraph($workspaceName); + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php new file mode 100644 index 00000000000..ee3ee2de52c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php @@ -0,0 +1,171 @@ + this will do all constraint checks based on the projection in the open transaction (so it sees + * previously modified projection state which is not committed) + * - -> it will run the command handlers, buffer all emitted events in the InMemoryEventStore + * -> note to avoid full recursion the workspace command handler is not included in the bus + * - -> update the GraphProjection, but WITHOUT committing the transaction {@see ContentGraphProjectionInterface::inSimulation()} + * + * This is quite performant because we do not need to fork a new content stream. + * + * @internal + */ +final class CommandSimulator +{ + private CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase; + + private readonly InMemoryEventStore $inMemoryEventStore; + + public function __construct( + private readonly ContentGraphProjectionInterface $contentRepositoryProjection, + private readonly EventNormalizer $eventNormalizer, + private readonly CommandBus $commandBus, + private readonly WorkspaceName $workspaceNameToSimulateIn, + ) { + $this->inMemoryEventStore = new InMemoryEventStore(); + $this->commandsThatFailedDuringRebase = new CommandsThatFailedDuringRebase(); + } + + /** + * Start the simulation for the passed function which receives as argument the {@see handle} function. + * + * @template T + * @param callable(callable(RebaseableCommand): void): T $fn + * @return T the return value of $fn + */ + public function run(callable $fn): mixed + { + return $this->contentRepositoryProjection->inSimulation(fn () => $fn($this->handle(...))); + } + + /** + * Handle a command within a running simulation, otherwise throw. + * + * We will automatically copy given commands to the workspace this simulation + * is running in to ensure consistency in the simulations constraint checks. + */ + private function handle(RebaseableCommand $rebaseableCommand): void + { + // FIXME: Check if workspace already matches and skip this, e.g. $commandInWorkspace = $command->getWorkspaceName()->equals($this->workspaceNameToSimulateIn) ? $command : $command->createCopyForWorkspace($this->workspaceNameToSimulateIn); + // when https://github.com/neos/neos-development-collection/pull/5298 is merged + $commandInWorkspace = $rebaseableCommand->originalCommand->createCopyForWorkspace($this->workspaceNameToSimulateIn); + + try { + $eventsToPublish = $this->commandBus->handle($commandInWorkspace); + } catch (\Exception $exception) { + $this->commandsThatFailedDuringRebase = $this->commandsThatFailedDuringRebase->withAppended( + new CommandThatFailedDuringRebase( + $rebaseableCommand->originalCommand, + $exception, + $rebaseableCommand->originalSequenceNumber + ) + ); + + return; + } + + if (!$eventsToPublish instanceof EventsToPublish) { + throw new \RuntimeException(sprintf('%s expects an instance of %s to be returned. Got %s when handling %s', self::class, EventsToPublish::class, get_debug_type($eventsToPublish), $rebaseableCommand->originalCommand::class)); + } + + if ($eventsToPublish->events->isEmpty()) { + return; + } + + $normalizedEvents = Events::fromArray( + $eventsToPublish->events->map(function (EventInterface|DecoratedEvent $event) use ( + $rebaseableCommand + ) { + $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; + $decoratedEvent = DecoratedEvent::create($event, metadata: EventMetadata::fromArray( + array_merge($metadata, $rebaseableCommand->initiatingMetaData->value ?? []) + )); + return $this->eventNormalizer->normalize($decoratedEvent); + }) + ); + + $sequenceNumberBeforeCommit = $this->currentSequenceNumber(); + + // The version of the stream in the IN MEMORY event store does not matter to us, + // because this is only used in memory during the partial publish or rebase operation; so it cannot be written to + // concurrently. + // HINT: We cannot use $eventsToPublish->expectedVersion, because this is based on the PERSISTENT event stream (having different numbers) + $this->inMemoryEventStore->commit( + $eventsToPublish->streamName, + $normalizedEvents, + ExpectedVersion::ANY() + ); + + // 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->eventStream()->withMinimumSequenceNumber( + $sequenceNumberBeforeCommit->next() + ); + + foreach ($eventStream as $eventEnvelope) { + $event = $this->eventNormalizer->denormalize($eventEnvelope->event); + + if (!$this->contentRepositoryProjection->canHandle($event)) { + continue; + } + + $this->contentRepositoryProjection->apply($event, $eventEnvelope); + } + } + + public function currentSequenceNumber(): SequenceNumber + { + foreach ($this->eventStream()->backwards()->limit(1) as $eventEnvelope) { + return $eventEnvelope->sequenceNumber; + } + return SequenceNumber::none(); + } + + 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/CommandHandler/CommandSimulatorFactory.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulatorFactory.php new file mode 100644 index 00000000000..602fd28b25f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulatorFactory.php @@ -0,0 +1,32 @@ +contentRepositoryProjection, + $this->eventNormalizer, + $this->commandBus, + $workspaceNameToSimulateIn, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php deleted file mode 100644 index 622af9c9a15..00000000000 --- a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php +++ /dev/null @@ -1,127 +0,0 @@ -value to ContentGraphInterface - * @var array - */ - private array $overriddenContentGraphInstances = []; - - public function __construct( - private readonly ContentRepository $contentRepository, - private readonly ContentGraphReadModelInterface $contentGraphReadModel - ) { - } - - public function handle(CommandInterface $command): void - { - $this->contentRepository->handle($command); - } - - public function getContentStreamVersion(ContentStreamId $contentStreamId): Version - { - $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); - if ($contentStream === null) { - throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902051); - } - return $contentStream->version; - } - - public function contentStreamExists(ContentStreamId $contentStreamId): bool - { - return $this->contentGraphReadModel->findContentStreamById($contentStreamId) !== null; - } - - public function getContentStreamStatus(ContentStreamId $contentStreamId): ContentStreamStatus - { - $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); - if ($contentStream === null) { - throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902219); - } - return $contentStream->status; - } - - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace - { - return $this->contentGraphReadModel->findWorkspaceByName($workspaceName); - } - - /** - * @throws WorkspaceDoesNotExist if the workspace does not exist - */ - public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface - { - if (isset($this->overriddenContentGraphInstances[$workspaceName->value])) { - return $this->overriddenContentGraphInstances[$workspaceName->value]; - } - $workspace = $this->contentGraphReadModel->findWorkspaceByName($workspaceName); - if ($workspace === null) { - throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); - } - return $this->contentGraphReadModel->buildContentGraph($workspace->workspaceName, $workspace->currentContentStreamId); - } - - /** - * Stateful (dirty) override of the chosen ContentStreamId for a given workspace, it applies within the given closure. - * Implementations must ensure that requesting the contentStreamId for this workspace will resolve to the given - * override ContentStreamId and vice versa resolving the WorkspaceName from this ContentStreamId should result in the - * given WorkspaceName within the closure. - * - * @internal Used in write operations applying commands to a contentstream that will have WorkspaceName in the future - * but doesn't have one yet. - */ - public function overrideContentStreamId(WorkspaceName $workspaceName, ContentStreamId $contentStreamId, \Closure $fn): void - { - if (isset($this->overriddenContentGraphInstances[$workspaceName->value])) { - throw new \RuntimeException('Contentstream override for this workspace already in effect, nesting not allowed.', 1715170938); - } - - $contentGraph = $this->contentGraphReadModel->buildContentGraph($workspaceName, $contentStreamId); - $this->overriddenContentGraphInstances[$workspaceName->value] = $contentGraph; - - try { - $fn(); - } finally { - unset($this->overriddenContentGraphInstances[$workspaceName->value]); - } - } - - /** - * Fixme only required to build the possible catchup hooks - */ - public function getContentRepository(): ContentRepository - { - return $this->contentRepository; - } -} diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 68c28d1069a..d966002fa7b 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -18,19 +18,17 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; -use Neos\ContentRepository\Core\EventStore\DecoratedEvent; -use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventPersister; -use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUp; use Neos\ContentRepository\Core\Projection\CatchUpOptions; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; @@ -48,7 +46,6 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Exception\ConcurrencyException; -use Neos\EventStore\Model\Event\EventMetadata; use Neos\EventStore\Model\EventEnvelope; use Neos\EventStore\Model\EventStream\VirtualStreamName; use Psr\Clock\ClockInterface; @@ -71,8 +68,6 @@ final class ContentRepository */ private array $projectionStateCache; - private CommandHandlingDependencies $commandHandlingDependencies; - /** * @internal use the {@see ContentRepositoryFactory::getOrBuild()} to instantiate */ @@ -90,7 +85,6 @@ public function __construct( private readonly ClockInterface $clock, private readonly ContentGraphReadModelInterface $contentGraphReadModel ) { - $this->commandHandlingDependencies = new CommandHandlingDependencies($this, $this->contentGraphReadModel); } /** @@ -102,7 +96,7 @@ public function handle(CommandInterface $command): void { // the commands only calculate which events they want to have published, but do not do the // publishing themselves - $eventsToPublishOrGenerator = $this->commandBus->handle($command, $this->commandHandlingDependencies); + $eventsToPublishOrGenerator = $this->commandBus->handle($command); if ($eventsToPublishOrGenerator instanceof EventsToPublish) { $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublishOrGenerator); @@ -111,25 +105,7 @@ public function handle(CommandInterface $command): void foreach ($eventsToPublishOrGenerator as $eventsToPublish) { assert($eventsToPublish instanceof EventsToPublish); // just for the ide $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublish); - try { - $this->eventPersister->publishEvents($this, $eventsToPublish); - } catch (ConcurrencyException $e) { - // we pass the exception into the generator, so it could be try-caught and reacted upon: - // - // try { - // yield EventsToPublish(); - // } catch (ConcurrencyException $e) { - // yield $restoreState(); - // throw $e; - // } - - $errorStrategy = $eventsToPublishOrGenerator->throw($e); - - if ($errorStrategy instanceof EventsToPublish) { - $eventsToPublish = $this->enrichEventsToPublishWithMetadata($errorStrategy); - $this->eventPersister->publishEvents($this, $this->enrichEventsToPublishWithMetadata($eventsToPublish)); - } - } + $this->eventPersister->publishEvents($this, $eventsToPublish); } } } @@ -301,34 +277,17 @@ public function getContentDimensionSource(): ContentDimensionSourceInterface return $this->contentDimensionSource; } - /** - * Add "initiatingUserId" and "initiatingTimestamp" metadata to all events. - * This is done in order to keep information about the _original_ metadata when an - * event is re-applied during publishing/rebasing - * "initiatingUserId": The identifier of the user that originally triggered this event. This will never - * be overridden if it is set once. - * "initiatingTimestamp": The timestamp of the original event. The "recordedAt" timestamp will always be - * re-created and reflects the time an event was actually persisted in a stream, - * the "initiatingTimestamp" will be kept and is never overridden again. - */ private function enrichEventsToPublishWithMetadata(EventsToPublish $eventsToPublish): EventsToPublish { $initiatingUserId = $this->userIdProvider->getUserId(); - $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM); + $initiatingTimestamp = $this->clock->now(); return new EventsToPublish( $eventsToPublish->streamName, - Events::fromArray( - $eventsToPublish->events->map(function (EventInterface|DecoratedEvent $event) use ( - $initiatingUserId, - $initiatingTimestamp - ) { - $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; - $metadata['initiatingUserId'] ??= $initiatingUserId; - $metadata['initiatingTimestamp'] ??= $initiatingTimestamp; - - return DecoratedEvent::create($event, metadata: EventMetadata::fromArray($metadata)); - }) + InitiatingEventMetadata::enrichEventsWithInitiatingMetadata( + $eventsToPublish->events, + $initiatingUserId, + $initiatingTimestamp ), $eventsToPublish->expectedVersion, ); diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php index c01aa27b45e..4909d50e661 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php @@ -31,8 +31,6 @@ public function publishEvents(ContentRepository $contentRepository, EventsToPubl if ($eventsToPublish->events->isEmpty()) { return; } - // the following logic could also be done in an AppEventStore::commit method (being called - // directly from the individual Command Handlers). $normalizedEvents = Events::fromArray( $eventsToPublish->events->map($this->eventNormalizer->normalize(...)) ); diff --git a/Neos.ContentRepository.Core/Classes/EventStore/Events.php b/Neos.ContentRepository.Core/Classes/EventStore/Events.php index 69e58099661..872aab9a56d 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/Events.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/Events.php @@ -27,6 +27,11 @@ public static function with(EventInterface|DecoratedEvent $event): self return new self($event); } + public function withAppendedEvents(Events $events): self + { + return new self(...$this->events, ...$events->events); + } + /** * @param array $events * @return static diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php index 3dd0d2567dc..d7b4ab5bcfe 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php @@ -33,4 +33,13 @@ public static function empty(): self ExpectedVersion::ANY() ); } + + public function withAppendedEvents(Events $events): self + { + return new self( + $this->streamName, + $this->events->withAppendedEvents($events), + $this->expectedVersion + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php new file mode 100644 index 00000000000..87be6d0b2cd --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php @@ -0,0 +1,69 @@ +format(\DateTimeInterface::ATOM); + + return Events::fromArray( + $events->map(function (EventInterface|DecoratedEvent $event) use ( + $initiatingUserId, + $initiatingTimestampFormatted + ) { + $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; + $metadata[self::INITIATING_USER_ID] ??= $initiatingUserId; + $metadata[self::INITIATING_TIMESTAMP] ??= $initiatingTimestampFormatted; + + return DecoratedEvent::create($event, metadata: EventMetadata::fromArray($metadata)); + }) + ); + } + + public static function getInitiatingTimestamp(EventMetadata $eventMetadata): ?\DateTimeImmutable + { + $rawTimestamp = $eventMetadata->get(self::INITIATING_TIMESTAMP); + if ($rawTimestamp === null) { + return null; + } + return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $rawTimestamp) ?: null; + } + + public static function extractInitiatingMetadata(EventMetadata $eventMetadata): EventMetadata + { + return EventMetadata::fromArray(array_filter([ + self::INITIATING_USER_ID => $eventMetadata->get(self::INITIATING_USER_ID), + self::INITIATING_TIMESTAMP => $eventMetadata->get(self::INITIATING_TIMESTAMP), + ])); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index c5ba2c24772..f8244afea53 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -15,6 +15,8 @@ namespace Neos\ContentRepository\Core\Factory; use Neos\ContentRepository\Core\CommandHandler\CommandBus; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; @@ -75,7 +77,6 @@ public function __construct( // The following properties store "singleton" references of objects for this content repository private ?ContentRepository $contentRepository = null; - private ?CommandBus $commandBus = null; private ?EventPersister $eventPersister = null; /** @@ -86,23 +87,62 @@ public function __construct( */ public function getOrBuild(): ContentRepository { - if (!$this->contentRepository) { - $this->contentRepository = new ContentRepository( - $this->contentRepositoryId, - $this->buildCommandBus(), - $this->projectionFactoryDependencies->eventStore, - $this->projectionsAndCatchUpHooks, - $this->projectionFactoryDependencies->eventNormalizer, - $this->buildEventPersister(), + if ($this->contentRepository) { + return $this->contentRepository; + } + + $contentGraphReadModel = $this->projectionsAndCatchUpHooks->contentGraphProjection->getState(); + $commandHandlingDependencies = new CommandHandlingDependencies($contentGraphReadModel); + + // we dont need full recursion in rebase - e.g apply workspace commands - and thus we can use this set for simulation + $commandBusForRebaseableCommands = new CommandBus( + $commandHandlingDependencies, + new NodeAggregateCommandHandler( $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->contentDimensionZookeeper, $this->projectionFactoryDependencies->interDimensionalVariationGraph, - $this->projectionFactoryDependencies->contentDimensionSource, - $this->userIdProvider, - $this->clock, - $this->projectionsAndCatchUpHooks->contentGraphProjection->getState() - ); - } - return $this->contentRepository; + $this->projectionFactoryDependencies->propertyConverter, + ), + new DimensionSpaceCommandHandler( + $this->projectionFactoryDependencies->contentDimensionZookeeper, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + ), + new NodeDuplicationCommandHandler( + $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->contentDimensionZookeeper, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + ) + ); + + $commandSimulatorFactory = new CommandSimulatorFactory( + $this->projectionsAndCatchUpHooks->contentGraphProjection, + $this->projectionFactoryDependencies->eventNormalizer, + $commandBusForRebaseableCommands + ); + + $publicCommandBus = $commandBusForRebaseableCommands->withAdditionalHandlers( + new ContentStreamCommandHandler(), + new WorkspaceCommandHandler( + $commandSimulatorFactory, + $this->projectionFactoryDependencies->eventStore, + $this->projectionFactoryDependencies->eventNormalizer, + ) + ); + + return $this->contentRepository = new ContentRepository( + $this->contentRepositoryId, + $publicCommandBus, + $this->projectionFactoryDependencies->eventStore, + $this->projectionsAndCatchUpHooks, + $this->projectionFactoryDependencies->eventNormalizer, + $this->buildEventPersister(), + $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + $this->projectionFactoryDependencies->contentDimensionSource, + $this->userIdProvider, + $this->clock, + $contentGraphReadModel + ); } /** @@ -129,37 +169,6 @@ public function buildService( return $serviceFactory->build($serviceFactoryDependencies); } - private function buildCommandBus(): CommandBus - { - if (!$this->commandBus) { - $this->commandBus = new CommandBus( - new ContentStreamCommandHandler( - ), - new WorkspaceCommandHandler( - $this->buildEventPersister(), - $this->projectionFactoryDependencies->eventStore, - $this->projectionFactoryDependencies->eventNormalizer, - ), - new NodeAggregateCommandHandler( - $this->projectionFactoryDependencies->nodeTypeManager, - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - $this->projectionFactoryDependencies->propertyConverter, - ), - new DimensionSpaceCommandHandler( - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - ), - new NodeDuplicationCommandHandler( - $this->projectionFactoryDependencies->nodeTypeManager, - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - ) - ); - } - return $this->commandBus; - } - private function buildEventPersister(): EventPersister { if (!$this->eventPersister) { diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php index 1dccfbe42d2..30b67c1de78 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\Common; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php index 4d2d5094818..c7b3d5cbc59 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php @@ -25,11 +25,11 @@ * * @internal used internally for the rebasing mechanism of content streams */ -interface RebasableToOtherWorkspaceInterface +interface RebasableToOtherWorkspaceInterface extends CommandInterface { public function createCopyForWorkspace( WorkspaceName $targetWorkspaceName, - ): CommandInterface; + ): self; /** * called during deserialization from metadata diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php index 2b129e8286a..1b8400833d1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php @@ -16,15 +16,13 @@ use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\CloseContentStream; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\ReopenContentStream; use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Command\RemoveContentStream; -use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamAlreadyExists; -use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; /** * INTERNALS. Only to be used from WorkspaceCommandHandler!!! @@ -46,7 +44,6 @@ public function handle(CommandInterface $command, CommandHandlingDependencies $c return match ($command::class) { CloseContentStream::class => $this->handleCloseContentStream($command, $commandHandlingDependencies), ReopenContentStream::class => $this->handleReopenContentStream($command, $commandHandlingDependencies), - ForkContentStream::class => $this->handleForkContentStream($command, $commandHandlingDependencies), RemoveContentStream::class => $this->handleRemoveContentStream($command, $commandHandlingDependencies), default => throw new \DomainException('Cannot handle commands of class ' . get_class($command), 1710408206), }; @@ -66,17 +63,6 @@ private function handleReopenContentStream( return $this->reopenContentStream($command->contentStreamId, $command->previousState, $commandHandlingDependencies); } - /** - * @throws ContentStreamAlreadyExists - * @throws ContentStreamDoesNotExistYet - */ - private function handleForkContentStream( - ForkContentStream $command, - CommandHandlingDependencies $commandHandlingDependencies - ): EventsToPublish { - return $this->forkContentStream($command->newContentStreamId, $command->sourceContentStreamId, $commandHandlingDependencies); - } - private function handleRemoveContentStream( RemoveContentStream $command, CommandHandlingDependencies $commandHandlingDependencies diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Command/ForkContentStream.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Command/ForkContentStream.php deleted file mode 100644 index 5ca25937112..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Command/ForkContentStream.php +++ /dev/null @@ -1,59 +0,0 @@ - $array - * @internal only used for testcases - */ - public static function fromArray(array $array): self - { - return new self( - ContentStreamId::fromString($array['contentStreamId']), - ContentStreamId::fromString($array['sourceContentStreamId']), - ); - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php index 5c2697ec9f3..16d191cfc10 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php @@ -4,7 +4,7 @@ namespace Neos\ContentRepository\Core\Feature; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasClosed; @@ -25,6 +25,7 @@ trait ContentStreamHandling /** * @param ContentStreamId $contentStreamId The id of the content stream to create * @throws ContentStreamAlreadyExists + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ private function createContentStream( ContentStreamId $contentStreamId, @@ -49,6 +50,7 @@ private function createContentStream( * @param ContentStreamId $contentStreamId The id of the content stream to close * @param CommandHandlingDependencies $commandHandlingDependencies * @return EventsToPublish + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ private function closeContentStream( ContentStreamId $contentStreamId, @@ -73,6 +75,7 @@ private function closeContentStream( /** * @param ContentStreamId $contentStreamId The id of the content stream to reopen * @param ContentStreamStatus $previousState The state the content stream was in before closing and is to be reset to + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ private function reopenContentStream( ContentStreamId $contentStreamId, @@ -100,6 +103,7 @@ private function reopenContentStream( * @param ContentStreamId $sourceContentStreamId The id of the content stream to fork * @throws ContentStreamAlreadyExists * @throws ContentStreamDoesNotExistYet + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ private function forkContentStream( ContentStreamId $newContentStreamId, @@ -131,6 +135,7 @@ private function forkContentStream( /** * @param ContentStreamId $contentStreamId The id of the content stream to remove + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ private function removeContentStream( ContentStreamId $contentStreamId, diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php index 76998ff39a2..4433ac05661 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php @@ -16,7 +16,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; @@ -26,6 +26,7 @@ use Neos\ContentRepository\Core\DimensionSpace\VariantType; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; @@ -77,13 +78,16 @@ private function handleMoveDimensionSpacePoint( return new EventsToPublish( $streamName, - Events::with( - new DimensionSpacePointWasMoved( - $contentGraph->getWorkspaceName(), - $contentGraph->getContentStreamId(), - $command->source, - $command->target - ), + RebaseableCommand::enrichWithCommand( + $command, + Events::with( + new DimensionSpacePointWasMoved( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $command->source, + $command->target + ), + ) ), ExpectedVersion::ANY() ); @@ -107,12 +111,15 @@ private function handleAddDimensionShineThrough( return new EventsToPublish( $streamName, - Events::with( - new DimensionShineThroughWasAdded( - $contentGraph->getWorkspaceName(), - $contentGraph->getContentStreamId(), - $command->source, - $command->target + RebaseableCommand::enrichWithCommand( + $command, + Events::with( + new DimensionShineThroughWasAdded( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $command->source, + $command->target + ) ) ), ExpectedVersion::ANY() diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php index 3830cf78e29..eea2319dd3a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php @@ -16,7 +16,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php index a0533f52e97..a27f1a295af 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php @@ -14,13 +14,13 @@ namespace Neos\ContentRepository\Core\Feature\NodeCreation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeCreationInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; @@ -221,7 +221,7 @@ private function handleCreateNodeAggregateWithNodeAndSerializedProperties( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, Events::fromArray($events)), + RebaseableCommand::enrichWithCommand($command, Events::fromArray($events)), $expectedVersion ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php index 4b4727757c0..c6e3820eb6e 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php @@ -14,12 +14,12 @@ * source code. */ -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; @@ -82,7 +82,7 @@ private function handleDisableNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), @@ -137,7 +137,7 @@ public function handleEnableNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, $events), + RebaseableCommand::enrichWithCommand($command, $events), $expectedVersion ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php index 7ef51daad82..4aa4da710b0 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; @@ -27,7 +27,7 @@ use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeCreationInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; @@ -160,7 +160,7 @@ private function handleCopyNodesRecursively( ContentStreamEventStreamName::fromContentStreamId( $contentGraph->getContentStreamId() )->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php index e4633ca163d..7608fef3c50 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php @@ -14,10 +14,10 @@ namespace Neos\ContentRepository\Core\Feature\NodeModification; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; @@ -132,7 +132,7 @@ private function handleSetSerializedNodeProperties( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php index 6dc6c947dc9..5dfae05275c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\NodeMove; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; @@ -23,7 +23,7 @@ use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSibling; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; @@ -207,7 +207,7 @@ private function handleMoveNodeAggregate( return new EventsToPublish( $contentStreamEventStreamName->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php index 6749780c5ed..eed1ce7c3b7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php @@ -14,11 +14,11 @@ namespace Neos\ContentRepository\Core\Feature\NodeReferencing; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyScope; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; @@ -162,7 +162,7 @@ private function handleSetSerializedNodeReferences( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php index e6b62d986b1..92dee4b597d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php @@ -14,12 +14,12 @@ namespace Neos\ContentRepository\Core\Feature\NodeRemoval; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRemoval\Event\NodeAggregateWasRemoved; @@ -91,7 +91,7 @@ private function handleRemoveNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php index ab2af2b4bb6..f3531340345 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php @@ -14,11 +14,11 @@ namespace Neos\ContentRepository\Core\Feature\NodeRenaming; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\Feature\NodeRenaming\Event\NodeAggregateNameWasChanged; @@ -61,7 +61,7 @@ private function handleChangeNodeAggregateName(ChangeNodeAggregateName $command, return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php index 6cc77a9d6bb..5436b048236 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php @@ -14,12 +14,12 @@ namespace Neos\ContentRepository\Core\Feature\NodeTypeChange; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeTypeChangeInternals; use Neos\ContentRepository\Core\Feature\Common\TetheredNodeInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; @@ -279,7 +279,7 @@ private function handleChangeNodeAggregateType( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events), ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php index fc9a1864444..1e01e281265 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php @@ -14,11 +14,11 @@ namespace Neos\ContentRepository\Core\Feature\NodeVariation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeVariationInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; @@ -84,7 +84,7 @@ private function handleCreateNodeVariant( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php similarity index 56% rename from Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php rename to Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php index d92b5f892c0..210f91b8b8a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php @@ -2,34 +2,61 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\Feature\Common; +namespace Neos\ContentRepository\Core\Feature; -/* - * This file is part of the Neos.ContentRepository package. - * - * (c) Contributors of the Neos Project - www.neos.io - * - * This package is Open Source Software. For the full copyright and license - * information, please view the LICENSE file which was distributed with this - * source code. - */ - -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\EventStore\DecoratedEvent; use Neos\ContentRepository\Core\EventStore\Events; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; +use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; +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; /** - * Stores the command in the event's metadata for events on a content stream. This is an important prerequisite - * for the rebase functionality- - * * @internal */ -final class NodeAggregateEventPublisher +final readonly class RebaseableCommand { + public function __construct( + public RebasableToOtherWorkspaceInterface $originalCommand, + public EventMetadata $initiatingMetaData, + public SequenceNumber $originalSequenceNumber + ) { + } + + public static function extractFromEventMetaData(EventMetadata $eventMetadata, SequenceNumber $sequenceNumber): self + { + $commandToRebaseClass = $eventMetadata->value['commandClass'] ?? null; + $commandToRebasePayload = $eventMetadata->value['commandPayload'] ?? null; + + if ($commandToRebaseClass === null || $commandToRebasePayload === null) { + throw new \RuntimeException('Command cannot be extracted from metadata, missing commandClass or commandPayload.', 1729847804); + } + + if (!in_array(RebasableToOtherWorkspaceInterface::class, class_implements($commandToRebaseClass) ?: [], true)) { + throw new \RuntimeException(sprintf( + 'Command "%s" can\'t be rebased because it does not implement %s', + $commandToRebaseClass, + RebasableToOtherWorkspaceInterface::class + ), 1547815341); + } + /** @var class-string $commandToRebaseClass */ + /** @var RebasableToOtherWorkspaceInterface $commandInstance */ + $commandInstance = $commandToRebaseClass::fromArray($commandToRebasePayload); + return new self( + $commandInstance, + InitiatingEventMetadata::extractInitiatingMetadata($eventMetadata), + $sequenceNumber + ); + } + + /** + * Stores the command in the event's metadata for events on a content stream. This is an important prerequisite + * for the rebase functionality- + */ public static function enrichWithCommand( - CommandInterface $command, + RebasableToOtherWorkspaceInterface $command, Events $events, ): Events { $processedEvents = []; @@ -54,7 +81,7 @@ public static function enrichWithCommand( if ($i === 0) { if (!$command instanceof \JsonSerializable) { throw new \RuntimeException(sprintf( - 'Command %s must be JSON Serializable to be used with NodeAggregateEventPublisher.', + 'Command %s must be JSON Serializable to be rebase able.', get_class($command) )); } @@ -81,7 +108,6 @@ public static function enrichWithCommand( $i++; } - return Events::fromArray($processedEvents); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php new file mode 100644 index 00000000000..5f4146321fe --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php @@ -0,0 +1,91 @@ + + */ +class RebaseableCommands implements \IteratorAggregate +{ + /** + * @var array + */ + private array $items; + + public function __construct( + RebaseableCommand ...$items + ) { + $this->items = $items; + } + + public static function extractFromEventStream(EventStreamInterface $eventStream): self + { + $commands = []; + foreach ($eventStream as $eventEnvelope) { + if ($eventEnvelope->event->metadata && isset($eventEnvelope->event->metadata?->value['commandClass'])) { + $commands[] = RebaseableCommand::extractFromEventMetaData($eventEnvelope->event->metadata, $eventEnvelope->sequenceNumber); + } + } + + return new RebaseableCommands(...$commands); + } + + /** + * @return array{RebaseableCommands,RebaseableCommands} + */ + public function separateMatchingAndRemainingCommands( + NodeIdsToPublishOrDiscard $nodeIdsToPublishOrDiscard + ): array { + $matchingCommands = []; + $remainingCommands = []; + foreach ($this->items as $extractedCommand) { + $originalCommand = $extractedCommand->originalCommand; + if (!$originalCommand instanceof MatchableWithNodeIdToPublishOrDiscardInterface) { + throw new \Exception( + 'Command class ' . get_class($originalCommand) . ' does not implement ' + . MatchableWithNodeIdToPublishOrDiscardInterface::class, + 1645393655 + ); + } + if (self::commandMatchesAtLeastOneNode($originalCommand, $nodeIdsToPublishOrDiscard)) { + $matchingCommands[] = $extractedCommand; + } else { + $remainingCommands[] = $extractedCommand; + } + } + return [ + new RebaseableCommands(...$matchingCommands), + new RebaseableCommands(...$remainingCommands) + ]; + } + + private static function commandMatchesAtLeastOneNode( + MatchableWithNodeIdToPublishOrDiscardInterface $command, + NodeIdsToPublishOrDiscard $nodeIds, + ): bool { + foreach ($nodeIds as $nodeId) { + if ($command->matchesNodeId($nodeId)) { + return true; + } + } + + return false; + } + + public function isEmpty(): bool + { + return $this->items === []; + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php index f09fc214aaf..739fa30c50a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php @@ -14,13 +14,13 @@ namespace Neos\ContentRepository\Core\Feature\RootNodeCreation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; @@ -118,7 +118,7 @@ private function handleCreateRootNodeAggregateWithNode( $contentStreamEventStream = ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()); return new EventsToPublish( $contentStreamEventStream->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), @@ -173,7 +173,7 @@ private function handleUpdateRootNodeAggregateDimensions( ); return new EventsToPublish( $contentStreamEventStream->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php index d26f8c30118..b134d44cfdc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php @@ -14,12 +14,12 @@ * source code. */ -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; @@ -71,7 +71,7 @@ private function handleTagSubtree(TagSubtree $command, CommandHandlingDependenci return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), @@ -116,7 +116,7 @@ public function handleUntagSubtree(UntagSubtree $command, CommandHandlingDepende return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, $events), + RebaseableCommand::enrichWithCommand($command, $events), ExpectedVersion::ANY() ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index daaf635fede..0cea05dc7e3 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -15,25 +15,19 @@ namespace Neos\ContentRepository\Core\Feature; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\DecoratedEvent; use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\EventStore\EventNormalizer; -use Neos\ContentRepository\Core\EventStore\EventPersister; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\EventStore\EventsToPublishFailed; -use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\CloseContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\ReopenContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Command\CreateContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; +use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasClosed; +use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened; use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked; -use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Command\RemoveContentStream; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; @@ -51,15 +45,11 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasDiscarded; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyDiscarded; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyPublished; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPublished; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Exception\BaseWorkspaceHasBeenModifiedInTheMeantime; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandThatFailedDuringRebase; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; @@ -68,11 +58,15 @@ use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; use Neos\EventStore\EventStoreInterface; -use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Event\EventType; +use Neos\EventStore\Model\Event\SequenceNumber; +use Neos\EventStore\Model\Event\Version; +use Neos\EventStore\Model\EventStream\EventStreamInterface; use Neos\EventStore\Model\EventStream\ExpectedVersion; /** @@ -83,7 +77,7 @@ use ContentStreamHandling; public function __construct( - private EventPersister $eventPersister, + private CommandSimulatorFactory $commandSimulatorFactory, private EventStoreInterface $eventStore, private EventNormalizer $eventNormalizer, ) { @@ -94,7 +88,7 @@ public function canHandle(CommandInterface $command): bool return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator + public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): \Generator { /** @phpstan-ignore-next-line */ return match ($command::class) { @@ -121,7 +115,9 @@ private function handleCreateWorkspace( CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { $this->requireWorkspaceToNotExist($command->workspaceName, $commandHandlingDependencies); - if ($commandHandlingDependencies->findWorkspaceByName($command->baseWorkspaceName) === null) { + $baseWorkspace = $commandHandlingDependencies->findWorkspaceByName($command->baseWorkspaceName); + + if ($baseWorkspace === null) { throw new BaseWorkspaceDoesNotExist(sprintf( 'The workspace %s (base workspace of %s) does not exist', $command->baseWorkspaceName->value, @@ -129,11 +125,10 @@ private function handleCreateWorkspace( ), 1513890708); } - $baseWorkspaceContentGraph = $commandHandlingDependencies->getContentGraph($command->baseWorkspaceName); // When the workspace is created, we first have to fork the content stream yield $this->forkContentStream( $command->newContentStreamId, - $baseWorkspaceContentGraph->getContentStreamId(), + $baseWorkspace->currentContentStreamId, $commandHandlingDependencies ); @@ -179,192 +174,163 @@ private function handleCreateRootWorkspace( ); } - /** - * @throws BaseWorkspaceDoesNotExist - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime - * @throws ContentStreamAlreadyExists - * @throws ContentStreamDoesNotExistYet - * @throws WorkspaceDoesNotExist - * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface - * @throws WorkspaceHasNoBaseWorkspaceName - */ private function handlePublishWorkspace( PublishWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot publish nodes on a workspace with a stateless content stream', 1729711258); + } + $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); + $baseContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); - $publishContentStream = $this->getCopiedEventsToPublishForContentStream( + yield $this->closeContentStream( $workspace->currentContentStreamId, - $baseWorkspace->workspaceName, - $baseWorkspace->currentContentStreamId + $commandHandlingDependencies ); + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() + ) + ); + + if ($rebaseableCommands->isEmpty()) { + // we have no changes, we just reopen; partial no-op + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + ContentStreamStatus::IN_USE_BY_WORKSPACE, // todo will be removed + $commandHandlingDependencies + ); + return; + } + try { - yield $publishContentStream; - } catch (ConcurrencyException $exception) { - throw new BaseWorkspaceHasBeenModifiedInTheMeantime(sprintf( - 'The base workspace has been modified in the meantime; please rebase.' - . ' Expected version %d of source content stream %s', - $publishContentStream->expectedVersion->value, - $baseWorkspace->currentContentStreamId - )); + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, + $command->newContentStreamId, + $baseContentStreamVersion, + $rebaseableCommands, + $commandHandlingDependencies + ); + } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + ContentStreamStatus::IN_USE_BY_WORKSPACE, // todo will be removed + $commandHandlingDependencies + ); + throw $workspaceRebaseFailed; + } + } + + private function publishWorkspace( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStreamId, + Version $baseContentStreamVersion, + RebaseableCommands $rebaseableCommands, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); + + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); + } + } + ); + + if ($commandSimulator->hasCommandsThatFailed()) { + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); } - // After publishing a workspace, we need to again fork from Base. + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream(), + ), + ExpectedVersion::fromVersion($baseContentStreamVersion) + ); + yield $this->forkContentStream( - $command->newContentStreamId, + $newContentStreamId, $baseWorkspace->currentContentStreamId, $commandHandlingDependencies ); - // if we got so far without an Exception, we can switch the Workspace's active Content stream. yield new EventsToPublish( - WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), Events::with( new WorkspaceWasPublished( - $command->workspaceName, + $workspace->workspaceName, $baseWorkspace->workspaceName, - $command->newContentStreamId, + $newContentStreamId, $workspace->currentContentStreamId, ) ), ExpectedVersion::ANY() ); - } - - /** - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime - * @throws \Exception - */ - private function getCopiedEventsToPublishForContentStream( - ContentStreamId $contentStreamId, - WorkspaceName $baseWorkspaceName, - ContentStreamId $baseContentStreamId, - ): EventsToPublish { - $baseWorkspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $baseContentStreamId - ); - - // TODO: please check the code below in-depth. it does: - // - copy all events from the "user" content stream which implement @see{}"PublishableToOtherContentStreamsInterface" - // - extract the initial ContentStreamWasForked event, - // to read the version of the source content stream when the fork occurred - // - ensure that no other changes have been done in the meantime in the base content stream - - $workspaceContentStream = iterator_to_array($this->eventStore->load( - ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName() - )); - - $events = []; - $contentStreamWasForkedEvent = null; - foreach ($workspaceContentStream as $eventEnvelope) { - $event = $this->eventNormalizer->denormalize($eventEnvelope->event); - if ($event instanceof ContentStreamWasForked) { - if ($contentStreamWasForkedEvent !== null) { - throw new \RuntimeException( - 'Invariant violation: The content stream "' . $contentStreamId->value - . '" has two forked events.', - 1658740373 - ); - } - $contentStreamWasForkedEvent = $event; - } elseif ($event instanceof PublishableToWorkspaceInterface) { - /** @var EventInterface $copiedEvent */ - $copiedEvent = $event->withWorkspaceNameAndContentStreamId($baseWorkspaceName, $baseContentStreamId); - // We need to add the event metadata here for rebasing in nested workspace situations - // (and for exporting) - $events[] = DecoratedEvent::create($copiedEvent, metadata: $eventEnvelope->event->metadata, causationId: $eventEnvelope->event->causationId, correlationId: $eventEnvelope->event->correlationId); - } - } + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + } - if ($contentStreamWasForkedEvent === null) { - throw new \RuntimeException('Invariant violation: The content stream "' . $contentStreamId->value - . '" has NO forked event.', 1658740407); - } + private function rebaseWorkspaceWithoutChanges( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStreamId, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + yield $this->forkContentStream( + $newContentStreamId, + $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies + ); - return new EventsToPublish( - $baseWorkspaceContentStreamName->getEventStreamName(), - Events::fromArray($events), - ExpectedVersion::fromVersion($contentStreamWasForkedEvent->versionOfSourceContentStream) + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasRebased( + $workspace->workspaceName, + $newContentStreamId, + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::ANY() ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** - * @deprecated FIXME REMOVE with https://github.com/neos/neos-development-collection/pull/5301 - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime - * @throws \Exception + * Copy all events from the passed event stream which implement the {@see PublishableToOtherContentStreamsInterface} */ - private function publishContentStream( - CommandHandlingDependencies $commandHandlingDependencies, - ContentStreamId $contentStreamId, - WorkspaceName $baseWorkspaceName, - ContentStreamId $baseContentStreamId, - ): void { - $baseWorkspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $baseContentStreamId - ); - - // TODO: please check the code below in-depth. it does: - // - copy all events from the "user" content stream which implement @see{}"PublishableToOtherContentStreamsInterface" - // - extract the initial ContentStreamWasForked event, - // to read the version of the source content stream when the fork occurred - // - ensure that no other changes have been done in the meantime in the base content stream - - $workspaceContentStream = iterator_to_array($this->eventStore->load( - ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName() - )); - + private function getCopiedEventsOfEventStream( + WorkspaceName $targetWorkspaceName, + ContentStreamId $targetContentStreamId, + EventStreamInterface $eventStream + ): Events { $events = []; - $contentStreamWasForkedEvent = null; - foreach ($workspaceContentStream as $eventEnvelope) { + foreach ($eventStream as $eventEnvelope) { $event = $this->eventNormalizer->denormalize($eventEnvelope->event); - if ($event instanceof ContentStreamWasForked) { - if ($contentStreamWasForkedEvent !== null) { - throw new \RuntimeException( - 'Invariant violation: The content stream "' . $contentStreamId->value - . '" has two forked events.', - 1658740373 - ); - } - $contentStreamWasForkedEvent = $event; - } elseif ($event instanceof PublishableToWorkspaceInterface) { + if ($event instanceof PublishableToWorkspaceInterface) { /** @var EventInterface $copiedEvent */ - $copiedEvent = $event->withWorkspaceNameAndContentStreamId($baseWorkspaceName, $baseContentStreamId); + $copiedEvent = $event->withWorkspaceNameAndContentStreamId($targetWorkspaceName, $targetContentStreamId); // We need to add the event metadata here for rebasing in nested workspace situations // (and for exporting) $events[] = DecoratedEvent::create($copiedEvent, metadata: $eventEnvelope->event->metadata, causationId: $eventEnvelope->event->causationId, correlationId: $eventEnvelope->event->correlationId); } } - if ($contentStreamWasForkedEvent === null) { - throw new \RuntimeException('Invariant violation: The content stream "' . $contentStreamId->value - . '" has NO forked event.', 1658740407); - } - - if (count($events) === 0) { - return; - } - try { - $this->eventPersister->publishEvents( - $commandHandlingDependencies->getContentRepository(), - new EventsToPublish( - $baseWorkspaceContentStreamName->getEventStreamName(), - Events::fromArray($events), - ExpectedVersion::fromVersion($contentStreamWasForkedEvent->versionOfSourceContentStream) - ) - ); - } catch (ConcurrencyException $e) { - throw new BaseWorkspaceHasBeenModifiedInTheMeantime(sprintf( - 'The base workspace has been modified in the meantime; please rebase.' - . ' Expected version %d of source content stream %s', - $contentStreamWasForkedEvent->versionOfSourceContentStream->value, - $baseContentStreamId->value - )); - } + return Events::fromArray($events); } /** @@ -375,132 +341,99 @@ private function publishContentStream( private function handleRebaseWorkspace( RebaseWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $workspace->currentContentStreamId; - if (!$commandHandlingDependencies->contentStreamExists($oldWorkspaceContentStreamId)) { - throw new \DomainException('Cannot rebase a workspace with a stateless content stream', 1711718314); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot rebase a workspace with a stateless content stream', 1711718314); } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($oldWorkspaceContentStreamId); + $currentWorkspaceContentStreamState = $commandHandlingDependencies->getContentStreamStatus($workspace->currentContentStreamId); - // 0) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($oldWorkspaceContentStreamId) + if ( + $workspace->status === WorkspaceStatus::UP_TO_DATE + && $command->rebaseErrorHandlingStrategy !== RebaseErrorHandlingStrategy::STRATEGY_FORCE + ) { + // no-op if workspace is not outdated and not forcing it + return; + } + + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - // 1) fork a new content stream - $rebasedContentStreamId = $command->rebasedContentStreamId; - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->rebasedContentStreamId, - $baseWorkspace->currentContentStreamId, + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - $workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $workspace->currentContentStreamId - ); + if ($rebaseableCommands->isEmpty()) { + // if we have no changes in the workspace we can fork from the base directly + yield from $this->rebaseWorkspaceWithoutChanges( + $workspace, + $baseWorkspace, + $command->rebasedContentStreamId, + $commandHandlingDependencies + ); + return; + } - // 2) extract the commands from the to-be-rebased content stream; and applies them on the new content stream - $originalCommands = $this->extractCommandsFromContentStreamMetadata($workspaceContentStreamName); - $commandsThatFailed = new CommandsThatFailedDuringRebase(); - $commandHandlingDependencies->overrideContentStreamId( - $command->workspaceName, - $command->rebasedContentStreamId, - function () use ($originalCommands, $commandHandlingDependencies, &$commandsThatFailed): void { - foreach ($originalCommands as $sequenceNumber => $originalCommand) { - // We no longer need to adjust commands as the workspace stays the same - try { - $commandHandlingDependencies->handle($originalCommand); - // if we came this far, we know the command was applied successfully. - } catch (\Exception $e) { - $commandsThatFailed = $commandsThatFailed->add( - new CommandThatFailedDuringRebase( - $sequenceNumber, - $originalCommand, - $e - ) - ); - } + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); + + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); } } ); - // 3) if we got so far without an exception (or if we don't care), we can switch the workspace's active content stream. - if ($command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FORCE || $commandsThatFailed->isEmpty()) { - $events = Events::with( - new WorkspaceWasRebased( - $command->workspaceName, - $rebasedContentStreamId, - $workspace->currentContentStreamId, - ), + if ( + $command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FAIL + && $commandSimulator->hasCommandsThatFailed() + ) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $currentWorkspaceContentStreamState, + $commandHandlingDependencies ); - return new EventsToPublish( - $workspaceStreamName, - $events, - ExpectedVersion::ANY() - ); + // throw an exception that contains all the information about what exactly failed + throw WorkspaceRebaseFailed::duringRebase($commandSimulator->getCommandsThatFailed()); } - // 3.E) In case of an exception, reopen the old content stream... - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) + // if we got so far without an exception (or if we don't care), we can switch the workspace's active content stream. + yield from $this->forkNewContentStreamAndApplyEvents( + $command->rebasedContentStreamId, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasRebased( + $command->workspaceName, + $command->rebasedContentStreamId, + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->rebasedContentStreamId, + $commandSimulator->eventStream(), + ), + $commandHandlingDependencies ); - // ... remove the newly created one... - $commandHandlingDependencies->handle(RemoveContentStream::create( - $rebasedContentStreamId - )); - - // ...and throw an exception that contains all the information about what exactly failed - throw new WorkspaceRebaseFailed($commandsThatFailed, 'Rebase failed', 1711713880); - } - - /** - * @return array - */ - private function extractCommandsFromContentStreamMetadata( - ContentStreamEventStreamName $workspaceContentStreamName, - ): array { - $workspaceContentStream = $this->eventStore->load($workspaceContentStreamName->getEventStreamName()); - - $commands = []; - foreach ($workspaceContentStream as $eventEnvelope) { - $metadata = $eventEnvelope->event->metadata?->value ?? []; - // TODO: Add this logic to the NodeAggregateCommandHandler; - // so that we can be sure these can be parsed again. - if (isset($metadata['commandClass'])) { - $commandToRebaseClass = $metadata['commandClass']; - $commandToRebasePayload = $metadata['commandPayload']; - - if (array_diff(class_implements($commandToRebaseClass) ?: [], [CommandInterface::class, RebasableToOtherWorkspaceInterface::class]) === []) { - throw new \RuntimeException(sprintf( - 'Command "%s" can\'t be rebased because it does not implement %s', - $commandToRebaseClass, - RebasableToOtherWorkspaceInterface::class - ), 1547815341); - } - /** @var class-string $commandToRebaseClass */ - /** @var CommandInterface&RebasableToOtherWorkspaceInterface $commandInstance */ - $commandInstance = $commandToRebaseClass::fromArray($commandToRebasePayload); - $commands[$eventEnvelope->sequenceNumber->value] = $commandInstance; - } - } - - return $commands; + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** * This method is like a combined Rebase and Publish! * * @throws BaseWorkspaceDoesNotExist - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime * @throws ContentStreamAlreadyExists * @throws ContentStreamDoesNotExistYet * @throws WorkspaceDoesNotExist @@ -509,131 +442,129 @@ private function extractCommandsFromContentStreamMetadata( private function handlePublishIndividualNodesFromWorkspace( PublishIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { - $contentGraph = $commandHandlingDependencies->getContentGraph($command->workspaceName); + ): \Generator { + if ($command->nodesToPublish->isEmpty()) { + // noop + return; + } + $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $workspace->currentContentStreamId; - if (!$commandHandlingDependencies->contentStreamExists($oldWorkspaceContentStreamId)) { - throw new \DomainException('Cannot publish nodes on a workspace with a stateless content stream', 1710410114); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot publish nodes on a workspace with a stateless content stream', 1710410114); } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($oldWorkspaceContentStreamId); + $currentWorkspaceContentStreamState = $commandHandlingDependencies->getContentStreamStatus($workspace->currentContentStreamId); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); + $baseContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); - // 1) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($contentGraph->getContentStreamId()) + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - // 2) separate commands in two parts - the ones MATCHING the nodes from the command, and the REST - /** @var RebasableToOtherWorkspaceInterface[] $matchingCommands */ - $matchingCommands = []; - $remainingCommands = []; - $this->separateMatchingAndRemainingCommands($command, $workspace, $matchingCommands, $remainingCommands); - - // 3) fork a new contentStream, based on the base WS, and apply MATCHING - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->contentStreamIdForMatchingPart, - $baseWorkspace->currentContentStreamId, + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); - try { - // 4) using the new content stream, apply the matching commands - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, - $command->contentStreamIdForMatchingPart, - function () use ($matchingCommands, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($matchingCommands as $matchingCommand) { - if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { - throw new \RuntimeException( - 'ERROR: The command ' . get_class($matchingCommand) - . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' - ); - } - - $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace( - $baseWorkspace->workspaceName, - )); - } - } - ); + [$matchingCommands, $remainingCommands] = $rebaseableCommands->separateMatchingAndRemainingCommands($command->nodesToPublish); - // 5) take EVENTS(MATCHING) and apply them to base WS. - $this->publishContentStream( - $commandHandlingDependencies, - $command->contentStreamIdForMatchingPart, - $baseWorkspace->workspaceName, - $baseWorkspace->currentContentStreamId + if ($matchingCommands->isEmpty()) { + // almost a noop (e.g. random node ids were specified) ;) + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $currentWorkspaceContentStreamState, + $commandHandlingDependencies ); + return; + } - // 6) fork a new content stream, based on the base WS, and apply REST - $commandHandlingDependencies->handle( - ForkContentStream::create( + if ($remainingCommands->isEmpty()) { + try { + // do a full publish, this is simpler for the projections to handle + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, $command->contentStreamIdForRemainingPart, - $baseWorkspace->currentContentStreamId - ) - ); + $baseContentStreamVersion, + $matchingCommands, + $commandHandlingDependencies + ); + return; + } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + ContentStreamStatus::IN_USE_BY_WORKSPACE, // todo will be removed + $commandHandlingDependencies + ); + throw $workspaceRebaseFailed; + } + } + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); - // 7) apply REMAINING commands to the workspace's new content stream - $commandHandlingDependencies->overrideContentStreamId( - $command->workspaceName, - $command->contentStreamIdForRemainingPart, - function () use ($commandHandlingDependencies, $remainingCommands) { - foreach ($remainingCommands as $remainingCommand) { - $commandHandlingDependencies->handle($remainingCommand); - } + $highestSequenceNumberForMatching = $commandSimulator->run( + static function ($handle) use ($commandSimulator, $matchingCommands, $remainingCommands): SequenceNumber { + foreach ($matchingCommands as $matchingCommand) { + $handle($matchingCommand); } - ); - } catch (\Exception $exception) { - // 4.E) In case of an exception, reopen the old content stream and remove the newly created - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) - ); - - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForMatchingPart - )); - - try { - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForRemainingPart - )); - } catch (ContentStreamDoesNotExistYet $contentStreamDoesNotExistYet) { - // in case the exception was thrown before 6), this does not exist + $highestSequenceNumberForMatching = $commandSimulator->currentSequenceNumber(); + foreach ($remainingCommands as $remainingCommand) { + $handle($remainingCommand); + } + return $highestSequenceNumberForMatching; } + ); - throw $exception; - } + if ($commandSimulator->hasCommandsThatFailed()) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $currentWorkspaceContentStreamState, + $commandHandlingDependencies + ); - // 8) to avoid dangling content streams, we need to remove our temporary content stream (whose events - // have already been published) as well as the old one - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForMatchingPart - )); - $commandHandlingDependencies->handle(RemoveContentStream::create( - $oldWorkspaceContentStreamId - )); + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); + } - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); + // this could be a no-op for the rare case when a command returns empty events e.g. the node was already tagged with this subtree tag, meaning we actually just rebase + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream()->withMaximumSequenceNumber($highestSequenceNumberForMatching), + ), + ExpectedVersion::fromVersion($baseContentStreamVersion) + ); - return new EventsToPublish( - $streamName, - Events::fromArray([ - new WorkspaceWasPartiallyPublished( - $command->workspaceName, - $baseWorkspace->workspaceName, - $command->contentStreamIdForRemainingPart, - $oldWorkspaceContentStreamId, - $command->nodesToPublish - ) - ]), - ExpectedVersion::ANY() + yield from $this->forkNewContentStreamAndApplyEvents( + $command->contentStreamIdForRemainingPart, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::fromArray([ + new WorkspaceWasPartiallyPublished( + $command->workspaceName, + $baseWorkspace->workspaceName, + $command->contentStreamIdForRemainingPart, + $workspace->currentContentStreamId, + $command->nodesToPublish + ) + ]), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->contentStreamIdForRemainingPart, + $commandSimulator->eventStream()->withMinimumSequenceNumber($highestSequenceNumberForMatching->next()) + ), + $commandHandlingDependencies ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -649,134 +580,97 @@ function () use ($commandHandlingDependencies, $remainingCommands) { private function handleDiscardIndividualNodesFromWorkspace( DiscardIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { - $contentGraph = $commandHandlingDependencies->getContentGraph($command->workspaceName); + ): \Generator { + if ($command->nodesToDiscard->isEmpty()) { + // noop + return; + } + $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $contentGraph->getContentStreamId(); - if (!$commandHandlingDependencies->contentStreamExists($contentGraph->getContentStreamId())) { - throw new \DomainException('Cannot discard nodes on a workspace with a stateless content stream', 1710408112); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot discard nodes on a workspace with a stateless content stream', 1710408112); } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($contentGraph->getContentStreamId()); + $currentWorkspaceContentStreamState = $commandHandlingDependencies->getContentStreamStatus($workspace->currentContentStreamId); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - // 1) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($oldWorkspaceContentStreamId) + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - // 2) filter commands, only keeping the ones NOT MATCHING the nodes from the command - // (i.e. the modifications we want to keep) - $commandsToDiscard = []; - $commandsToKeep = []; - $this->separateMatchingAndRemainingCommands($command, $workspace, $commandsToDiscard, $commandsToKeep); - - // 3) fork a new contentStream, based on the base WS, and apply the commands to keep - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->newContentStreamId, - $baseWorkspace->currentContentStreamId, + // filter commands, only keeping the ones NOT MATCHING the nodes from the command (i.e. the modifications we want to keep) + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); + [$commandsToDiscard, $commandsToKeep] = $rebaseableCommands->separateMatchingAndRemainingCommands($command->nodesToDiscard); + + if ($commandsToDiscard->isEmpty()) { + // if we have nothing to discard, we can just keep all. (e.g. random node ids were specified) It's almost a noop ;) + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $currentWorkspaceContentStreamState, + $commandHandlingDependencies + ); + return; + } - // 4) using the new content stream, apply the commands to keep - try { - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, + if ($commandsToKeep->isEmpty()) { + // quick path everything was discarded + yield from $this->discardWorkspace( + $workspace, + $baseWorkspace, $command->newContentStreamId, - function () use ($commandsToKeep, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($commandsToKeep as $matchingCommand) { - $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace( - $baseWorkspace->workspaceName, - )); - } - } + $commandHandlingDependencies ); - } catch (\Exception $exception) { - // 4.E) In case of an exception, reopen the old content stream and remove the newly created - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) - ); - - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->newContentStreamId - )); - - throw $exception; + return; } - // 5) If everything worked, to avoid dangling content streams, we need to remove the old content stream - $commandHandlingDependencies->handle(RemoveContentStream::create( - $oldWorkspaceContentStreamId - )); - - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - - return new EventsToPublish( - $streamName, - Events::with( - new WorkspaceWasPartiallyDiscarded( - $command->workspaceName, - $command->newContentStreamId, - $workspace->currentContentStreamId, - $command->nodesToDiscard, - ) - ), - ExpectedVersion::ANY() - ); - } + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); - - /** - * @param array &$matchingCommands - * @param array &$remainingCommands - * @param-out array $matchingCommands - * @param-out array $remainingCommands - */ - private function separateMatchingAndRemainingCommands( - PublishIndividualNodesFromWorkspace|DiscardIndividualNodesFromWorkspace $command, - Workspace $workspace, - array &$matchingCommands, - array &$remainingCommands - ): void { - $workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $workspace->currentContentStreamId + $commandSimulator->run( + static function ($handle) use ($commandsToKeep): void { + foreach ($commandsToKeep as $matchingCommand) { + $handle($matchingCommand); + } + } ); - $originalCommands = $this->extractCommandsFromContentStreamMetadata($workspaceContentStreamName); - - foreach ($originalCommands as $originalCommand) { - if (!$originalCommand instanceof MatchableWithNodeIdToPublishOrDiscardInterface) { - throw new \Exception( - 'Command class ' . get_class($originalCommand) . ' does not implement ' - . MatchableWithNodeIdToPublishOrDiscardInterface::class, - 1645393655 - ); - } - $nodeIds = $command instanceof PublishIndividualNodesFromWorkspace - ? $command->nodesToPublish - : $command->nodesToDiscard; - if ($this->commandMatchesAtLeastOneNode($originalCommand, $nodeIds)) { - $matchingCommands[] = $originalCommand; - } else { - $remainingCommands[] = $originalCommand; - } + if ($commandSimulator->hasCommandsThatFailed()) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $currentWorkspaceContentStreamState, + $commandHandlingDependencies + ); + throw WorkspaceRebaseFailed::duringDiscard($commandSimulator->getCommandsThatFailed()); } - } - private function commandMatchesAtLeastOneNode( - MatchableWithNodeIdToPublishOrDiscardInterface $command, - NodeIdsToPublishOrDiscard $nodeIds, - ): bool { - foreach ($nodeIds as $nodeId) { - if ($command->matchesNodeId($nodeId)) { - return true; - } - } + yield from $this->forkNewContentStreamAndApplyEvents( + $command->newContentStreamId, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasPartiallyDiscarded( + $command->workspaceName, + $command->newContentStreamId, + $workspace->currentContentStreamId, + $command->nodesToDiscard, + ) + ), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->newContentStreamId, + $commandSimulator->eventStream(), + ), + $commandHandlingDependencies + ); - return false; + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -791,25 +685,50 @@ private function handleDiscardWorkspace( $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $newContentStream = $command->newContentStreamId; + if (!$this->hasEventsInContentStreamExceptForking(ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId))) { + return; + } + + yield from $this->discardWorkspace( + $workspace, + $baseWorkspace, + $command->newContentStreamId, + $commandHandlingDependencies + ); + } + + /** + * @param Workspace $workspace + * @param Workspace $baseWorkspace + * @param ContentStreamId $newContentStream + * @param CommandHandlingDependencies $commandHandlingDependencies + * @phpstan-pure this method is pure, to persist the events they must be handled outside + */ + private function discardWorkspace( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStream, + CommandHandlingDependencies $commandHandlingDependencies + ): \Generator { yield $this->forkContentStream( $newContentStream, $baseWorkspace->currentContentStreamId, $commandHandlingDependencies ); - // if we got so far without an Exception, we can switch the Workspace's active Content stream. yield new EventsToPublish( - WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), Events::with( new WorkspaceWasDiscarded( - $command->workspaceName, + $workspace->workspaceName, $newContentStream, $workspace->currentContentStreamId, ) ), ExpectedVersion::ANY() ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -875,12 +794,43 @@ private function handleDeleteWorkspace( ); } + private function forkNewContentStreamAndApplyEvents( + ContentStreamId $newContentStreamId, + ContentStreamId $sourceContentStreamId, + EventsToPublish $pointWorkspaceToNewContentStream, + Events $eventsToApplyOnNewContentStream, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + yield $this->forkContentStream( + $newContentStreamId, + $sourceContentStreamId, + $commandHandlingDependencies + )->withAppendedEvents(Events::with( + new ContentStreamWasClosed( + $newContentStreamId + ) + )); + + yield $pointWorkspaceToNewContentStream; + + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($newContentStreamId) + ->getEventStreamName(), + $eventsToApplyOnNewContentStream->withAppendedEvents( + Events::with( + new ContentStreamWasReopened( + $newContentStreamId, + ContentStreamStatus::IN_USE_BY_WORKSPACE // todo remove just temporary + ) + ) + ), + ExpectedVersion::fromVersion(Version::first()->next()) + ); + } + private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void { - try { - $commandHandlingDependencies->getContentGraph($workspaceName); - } catch (WorkspaceDoesNotExist) { - // Desired outcome + if ($commandHandlingDependencies->findWorkspaceByName($workspaceName) === null) { return; } @@ -956,6 +906,7 @@ private function requireEmptyWorkspace(Workspace $workspace): void private function hasEventsInContentStreamExceptForking( ContentStreamEventStreamName $workspaceContentStreamName, ): bool { + // todo introduce workspace has changes instead $workspaceContentStream = $this->eventStore->load($workspaceContentStreamName->getEventStreamName()); $fullQualifiedEventClassName = ContentStreamWasForked::class; diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php index 8771ec296d8..5a5b0355945 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php @@ -27,7 +27,7 @@ { /** * @param WorkspaceName $workspaceName Name of the affected workspace - * @param ContentStreamId $newContentStreamId The id of the newly created content stream that will contain the remaining changes that were not discarded + * @param ContentStreamId $newContentStreamId The id of the newly forked content stream with no changes */ private function __construct( public WorkspaceName $workspaceName, diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php index 452eeeeec54..a3dbf77b47c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php @@ -29,13 +29,11 @@ /** * @param WorkspaceName $workspaceName Name of the affected workspace * @param NodeIdsToPublishOrDiscard $nodesToPublish Ids of the nodes to publish or discard - * @param ContentStreamId $contentStreamIdForMatchingPart The id of the new content stream that will contain all events to be published {@see self::withContentStreamIdForMatchingPart()} * @param ContentStreamId $contentStreamIdForRemainingPart The id of the new content stream that will contain all remaining events {@see self::withContentStreamIdForRemainingPart()} */ private function __construct( public WorkspaceName $workspaceName, public NodeIdsToPublishOrDiscard $nodesToPublish, - public ContentStreamId $contentStreamIdForMatchingPart, public ContentStreamId $contentStreamIdForRemainingPart ) { } @@ -49,26 +47,10 @@ public static function create(WorkspaceName $workspaceName, NodeIdsToPublishOrDi return new self( $workspaceName, $nodesToPublish, - ContentStreamId::create(), ContentStreamId::create() ); } - /** - * During the publish process, we sort the events so that the events we want to publish - * come first. In this process, two new content streams are generated: - * - the first one contains all events which we want to publish - * - the second one is based on the first one, and contains all the remaining events (which we want to keep - * in the user workspace). - * - * This method adds the ID of the first content stream, so that the command - * can run fully deterministic - we need this for the test cases. - */ - public function withContentStreamIdForMatchingPart(ContentStreamId $contentStreamIdForMatchingPart): self - { - return new self($this->workspaceName, $this->nodesToPublish, $contentStreamIdForMatchingPart, $this->contentStreamIdForRemainingPart); - } - /** * See the description of {@see self::withContentStreamIdForMatchingPart()}. * @@ -77,6 +59,6 @@ public function withContentStreamIdForMatchingPart(ContentStreamId $contentStrea */ public function withContentStreamIdForRemainingPart(ContentStreamId $contentStreamIdForRemainingPart): self { - return new self($this->workspaceName, $this->nodesToPublish, $this->contentStreamIdForMatchingPart, $contentStreamIdForRemainingPart); + return new self($this->workspaceName, $this->nodesToPublish, $contentStreamIdForRemainingPart); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php index db373f0151a..53aa7a73363 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php @@ -56,6 +56,11 @@ public function getIterator(): \Traversable yield from $this->nodeIds; } + public function isEmpty(): bool + { + return $this->nodeIds === []; + } + public function count(): int { return count($this->nodeIds); diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php deleted file mode 100644 index a590fc50546..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php +++ /dev/null @@ -1,22 +0,0 @@ -sequenceNumber; + } } 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.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php index fca250fd797..3830c431cc3 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php @@ -13,4 +13,24 @@ interface ContentGraphProjectionInterface extends ProjectionInterface { public function getState(): ContentGraphReadModelInterface; + + /** + * Dedicated method for simulated rebasing + * + * The implementation must ensure that the function passed is invoked + * and that any changes via {@see ContentGraphProjectionInterface::apply()} + * are executed "in simulation" e.g. NOT persisted after returning. + * + * The projection state {@see ContentGraphReadModelInterface} must reflect the + * current changes of the simulation as well during the execution of the function. + * + * This is generally done by leveraging a transaction and rollback. + * + * Used to simulate commands for publishing: {@see \Neos\ContentRepository\Core\CommandHandler\CommandSimulator} + * + * @template T + * @param \Closure(): T $fn + * @return T the return value of $fn + */ + public function inSimulation(\Closure $fn): mixed; } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php index 03bce7a58b7..e1298bc151e 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php @@ -34,11 +34,6 @@ interface ContentGraphReadModelInterface extends ProjectionStateInterface */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface; - /** - * @deprecated todo remove me after https://github.com/neos/neos-development-collection/pull/5301 ;) - */ - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraphInterface; - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace; public function findWorkspaces(): Workspaces; diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php index 33497841f00..934f0c62d18 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php @@ -38,7 +38,7 @@ * The system guarantees the following invariants: * * - Inside a NodeAggregate, each DimensionSpacePoint has at most one Node which covers it. - * To check this, the ReadableNodeAggregateInterface is used (mainly in constraint checks). + * To check this, this class is used (mainly in constraint checks). * - The NodeType is always the same for all Nodes in a NodeAggregate * - all Nodes inside the NodeAggregate always have the same NodeName. * - all nodes inside a NodeAggregate are all of the same *classification*, which can be: @@ -46,9 +46,6 @@ * - *tethered*: for nodes "attached" to the parent node (i.e. the old "AutoCreatedChildNodes") * - *regular*: for all other nodes. * - * This interface is called *Readable* because it exposes read operations on the set of nodes inside - * a single NodeAggregate; often used for constraint checks (in command handlers). - * * @api Note: The constructor is not part of the public API */ final readonly class NodeAggregate diff --git a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php index 15d7667ae5e..94f745e0a10 100644 --- a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php +++ b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php @@ -55,6 +55,9 @@ public function prune(bool $removeTemporary = false): iterable ); $unusedContentStreamIds = []; foreach ($unusedContentStreams as $contentStream) { + if ($contentStream->removed) { + continue; + } $this->contentRepository->handle( RemoveContentStream::create($contentStream->id) ); diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php index 0aa571b20ea..e603c12fd04 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php @@ -18,6 +18,7 @@ use Behat\Gherkin\Node\TableNode; use League\Flysystem\Filesystem; use League\Flysystem\InMemory\InMemoryFilesystemAdapter; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -154,12 +155,11 @@ public function iExpectTheFollowingJsonL(PyStringNode $string): void $eventsWithoutRandomIds = []; foreach ($exportedEvents as $exportedEvent) { - // we have to remove the event id in \Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher::enrichWithCommand - // and the initiatingTimestamp to make the events diff able + // we have to remove the event id and initiatingTimestamp to make the events diff able $eventsWithoutRandomIds[] = $exportedEvent ->withIdentifier('random-event-uuid') ->processMetadata(function (array $metadata) { - $metadata['initiatingTimestamp'] = 'random-time'; + $metadata[InitiatingEventMetadata::INITIATING_TIMESTAMP] = 'random-time'; return $metadata; }); } diff --git a/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php b/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php index 826460a31e0..d60adda0a33 100644 --- a/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php +++ b/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php @@ -199,6 +199,7 @@ protected function executeSubMigration( private function workspaceIsEmpty(Workspace $workspace): bool { + // todo introduce Workspace::hasPendingChanges return $this->contentRepository ->projectionState(ChangeFinder::class) ->countByContentStreamId($workspace->currentContentStreamId) === 0; 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/Features/WorkspacePublishing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php index 0b7c9cf04e9..8e94de6713b 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php @@ -46,9 +46,6 @@ public function theCommandPublishIndividualNodesFromWorkspaceIsExecuted(TableNod : $this->currentWorkspaceName, $nodesToPublish, ); - if (isset($commandArguments['contentStreamIdForMatchingPart'])) { - $command = $command->withContentStreamIdForMatchingPart(ContentStreamId::fromString($commandArguments['contentStreamIdForMatchingPart'])); - } if (isset($commandArguments['contentStreamIdForRemainingPart'])) { $command = $command->withContentStreamIdForRemainingPart(ContentStreamId::fromString($commandArguments['contentStreamIdForRemainingPart'])); } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index bc49c4c9e37..ffaf8f77780 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -20,7 +20,6 @@ use Neos\ContentRepository\Core\EventStore\EventPersister; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; @@ -35,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; @@ -105,7 +105,6 @@ protected static function resolveShortCommandName(string $shortCommandName): str 'PublishIndividualNodesFromWorkspace' => PublishIndividualNodesFromWorkspace::class, 'RebaseWorkspace' => RebaseWorkspace::class, 'CreateNodeAggregateWithNodeAndSerializedProperties' => CreateNodeAggregateWithNodeAndSerializedProperties::class, - 'ForkContentStream' => ForkContentStream::class, 'ChangeNodeAggregateName' => ChangeNodeAggregateName::class, 'SetSerializedNodeProperties' => SetSerializedNodeProperties::class, 'DisableNodeAggregate' => DisableNodeAggregate::class, @@ -149,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 { @@ -166,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->getSequenceNumber()->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/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index 7867326e1ea..97b970ba6cc 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -47,16 +47,13 @@ public function onBeforeCatchUp(): void public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { - // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance instanceof EmbedsWorkspaceName) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + // Skip if the workspace does not exist: "The source workspace missing does not exist" https://github.com/neos/neos-development-collection/pull/5270 + $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { return; } - if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { - return; - } } match ($eventInstance::class) { @@ -68,16 +65,13 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { - // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance instanceof EmbedsWorkspaceName) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + // Skip if the workspace does not exist: "The source workspace missing does not exist" https://github.com/neos/neos-development-collection/pull/5270 + $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { return; } - if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { - return; - } } match ($eventInstance::class) { 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, diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature index d7c95053274..3d56189288c 100644 --- a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature @@ -65,7 +65,6 @@ Feature: Publish nodes partially without dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -114,7 +113,6 @@ Feature: Publish nodes partially without dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature index 1d039f580be..932602a17dd 100644 --- a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature @@ -71,7 +71,6 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -138,7 +137,6 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -184,11 +182,10 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"},{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | | asset-1 | sir-david-nodenborough | asset | live | {"language": "en"} | | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | - | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | \ No newline at end of file + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature new file mode 100644 index 00000000000..6c82bbeacbb --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature @@ -0,0 +1,68 @@ +@flowEntities @contentrepository +Feature: Test cases for partial publish to live and uri path generation + + Scenario: Create Document in another workspace and partially publish to live + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | example | source,peer,peerSpec | peerSpec->peer | + And using the following node types: + """yaml + 'Neos.Neos:Sites': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.Neos:Document': + properties: + uriPathSegment: + type: string + 'Neos.Neos:Site': + superTypes: + 'Neos.Neos:Document': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {"example":"source"} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.Neos:Sites" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "shernode-homes" | + | nodeTypeName | "Neos.Neos:Site" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | originDimensionSpacePoint | {"example":"source"} | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | baseWorkspaceName | "live" | + | workspaceTitle | "My Personal Workspace" | + | workspaceDescription | "" | + | newContentStreamId | "cs-myworkspace" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "justsomepage" | + | nodeTypeName | "Neos.Neos:Document" | + | parentNodeAggregateId | "shernode-homes" | + | originDimensionSpacePoint | {"example":"source"} | + | properties | {"uriPathSegment": "just"}| + | workspaceName | "myworkspace" | + And the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | nodesToPublish | [{"nodeAggregateId": "justsomepage", "dimensionSpacePoint": {"example":"source"}}] | + + Then I expect the documenturipath table to contain exactly: + # source: 65901ded4f068dac14ad0dce4f459b29 + # spec: 9a723c057afa02982dae9d0b541739be + # leafSpec: c60c44685475d0e2e4f2b964e6158ce2 + | dimensionspacepointhash | uripath | nodeaggregateidpath | nodeaggregateid | parentnodeaggregateid | precedingnodeaggregateid | succeedingnodeaggregateid | nodetypename | + | "2ca4fae2f65267c94c85602df0cbb728" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "fbe53ddc3305685fbb4dbf529f283a0e" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes" | "shernode-homes" | "lady-eleonode-rootford" | null | null | "Neos.Neos:Site" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes/justsomepage" | "justsomepage" | "shernode-homes" | null | null | "Neos.Neos:Document" |