diff --git a/.composer.json b/.composer.json index 1bf4b28f90c..e7f37b99667 100644 --- a/.composer.json +++ b/.composer.json @@ -25,8 +25,9 @@ "../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepository.Core/Tests/Unit", "../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepositoryRegistry/Tests/Unit" ], + "test:paratest-cli": "../../bin/paratest --debug -v --functional --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml", "test:parallel": [ - "FLOW_CONTEXT=Testing/Behat ../../bin/paratest --debug -v --functional --group parallel --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml Neos.ContentRepository.BehavioralTests/Tests/Functional/Feature/WorkspacePublication/WorkspaceWritingDuringPublication.php" + "for f in Neos.ContentRepository.BehavioralTests/Tests/Parallel/**/*Test.php; do composer test:paratest-cli $f; done" ], "test:behat-cli": "../../bin/behat -f progress --strict --no-interaction", "test:behavioral": [ diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index b273c36a402..b6b760811e6 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -49,6 +49,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\References; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtrees; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; @@ -304,7 +305,11 @@ public function findSubtree(NodeAggregateId $entryNodeAggregateId, FindSubtreeFi $this->dimensionSpacePoint, $this->visibilityConstraints ); - $subtree = new Subtree((int)$nodeData['level'], $node, array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? array_reverse($subtreesByParentNodeId[$nodeAggregateId]) : []); + $subtree = Subtree::create( + (int)$nodeData['level'], + $node, + array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? Subtrees::fromArray(array_reverse($subtreesByParentNodeId[$nodeAggregateId])) : Subtrees::createEmpty() + ); if ($subtree->level === 0) { return $subtree; } diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php index 3a8068b68dd..e991c81d29e 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/NodeFactory.php @@ -33,6 +33,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Reference; use Neos\ContentRepository\Core\Projection\ContentGraph\References; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\Subtrees; use Neos\ContentRepository\Core\Projection\ContentGraph\Timestamps; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; @@ -154,7 +155,11 @@ public function mapNodeRowsToSubtree( $nodeAggregateId = $nodeRow['nodeaggregateid']; $parentNodeAggregateId = $nodeRow['parentnodeaggregateid']; $node = $this->mapNodeRowToNode($nodeRow, $visibilityConstraints); - $subtree = new Subtree((int)$nodeRow['level'], $node, array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? array_reverse($subtreesByParentNodeId[$nodeAggregateId]) : []); + $subtree = Subtree::create( + (int)$nodeRow['level'], + $node, + array_key_exists($nodeAggregateId, $subtreesByParentNodeId) ? Subtrees::fromArray(array_reverse($subtreesByParentNodeId[$nodeAggregateId])) : Subtrees::createEmpty() + ); if ($subtree->level === 0) { return $subtree; } diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml index 039cd2925f8..2d0fe1e74c0 100644 --- a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml +++ b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml @@ -27,8 +27,8 @@ Neos: factoryObjectName: Neos\ContentRepository\TestSuite\Fakes\FakeNodeTypeManagerFactory contentDimensionSource: factoryObjectName: Neos\ContentRepository\TestSuite\Fakes\FakeContentDimensionSourceFactory - userIdProvider: - factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory + authProvider: + factoryObjectName: Neos\ContentRepository\TestSuite\Fakes\FakeAuthProviderFactory clock: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory propertyConverters: {} diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml index 32f24c1b128..be295835ab5 100644 --- a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml @@ -2,8 +2,8 @@ Neos: ContentRepositoryRegistry: presets: default: - userIdProvider: - factoryObjectName: 'Neos\ContentRepository\TestSuite\Fakes\FakeUserIdProviderFactory' + authProvider: + factoryObjectName: Neos\ContentRepository\TestSuite\Fakes\FakeAuthProviderFactory clock: factoryObjectName: 'Neos\ContentRepository\TestSuite\Fakes\FakeClockFactory' nodeTypeManager: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/03-CreateNodeAggregateWithNode_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/03-CreateNodeAggregateWithNode_WithoutDimensions.feature index 076f8d72e2e..f8376aa7708 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/03-CreateNodeAggregateWithNode_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/03-CreateNodeAggregateWithNode_WithoutDimensions.feature @@ -191,9 +191,9 @@ Feature: Create node aggregate with node 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" | + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | And I am in workspace "live" And I am in dimension space point {} And I am user identified by "initiating-user-identifier" @@ -202,14 +202,14 @@ Feature: Create node aggregate with node | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - Given the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + Given the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:NodeWithoutTetheredChildNodes" | | originDimensionSpacePoint | {} | | parentNodeAggregateId | "lady-eleonode-rootford" | | nodeName | "node" | - And the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-nodeward-nodington-iii" | | nodeTypeName | "Neos.ContentRepository.Testing:NodeWithoutTetheredChildNodes" | @@ -280,7 +280,7 @@ Feature: Create node aggregate with node | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:NodeWithTetheredChildNodes" | @@ -459,7 +459,7 @@ Feature: Create node aggregate with node | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:NodeWithTetheredChildNodes" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature index 91c845e2b27..db7221eb28d 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature @@ -53,6 +53,7 @@ Feature: Constraint checks on node aggregate disabling | nodeAggregateId | "i-do-not-exist" | | nodeVariantSelectionStrategy | "allVariants" | | tag | "disabled" | + Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" Scenario: Try to disable an already disabled node aggregate Given the command DisableNodeAggregate is executed with payload: @@ -61,19 +62,12 @@ Feature: Constraint checks on node aggregate disabling | coveredDimensionSpacePoint | {"language": "de"} | | nodeVariantSelectionStrategy | "allVariants" | - # Note: The behavior has been changed with https://github.com/neos/neos-development-collection/pull/4284 and the test was adjusted accordingly - When the command DisableNodeAggregate is executed with payload: + When the command DisableNodeAggregate is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | coveredDimensionSpacePoint | {"language": "de"} | | nodeVariantSelectionStrategy | "allVariants" | - Then I expect exactly 4 events to be published on stream with prefix "ContentStream:cs-identifier" - And event at index 3 is of type "SubtreeWasTagged" with payload: - | Key | Expected | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "sir-david-nodenborough" | - | affectedDimensionSpacePoints | [{"language":"de"},{"language":"gsw"}] | - | tag | "disabled" | + Then the last command should have thrown an exception of type "NodeAggregateIsAlreadyDisabled" Scenario: Try to disable a node aggregate in a non-existing dimension space point diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature index 9146edf8d64..60e96e01088 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature @@ -108,7 +108,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature index ebfef8c33d5..0982df99e65 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature @@ -120,7 +120,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -148,7 +148,7 @@ Feature: Disable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -195,7 +195,7 @@ Feature: Disable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -224,7 +224,7 @@ Feature: Disable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -253,7 +253,7 @@ Feature: Disable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -371,7 +371,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -399,7 +399,7 @@ Feature: Disable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -428,7 +428,7 @@ Feature: Disable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -457,7 +457,7 @@ Feature: Disable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -486,7 +486,7 @@ Feature: Disable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/04-EnableNodeAggregate_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/04-EnableNodeAggregate_ConstraintChecks.feature index d50f8cea106..0c4e9a2ba1f 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/04-EnableNodeAggregate_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/04-EnableNodeAggregate_ConstraintChecks.feature @@ -44,13 +44,12 @@ Feature: Enable a node aggregate | nodeVariantSelectionStrategy | "allVariants" | Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" - # Note: The behavior has been changed with https://github.com/neos/neos-development-collection/pull/4284 and the test was adjusted accordingly Scenario: Try to enable an already enabled node aggregate - When the command EnableNodeAggregate is executed with payload: + When the command EnableNodeAggregate is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeVariantSelectionStrategy | "allVariants" | - Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-identifier" + Then the last command should have thrown an exception of type "NodeAggregateIsAlreadyEnabled" Scenario: Try to enable a node aggregate in a non-existing dimension space point When the command EnableNodeAggregate is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature index 1004173e4b4..a9befb5e349 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature @@ -68,7 +68,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -143,7 +143,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [{}] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -218,7 +218,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature index 929293a9a61..cae60c5dedf 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature @@ -143,7 +143,7 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -193,7 +193,7 @@ Feature: Enable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -223,7 +223,7 @@ Feature: Enable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -274,7 +274,7 @@ Feature: Enable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -325,7 +325,7 @@ Feature: Enable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -438,7 +438,7 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -488,7 +488,7 @@ Feature: Enable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -538,7 +538,7 @@ Feature: Enable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -589,7 +589,7 @@ Feature: Enable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -640,7 +640,7 @@ Feature: Enable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -716,25 +716,25 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature index 0ae1ae2cdae..c3b0ef4813e 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature @@ -35,7 +35,7 @@ Feature: Creation of nodes underneath disabled nodes | nodeAggregateId | "the-great-nodini" | | sourceOrigin | {"language":"mul"} | | targetOrigin | {"language":"ltz"} | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Scenario: Create a new node with parent disabled with strategy allSpecializations Given the command DisableNodeAggregate is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature index be26f2395e0..be6c01a921c 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature @@ -24,7 +24,7 @@ Feature: Variation of hidden nodes | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Scenario: Specialize a node where the specialization target is enabled Given I am in dimension space point {"language":"de"} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature index 780ef1d28ad..de97506aa49 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature @@ -75,7 +75,7 @@ Feature: On forking a content stream, hidden nodes should be correctly copied as | 1 | the-great-nodini | | 2 | nodingers-cat | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node user-cs-identifier;lady-eleonode-rootford;{} And I expect this node to have no child nodes And the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature index 3f6180cc362..8387081b091 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature @@ -142,7 +142,7 @@ Feature: Add Dimension Specialization Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # we change the dimension configuration When I change the content dimensions in content repository "default" to: @@ -166,14 +166,14 @@ Feature: Add Dimension Specialization Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # The visibility edges were modified When I am in workspace "migration-workspace" and dimension space point {"language": "ch"} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" When I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 0 errors diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddNewProperty_NoDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddNewProperty_NoDimensions.feature index 08f35b9c556..0f6d9270e71 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddNewProperty_NoDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddNewProperty_NoDimensions.feature @@ -13,6 +13,8 @@ Feature: Add New Property properties: text: type: string + dateTime: + type: DateTime """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -64,7 +66,7 @@ Feature: Add New Property - type: 'AddNewProperty' settings: - newPropertyName: 'aDateOutsideSchema' + newPropertyName: 'dateTime' serializedValue: '2013-09-09T12:04:12+00:00' type: 'DateTime' """ @@ -82,6 +84,46 @@ Feature: Add New Property | text | "Original text" | Then I expect a node identified by migration-cs;other;{} to exist in the content graph And I expect this node to have the following properties: - | Key | Value | - | text | "fixed value" | - | aDateOutsideSchema | Date:2013-09-09T12:04:12+00:00 | + | Key | Value | + | text | "fixed value" | + | dateTime | Date:2013-09-09T12:04:12+00:00 | + + Scenario: Adding a property that is not defined in the node type schema + When I run the following node migration for workspace "live", creating target workspace "migration-workspace" on contentStreamId "migration-cs" and exceptions are caught: + """yaml + migration: + - + filters: + - + type: 'NodeType' + settings: + nodeType: 'Neos.ContentRepository.Testing:Document' + transformations: + - + type: 'AddNewProperty' + settings: + newPropertyName: 'aDateOutsideSchema' + serializedValue: '2013-09-09T12:04:12+00:00' + type: 'DateTime' + """ + Then the last command should have thrown an exception of type "PropertyCannotBeSet" + + Scenario: Adding a property with a different type than defined by the node type schema + When I run the following node migration for workspace "live", creating target workspace "migration-workspace" on contentStreamId "migration-cs" and exceptions are caught: + """yaml + migration: + - + filters: + - + type: 'NodeType' + settings: + nodeType: 'Neos.ContentRepository.Testing:Document' + transformations: + - + type: 'AddNewProperty' + settings: + newPropertyName: 'dateTime' + serializedValue: '2013-09-09T12:04:12+00:00' + type: 'string' + """ + Then the last command should have thrown an exception of type "PropertyCannotBeSet" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature index f9a70788f8b..53dfbb58e86 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature @@ -94,7 +94,7 @@ Feature: Move dimension space point Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # we change the dimension configuration When I change the content dimensions in content repository "default" to: @@ -118,14 +118,14 @@ Feature: Move dimension space point Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # The visibility edges were modified When I am in workspace "migration-workspace" and dimension space point {"language": "de_DE"} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by migration-cs;sir-david-nodenborough;{"language": "de_DE"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" When I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 0 errors diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature deleted file mode 100644 index c8c036f9ed2..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature +++ /dev/null @@ -1,90 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Copy nodes (without dimensions) - - Background: - Given using no content dimensions - And using the following node types: - """yaml - 'Neos.ContentRepository.Testing:Document': - references: - ref: [] - """ - And using identifier "default", I define a content repository - And I am in content repository "default" - And the command CreateRootWorkspace is executed with payload: - | Key | Value | - | workspaceName | "live" | - | newContentStreamId | "cs-identifier" | - And I am in workspace "live" - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "lady-eleonode-rootford" | - | nodeTypeName | "Neos.ContentRepository:Root" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "sir-david-nodenborough" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "document" | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "sir-david-nodenborough" | - | nodeName | "child-document" | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: - | Key | Value | - | workspaceName | "live" | - | contentStreamId | "cs-identifier" | - | nodeAggregateId | "sir-nodeward-nodington-iii" | - | nodeTypeName | "Neos.ContentRepository.Testing:Document" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "esquire" | - | nodeAggregateClassification | "regular" | - - Scenario: Copy - When I am in workspace "live" and dimension space point {} - # node to copy (currentNode): "sir-nodeward-nodington-iii" - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} - When the command CopyNodesRecursively is executed, copying the current node aggregate with payload: - | Key | Value | - | targetDimensionSpacePoint | {} | - | targetParentNodeAggregateId | "nody-mc-nodeface" | - | targetNodeName | "target-nn" | - | targetSucceedingSiblingnodeAggregateId | null | - | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | - - Then I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} - - Scenario: Copy References - When I am in workspace "live" and dimension space point {} - And the command SetNodeReferences is executed with payload: - | Key | Value | - | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | - | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | - - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} - And the command CopyNodesRecursively is executed, copying the current node aggregate with payload: - | Key | Value | - | targetDimensionSpacePoint | {} | - | targetParentNodeAggregateId | "nody-mc-nodeface" | - | targetNodeName | "target-nn" | - | targetSucceedingSiblingnodeAggregateId | null | - | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | - - And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} - And I expect this node to have the following references: - | Name | Node | Properties | - | ref | cs-identifier;sir-david-nodenborough;{} | null | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature index 8652a6a8252..f1b0d9b8f41 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature @@ -50,7 +50,7 @@ Feature: Disable a node aggregate | affectedOccupiedDimensionSpacePoints | [{}] | | affectedCoveredDimensionSpacePoints | [{}] | - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | @@ -70,7 +70,7 @@ Feature: Disable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be: | Level | nodeAggregateId | | 0 | lady-eleonode-rootford | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateWithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateWithDimensions.feature index 96e0ecaafb7..742b38d69b6 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateWithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateWithDimensions.feature @@ -24,7 +24,7 @@ Feature: Remove NodeAggregate | nodeTypeName | "Neos.ContentRepository:Root" | # We have to add another node since root nodes are in all dimension space points and thus cannot be varied # Node /document - And the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | @@ -32,7 +32,7 @@ Feature: Remove NodeAggregate | nodeName | "document" | # We also want to add a child node to make sure it is correctly removed when the parent is removed # Node /document/child-document - And the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "nodimus-prime" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/DimensionMismatch.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/DimensionMismatch.feature index 6f10381a3cb..23347f92229 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/DimensionMismatch.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/DimensionMismatch.feature @@ -30,7 +30,7 @@ Feature: Dimension mismatch Scenario: Generalization detection # Node /document - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/Properties.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/Properties.feature index bd29ba2e76e..aaf81876ec4 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/Properties.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/Properties.feature @@ -28,7 +28,7 @@ Feature: Properties | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | # Node /document - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/TetheredNodesReordering.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/TetheredNodesReordering.feature index bb4974d7175..197a15e4989 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/TetheredNodesReordering.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/StructureAdjustment/TetheredNodesReordering.feature @@ -28,7 +28,7 @@ Feature: Tethered Nodes Reordering Structure changes | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Document" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/SubtreeTagging/TagSubtree_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/SubtreeTagging/TagSubtree_WithoutDimensions.feature index 82b5aa3dc73..6a75a86fe61 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/SubtreeTagging/TagSubtree_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/SubtreeTagging/TagSubtree_WithoutDimensions.feature @@ -37,7 +37,7 @@ Feature: Tag subtree without dimensions | b | Neos.ContentRepository.Testing:Document | root | b | | b1 | Neos.ContentRepository.Testing:Document | b | b1 | - Scenario: Tagging the same node twice with the same subtree tag is ignored + Scenario: Tagging the same node twice with the same subtree tag When the command TagSubtree is executed with payload: | Key | Value | | nodeAggregateId | "a1" | @@ -50,23 +50,23 @@ Feature: Tag subtree without dimensions | nodeAggregateId | "a1" | | affectedDimensionSpacePoints | [[]] | | tag | "tag1" | - When the command TagSubtree is executed with payload: + When the command TagSubtree is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "a1" | | nodeVariantSelectionStrategy | "allVariants" | | tag | "tag1" | - Then I expect exactly 14 events to be published on stream with prefix "ContentStream:cs-identifier" + Then the last command should have thrown an exception of type "SubtreeIsAlreadyTagged" - Scenario: Untagging a node without tags is ignored + Scenario: Untagging a node without tags Then I expect exactly 13 events to be published on stream with prefix "ContentStream:cs-identifier" - When the command UntagSubtree is executed with payload: + When the command UntagSubtree is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "a1" | | nodeVariantSelectionStrategy | "allVariants" | | tag | "tag1" | - Then I expect exactly 13 events to be published on stream with prefix "ContentStream:cs-identifier" + Then the last command should have thrown an exception of type "SubtreeIsNotTagged" - Scenario: Untagging a node that is only implicitly tagged (inherited) is ignored + Scenario: Untagging a node that is only implicitly tagged (inherited) When the command TagSubtree is executed with payload: | Key | Value | | nodeAggregateId | "a1" | @@ -79,12 +79,12 @@ Feature: Tag subtree without dimensions | nodeAggregateId | "a1" | | affectedDimensionSpacePoints | [[]] | | tag | "tag1" | - When the command UntagSubtree is executed with payload: + When the command UntagSubtree is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "a1a" | | nodeVariantSelectionStrategy | "allVariants" | | tag | "tag1" | - Then I expect exactly 14 events to be published on stream with prefix "ContentStream:cs-identifier" + Then the last command should have thrown an exception of type "SubtreeIsNotTagged" Scenario: Tagging subtree with arbitrary strategy since dimensions are not involved When the command TagSubtree is executed with payload: 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 7c5c5a7c002..bb15c251e28 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 @@ -73,8 +73,8 @@ Feature: Workspace discarding - complex chained functionality | 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 the WorkspaceRebaseFailed exception with: - | SequenceNumber | Command | Exception | - | 11 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | + | SequenceNumber | Event | Exception | + | 11 | NodeGeneralizationVariantWasCreated | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command DiscardWorkspace is executed with payload: | Key | Value | 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 39eca525c82..33bc813c32a 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 @@ -61,13 +61,12 @@ Feature: Rebasing auto-created nodes works And I expect this node to be a child of node user-cs-identifier;nody-mc-nodeface;{} # - then, for the auto-created child node, set a property. - When the command "SetSerializedNodeProperties" is executed with payload: - | Key | Value | - | workspaceName | "user-test" | - | nodeAggregateId | $this->currentNodeAggregateId | - | originDimensionSpacePoint | {} | - | propertyValues | {"text": {"value":"Modified","type":"string"}} | - | propertiesToUnset | {} | + When the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified"} | + | propertiesToUnset | {} | # ensure that live is outdated so the rebase is required: When the command CreateNodeAggregateWithNode is executed with payload: @@ -80,8 +79,8 @@ Feature: Rebasing auto-created nodes works # 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/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature index bbd1de5307c..7f30b4c68af 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature @@ -101,8 +101,8 @@ Feature: Workspace rebasing - conflicting changes Then I expect the content stream "user-cs-two" to exist Then I expect the content stream "user-cs-two-rebased" to not exist Then the last command should have thrown the WorkspaceRebaseFailed exception with: - | SequenceNumber | Command | Exception | - | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | SequenceNumber | Event | Exception | + | 13 | NodePropertiesWereSet | NodeAggregateCurrentlyDoesNotExist | When the command RebaseWorkspace is executed with payload: | Key | Value | @@ -169,9 +169,9 @@ Feature: Workspace rebasing - conflicting changes Then I expect the content stream "user-cs-identifier" to exist Then I expect the content stream "user-cs-identifier-rebased" to not exist Then the last command should have thrown the WorkspaceRebaseFailed exception with: - | SequenceNumber | Command | Exception | - | 12 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | - | 14 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | SequenceNumber | Event | Exception | + | 12 | NodePropertiesWereSet | NodeAggregateCurrentlyDoesNotExist | + | 14 | NodePropertiesWereSet | NodeAggregateCurrentlyDoesNotExist | When the command RebaseWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature index ac52e28289b..60bf7aadc2b 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 @@ -83,9 +83,9 @@ Feature: Workspace publication - complex chained functionality | 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 | + | SequenceNumber | Event | Exception | + | 13 | NodePropertiesWereSet | NodeAggregateCurrentlyDoesNotExist | + | 14 | NodePropertiesWereSet | 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: @@ -107,8 +107,8 @@ Feature: Workspace publication - complex chained functionality | nodesToPublish | [{"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "nody-mc-nodeface"}] | | newContentStreamId | "user-cs-id-rebased" | Then the last command should have thrown the WorkspaceRebaseFailed exception with: - | SequenceNumber | Command | Exception | - | 13 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | + | SequenceNumber | Event | Exception | + | 13 | NodeGeneralizationVariantWasCreated | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command PublishWorkspace is executed with payload: | Key | Value | 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 398d368f0a5..68a71a14266 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 @@ -182,7 +182,7 @@ Feature: Publishing individual nodes (basics) | 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 + Scenario: Tag the same node in live and in the user workspace so that a rebase will lead to a conflict When the command TagSubtree is executed with payload: | Key | Value | | workspaceName | "live" | @@ -195,20 +195,14 @@ Feature: Publishing individual nodes (basics) | nodeAggregateId | "sir-unchanged" | | nodeVariantSelectionStrategy | "allVariants" | | tag | "tag1" | - When the command PublishIndividualNodesFromWorkspace is executed with payload: + When the command PublishIndividualNodesFromWorkspace is executed with payload and exceptions are caught: | 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 + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Event | Exception | + | 14 | SubtreeWasTagged | SubtreeIsAlreadyTagged | Scenario: It is possible to publish all nodes When the command PublishIndividualNodesFromWorkspace is executed with payload: 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 a5e32169667..37de2e73450 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 @@ -346,7 +346,7 @@ Feature: Publishing hide/show scenario of nodes | newContentStreamId | "user-cs-identifier" | # SETUP: set two new nodes in USER workspace - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodeAggregateId | "new1-agg" | @@ -354,7 +354,7 @@ Feature: Publishing hide/show scenario of nodes | originDimensionSpacePoint | {} | | parentNodeAggregateId | "lady-eleonode-rootford" | | nodeName | "foo" | - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodeAggregateId | "new2-agg" | 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 0e180f7d0dd..158cd4dabb5 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 @@ -139,8 +139,8 @@ Feature: Workspace discarding - basic functionality | workspaceName | "user-ws-two" | | rebasedContentStreamId | "user-cs-two-rebased" | Then the last command should have thrown the WorkspaceRebaseFailed exception with: - | SequenceNumber | Command | Exception | - | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | SequenceNumber | Event | Exception | + | 13 | NodePropertiesWereSet | NodeAggregateCurrentlyDoesNotExist | Then workspace user-ws-two has status OUTDATED diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Parallel/AbstractParallelTestCase.php b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/AbstractParallelTestCase.php index 67afbdc91ab..569609ee5ce 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Parallel/AbstractParallelTestCase.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/AbstractParallelTestCase.php @@ -52,14 +52,15 @@ final protected function awaitFile(string $filename): void } } - final protected function awaitSharedLock($resource, int $maximumCycles = 2000): void + final protected function awaitFileRemoval(string $filename): void { $waiting = 0; - while (!flock($resource, LOCK_SH)) { - usleep(10000); + while (!is_file($filename)) { + usleep(1000); $waiting++; - if ($waiting > $maximumCycles) { - throw new \Exception('timeout while waiting on shared lock'); + clearstatcache(true, $filename); + if ($waiting > 60000) { + throw new \Exception('timeout while waiting on file ' . $filename); } } } @@ -82,6 +83,11 @@ final protected function setUpContentRepository( final protected function log(string $message): void { - file_put_contents(self::LOGGING_PATH, substr($this::class, strrpos($this::class, '\\') + 1) . ': ' . getmypid() . ': ' . $message . PHP_EOL, FILE_APPEND); + file_put_contents(self::LOGGING_PATH, self::shortClassName($this::class) . ': ' . getmypid() . ': ' . $message . PHP_EOL, FILE_APPEND); + } + + final protected static function shortClassName(string $className): string + { + return substr($className, strrpos($className, '\\') + 1); } } diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspacePublicationDuringWriting/WorkspacePublicationDuringWritingTest.php b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspacePublicationDuringWriting/WorkspacePublicationDuringWritingTest.php new file mode 100644 index 00000000000..d96a9adddf0 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspacePublicationDuringWriting/WorkspacePublicationDuringWritingTest.php @@ -0,0 +1,261 @@ +log('------ process started ------'); + // todo refrain from Gherkin naming here and make fakes easier to use: https://github.com/neos/neos-development-collection/pull/5346 + GherkinTableNodeBasedContentDimensionSourceFactory::$contentDimensionsToUse = new class implements ContentDimensionSourceInterface + { + public function getDimension(ContentDimensionId $dimensionId): ?ContentDimension + { + return null; + } + public function getContentDimensionsOrderedByPriority(): array + { + return []; + } + }; + // todo refrain from Gherkin naming here and make fakes easier to use: https://github.com/neos/neos-development-collection/pull/5346 + GherkinPyStringNodeBasedNodeTypeManagerFactory::$nodeTypesToUse = new NodeTypeManager( + fn (): array => [ + 'Neos.ContentRepository:Root' => [], + 'Neos.ContentRepository.Testing:Document' => [ + 'properties' => [ + 'title' => [ + 'type' => 'string' + ] + ] + ] + ] + ); + + $setupLockResource = fopen(self::SETUP_LOCK_PATH, 'w+'); + + $exclusiveNonBlockingLockResult = flock($setupLockResource, LOCK_EX | LOCK_NB); + if ($exclusiveNonBlockingLockResult === false) { + $this->log('waiting for setup'); + if (!flock($setupLockResource, LOCK_SH)) { + throw new \RuntimeException('failed to acquire blocking shared lock'); + } + $this->contentRepository = $this->contentRepositoryRegistry + ->get(ContentRepositoryId::fromString('test_parallel')); + $this->log('wait for setup finished'); + return; + } + + $this->log('setup started'); + $contentRepository = $this->setUpContentRepository(ContentRepositoryId::fromString('test_parallel')); + + $origin = OriginDimensionSpacePoint::createWithoutDimensions(); + $contentRepository->handle(CreateRootWorkspace::create( + WorkspaceName::forLive(), + ContentStreamId::fromString('live-cs-id') + )); + $contentRepository->handle(CreateRootNodeAggregateWithNode::create( + WorkspaceName::forLive(), + NodeAggregateId::fromString('lady-eleonode-rootford'), + NodeTypeName::fromString(NodeTypeName::ROOT_NODE_TYPE_NAME) + )); + $contentRepository->handle(CreateNodeAggregateWithNode::create( + WorkspaceName::forLive(), + NodeAggregateId::fromString('nody-mc-nodeface'), + NodeTypeName::fromString('Neos.ContentRepository.Testing:Document'), + $origin, + NodeAggregateId::fromString('lady-eleonode-rootford'), + initialPropertyValues: PropertyValuesToWrite::fromArray([ + 'title' => 'title-original' + ]) + )); + $contentRepository->handle(CreateWorkspace::create( + WorkspaceName::fromString('user-test'), + WorkspaceName::forLive(), + ContentStreamId::fromString('user-cs-id') + )); + for ($i = 0; $i <= 5000; $i++) { + $contentRepository->handle(CreateNodeAggregateWithNode::create( + WorkspaceName::fromString('user-test'), + NodeAggregateId::fromString('nody-mc-nodeface-' . $i), + NodeTypeName::fromString('Neos.ContentRepository.Testing:Document'), + $origin, + NodeAggregateId::fromString('lady-eleonode-rootford'), + initialPropertyValues: PropertyValuesToWrite::fromArray([ + 'title' => 'title' + ]) + )); + } + $this->contentRepository = $contentRepository; + + if (!flock($setupLockResource, LOCK_UN)) { + throw new \RuntimeException('failed to release setup lock'); + } + + $this->log('setup finished'); + } + + /** + * @test + * @group parallel + */ + public function whileANodesArWrittenOnLive(): void + { + $this->log('writing started'); + + touch(self::WRITING_IS_RUNNING_FLAG_PATH); + + try { + for ($i = 0; $i <= 50; $i++) { + $this->contentRepository->handle( + SetNodeProperties::create( + WorkspaceName::forLive(), + NodeAggregateId::fromString('nody-mc-nodeface'), + OriginDimensionSpacePoint::createWithoutDimensions(), + PropertyValuesToWrite::fromArray([ + 'title' => 'changed-title-' . $i + ]) + ) + ); + } + } finally { + unlink(self::WRITING_IS_RUNNING_FLAG_PATH); + } + + $this->log('writing finished'); + Assert::assertTrue(true, 'No exception was thrown ;)'); + } + + /** + * @test + * @group parallel + */ + public function thenConcurrentPublishLeadsToException(): void + { + if (!is_file(self::WRITING_IS_RUNNING_FLAG_PATH)) { + $this->log('waiting to publish'); + + $this->awaitFile(self::WRITING_IS_RUNNING_FLAG_PATH); + // If write is the process that does the (slowish) setup, and then waits for the rebase to start, + // We give the CR some time to close the content stream + // TODO find another way than to randomly wait!!! + // The problem is, if we dont sleep it happens often that the modification works only then the rebase is startet _really_ + // Doing the modification several times in hope that the second one fails will likely just stop the rebase thread as it cannot close + usleep(10000); + } + + $this->log('publish started'); + + + /* + // NOTE, can also be tested with PartialPublish, or PartialPublish leading to a full publish, but this test only allows one at time :) + + $nodesForAFullPublish = 5000; + $nodesForAPartialPublish = $nodesForAFullPublish - 1; + + $nodeIdToPublish = []; + for ($i = 0; $i <= $nodesForAPartialPublish; $i++) { + $nodeIdToPublish[] = new NodeIdToPublishOrDiscard( + NodeAggregateId::fromString('nody-mc-nodeface-' . $i), // see nodes created above + DimensionSpacePoint::createWithoutDimensions() + ); + } + + $this->contentRepository->handle(PublishIndividualNodesFromWorkspace::create( + WorkspaceName::fromString('user-test'), + NodeIdsToPublishOrDiscard::create(...$nodeIdToPublish) + )); + */ + + $actualException = null; + try { + $this->contentRepository->handle(PublishWorkspace::create( + WorkspaceName::fromString('user-test') + )); + } catch (\Exception $thrownException) { + $actualException = $thrownException; + $this->log(sprintf('Got exception %s: %s', self::shortClassName($actualException::class), $actualException->getMessage())); + } + + $this->log('publish finished'); + + if ($actualException === null) { + Assert::fail(sprintf('No exception was thrown')); + } + + Assert::assertInstanceOf(ConcurrencyException::class, $actualException); + + $this->awaitFileRemoval(self::WRITING_IS_RUNNING_FLAG_PATH); + + // writing to user works!!! + try { + $this->contentRepository->handle( + SetNodeProperties::create( + WorkspaceName::fromString('user-test'), + NodeAggregateId::fromString('nody-mc-nodeface'), + OriginDimensionSpacePoint::createWithoutDimensions(), + PropertyValuesToWrite::fromArray([ + 'title' => 'written-after-failed-publish' + ]) + ) + ); + } catch (ContentStreamIsClosed $exception) { + Assert::fail(sprintf('Workspace that failed to be publish cannot be written: %s', $exception->getMessage())); + } + + $node = $this->contentRepository->getContentGraph(WorkspaceName::fromString('user-test')) + ->getSubgraph(DimensionSpacePoint::createWithoutDimensions(), VisibilityConstraints::withoutRestrictions()) + ->findNodeById(NodeAggregateId::fromString('nody-mc-nodeface')); + + Assert::assertSame('written-after-failed-publish', $node?->getProperty('title')); + } +} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebase.php b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebaseTest.php similarity index 91% rename from Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebase.php rename to Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebaseTest.php index 5a4feaabb46..f4a37360ed1 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebase.php +++ b/Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebaseTest.php @@ -39,7 +39,7 @@ use Neos\Flow\ObjectManagement\ObjectManagerInterface; use PHPUnit\Framework\Assert; -class WorkspaceWritingDuringRebase extends AbstractParallelTestCase +class WorkspaceWritingDuringRebaseTest extends AbstractParallelTestCase { private const SETUP_LOCK_PATH = __DIR__ . '/setup-lock'; @@ -70,7 +70,9 @@ public function setUp(): void $exclusiveNonBlockingLockResult = flock($setupLockResource, LOCK_EX | LOCK_NB); if ($exclusiveNonBlockingLockResult === false) { $this->log('waiting for setup'); - $this->awaitSharedLock($setupLockResource); + if (!flock($setupLockResource, LOCK_SH)) { + throw new \RuntimeException('failed to acquire blocking shared lock'); + } $this->contentRepository = $this->contentRepositoryRegistry ->get(ContentRepositoryId::fromString('test_parallel')); $this->log('wait for setup finished'); @@ -140,7 +142,7 @@ public function whileAWorkspaceIsBeingRebased(): void try { $this->contentRepository->handle( RebaseWorkspace::create($workspaceName) - ->withRebasedContentStreamId(ContentStreamId::create()) + ->withRebasedContentStreamId(ContentStreamId::fromString('user-cs-rebased')) ->withErrorHandlingStrategy(RebaseErrorHandlingStrategy::STRATEGY_FORCE)); } finally { unlink(self::REBASE_IS_RUNNING_FLAG_PATH); @@ -170,6 +172,11 @@ public function thenConcurrentCommandsLeadToAnException(): void $this->log('write started'); + $workspaceDuringRebase = $this->contentRepository->getContentGraph(WorkspaceName::fromString('user-test')); + Assert::assertSame('user-cs-id', $workspaceDuringRebase->getContentStreamId()->value, + 'The parallel tests expects the workspace to still point to the original cs.' + ); + $origin = OriginDimensionSpacePoint::createWithoutDimensions(); $actualException = null; try { @@ -183,6 +190,7 @@ public function thenConcurrentCommandsLeadToAnException(): void )); } catch (\Exception $thrownException) { $actualException = $thrownException; + $this->log(sprintf('Got exception %s: %s', self::shortClassName($actualException::class), $actualException->getMessage())); } $this->log('write finished'); @@ -197,7 +205,7 @@ public function thenConcurrentCommandsLeadToAnException(): void Assert::assertThat($actualException, self::logicalOr( self::isInstanceOf(ContentStreamIsClosed::class), - self::isInstanceOf(ConcurrencyException::class), + self::isInstanceOf(ConcurrencyException::class), // todo is only thrown theoretical? but not during tests here ... )); Assert::assertSame('title-original', $node?->getProperty('title')); diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php index 92673a00c82..5c9a2e6fa7f 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php @@ -6,6 +6,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; /** * Implementation Detail of {@see ContentRepository::handle}, which does the command dispatching to the different @@ -29,9 +30,12 @@ public function __construct( } /** + * The handler only calculate which events they want to have published, + * but do not do the publishing themselves + * * @return EventsToPublish|\Generator */ - public function handle(CommandInterface $command): EventsToPublish|\Generator + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command): EventsToPublish|\Generator { // multiple handlers must not handle the same command foreach ($this->handlers as $handler) { diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php index b36d5d3ab75..93cba7111ef 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\Core\CommandHandler; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; /** * Common interface for all Content Repository command handlers @@ -15,7 +16,7 @@ */ interface CommandHandlerInterface { - public function canHandle(CommandInterface $command): bool; + public function canHandle(CommandInterface|RebasableToOtherWorkspaceInterface $command): bool; /** * "simple" command handlers return EventsToPublish directly @@ -25,5 +26,5 @@ public function canHandle(CommandInterface $command): bool; * * @return EventsToPublish|\Generator */ - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator; + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator; } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php index 9e726cacea1..629f5c01c1e 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; +use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; @@ -48,11 +49,17 @@ public function contentStreamExists(ContentStreamId $contentStreamId): bool return $this->contentGraphReadModel->findContentStreamById($contentStreamId) !== null; } + /** + * @throws ContentStreamDoesNotExistYet if there is no matching content stream + */ public function isContentStreamClosed(ContentStreamId $contentStreamId): bool { $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); if ($contentStream === null) { - throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1729863973); + throw new ContentStreamDoesNotExistYet( + 'Content stream "' . $contentStreamId->value . '" does not exist.', + 1521386692 + ); } return $contentStream->isClosed; } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHookInterface.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHookInterface.php new file mode 100644 index 00000000000..2afb7b2ea3f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHookInterface.php @@ -0,0 +1,25 @@ + + * @api + */ +final readonly class CommandHooks implements CommandHookInterface, \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $commandHooks; + + private function __construct( + CommandHookInterface ...$commandHooks + ) { + $this->commandHooks = $commandHooks; + } + + /** + * @param array $commandHooks + */ + public static function fromArray(array $commandHooks): self + { + return new self(...$commandHooks); + } + + public static function none(): self + { + return new self(); + } + + public function getIterator(): \Traversable + { + yield from $this->commandHooks; + } + + public function count(): int + { + return count($this->commandHooks); + } + + public function onBeforeHandle(CommandInterface $command): CommandInterface + { + foreach ($this->commandHooks as $commandHook) { + $command = $commandHook->onBeforeHandle($command); + } + return $command; + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandInterface.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandInterface.php index 8264248a6f9..4e1fe2e45d4 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandInterface.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandInterface.php @@ -4,11 +4,20 @@ namespace Neos\ContentRepository\Core\CommandHandler; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; + /** - * Common (marker) interface for all commands of the Content Repository + * Common interface for all commands of the content repository + * + * Note: Some commands also implement the {@see RebasableToOtherWorkspaceInterface} + * others are converted to the rebasable counter-part at command handling time, serializing their state to make them deterministic * - * @internal because extra commands are no extension point + * @internal sealed interface. Custom commands cannot be handled and are no extension point! */ interface CommandInterface { + /** + * @param array $array + */ + public static function fromArray(array $array): self; } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php index ee3ee2de52c..9becebc5a2d 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php @@ -9,8 +9,8 @@ use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\RebaseableCommand; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandThatFailedDuringRebase; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\ConflictingEvents; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\ConflictingEvent; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\EventStore\Helper\InMemoryEventStore; @@ -45,7 +45,7 @@ */ final class CommandSimulator { - private CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase; + private ConflictingEvents $conflictingEvents; private readonly InMemoryEventStore $inMemoryEventStore; @@ -56,7 +56,7 @@ public function __construct( private readonly WorkspaceName $workspaceNameToSimulateIn, ) { $this->inMemoryEventStore = new InMemoryEventStore(); - $this->commandsThatFailedDuringRebase = new CommandsThatFailedDuringRebase(); + $this->conflictingEvents = new ConflictingEvents(); } /** @@ -86,9 +86,11 @@ private function handle(RebaseableCommand $rebaseableCommand): void try { $eventsToPublish = $this->commandBus->handle($commandInWorkspace); } catch (\Exception $exception) { - $this->commandsThatFailedDuringRebase = $this->commandsThatFailedDuringRebase->withAppended( - new CommandThatFailedDuringRebase( - $rebaseableCommand->originalCommand, + $originalEvent = $this->eventNormalizer->denormalize($rebaseableCommand->originalEvent); + + $this->conflictingEvents = $this->conflictingEvents->withAppended( + new ConflictingEvent( + $originalEvent, $exception, $rebaseableCommand->originalSequenceNumber ) @@ -101,10 +103,6 @@ private function handle(RebaseableCommand $rebaseableCommand): void 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 @@ -159,13 +157,13 @@ public function eventStream(): EventStreamInterface return $this->inMemoryEventStore->load(VirtualStreamName::all()); } - public function hasCommandsThatFailed(): bool + public function hasConflicts(): bool { - return !$this->commandsThatFailedDuringRebase->isEmpty(); + return !$this->conflictingEvents->isEmpty(); } - public function getCommandsThatFailed(): CommandsThatFailedDuringRebase + public function getConflictingEvents(): ConflictingEvents { - return $this->commandsThatFailedDuringRebase; + return $this->conflictingEvents; } } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php b/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php new file mode 100644 index 00000000000..c76eb1955e7 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/Commands.php @@ -0,0 +1,62 @@ +handle($command); + * } + * + * @implements \IteratorAggregate + */ +final readonly class Commands implements \IteratorAggregate, \Countable +{ + /** @var array */ + private array $items; + + private function __construct( + CommandInterface ...$items + ) { + $this->items = array_values($items); + } + + public static function create(CommandInterface ...$items): self + { + return new self(...$items); + } + + public static function createEmpty(): self + { + return new self(); + } + + /** @param array $array */ + public static function fromArray(array $array): self + { + return new self(...$array); + } + + public function append(CommandInterface $command): self + { + return new self(...[...$this->items, $command]); + } + + public function merge(self $other): self + { + return new self(...$this->items, ...$other->items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index f474dda8191..556ba4379be 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -15,14 +15,18 @@ namespace Neos\ContentRepository\Core; use Neos\ContentRepository\Core\CommandHandler\CommandBus; +use Neos\ContentRepository\Core\CommandHandler\CommandHookInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventPersister; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; -use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUp; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; @@ -30,6 +34,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; @@ -38,7 +43,6 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; @@ -46,6 +50,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; use Neos\EventStore\EventStoreInterface; +use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\EventEnvelope; use Neos\EventStore\Model\EventStream\VirtualStreamName; use Psr\Clock\ClockInterface; @@ -81,9 +86,10 @@ public function __construct( private readonly NodeTypeManager $nodeTypeManager, private readonly InterDimensionalVariationGraph $variationGraph, private readonly ContentDimensionSourceInterface $contentDimensionSource, - private readonly UserIdProviderInterface $userIdProvider, + private readonly AuthProviderInterface $authProvider, private readonly ClockInterface $clock, - private readonly ContentGraphReadModelInterface $contentGraphReadModel + private readonly ContentGraphReadModelInterface $contentGraphReadModel, + private readonly CommandHookInterface $commandHook, ) { } @@ -91,22 +97,52 @@ public function __construct( * The only API to send commands (mutation intentions) to the system. * * @param CommandInterface $command + * @throws AccessDenied */ 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); - - if ($eventsToPublishOrGenerator instanceof EventsToPublish) { - $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublishOrGenerator); - $this->eventPersister->publishEvents($this, $eventsToPublish); - } else { - foreach ($eventsToPublishOrGenerator as $eventsToPublish) { - assert($eventsToPublish instanceof EventsToPublish); // just for the ide - $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublish); - $this->eventPersister->publishEvents($this, $eventsToPublish); + $command = $this->commandHook->onBeforeHandle($command); + $privilege = $this->authProvider->canExecuteCommand($command); + if (!$privilege->granted) { + throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->getReason()); + } + + $toPublish = $this->commandBus->handle($command); + + // simple case + if ($toPublish instanceof EventsToPublish) { + $eventsToPublish = $this->enrichEventsToPublishWithMetadata($toPublish); + $this->eventPersister->publishWithoutCatchup($eventsToPublish); + $this->catchupProjections(); + return; + } + + // control-flow aware command handling via generator + try { + foreach ($toPublish as $yieldedEventsToPublish) { + $eventsToPublish = $this->enrichEventsToPublishWithMetadata($yieldedEventsToPublish); + try { + $this->eventPersister->publishWithoutCatchup($eventsToPublish); + } catch (ConcurrencyException $concurrencyException) { + // we pass the exception into the generator (->throw), so it could be try-caught and reacted upon: + // + // try { + // yield EventsToPublish(...); + // } catch (ConcurrencyException $e) { + // yield $this->reopenContentStream(); + // throw $e; + // } + $yieldedErrorStrategy = $toPublish->throw($concurrencyException); + if ($yieldedErrorStrategy instanceof EventsToPublish) { + $this->eventPersister->publishWithoutCatchup($yieldedErrorStrategy); + } + throw $concurrencyException; + } } + } finally { + // We always NEED to catchup even if there was an unexpected ConcurrencyException to make sure previous commits are handled. + // Technically it would be acceptable for the catchup to fail here (due to hook errors) because all the events are already persisted. + $this->catchupProjections(); } } @@ -147,7 +183,7 @@ public function catchUpProjection(string $projectionClassName, CatchUpOptions $o $projection = $this->projectionsAndCatchUpHooks->projections->get($projectionClassName); $catchUpHookFactory = $this->projectionsAndCatchUpHooks->getCatchUpHookFactoryForProjection($projection); - $catchUpHook = $catchUpHookFactory?->build(new CatchUpHookFactoryDependencies( + $catchUpHook = $catchUpHookFactory?->build(CatchUpHookFactoryDependencies::create( $this->id, $projection->getState(), $this->nodeTypeManager, @@ -235,12 +271,31 @@ public function resetProjectionState(string $projectionClassName): void /** * @throws WorkspaceDoesNotExist if the workspace does not exist + * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface}) */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { + $privilege = $this->authProvider->canReadNodesFromWorkspace($workspaceName); + if (!$privilege->granted) { + throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->getReason()); + } return $this->contentGraphReadModel->getContentGraph($workspaceName); } + /** + * Main API to retrieve a content subgraph, taking VisibilityConstraints of the current user + * into account ({@see AuthProviderInterface::getVisibilityConstraints()}) + * + * @throws WorkspaceDoesNotExist if the workspace does not exist + * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface}) + */ + public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface + { + $contentGraph = $this->getContentGraph($workspaceName); + $visibilityConstraints = $this->authProvider->getVisibilityConstraints($workspaceName); + return $contentGraph->getSubgraph($dimensionSpacePoint, $visibilityConstraints); + } + /** * Returns the workspace with the given name, or NULL if it does not exist in this content repository */ @@ -285,7 +340,7 @@ public function getContentDimensionSource(): ContentDimensionSourceInterface private function enrichEventsToPublishWithMetadata(EventsToPublish $eventsToPublish): EventsToPublish { - $initiatingUserId = $this->userIdProvider->getUserId(); + $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser(); $initiatingTimestamp = $this->clock->now(); return new EventsToPublish( diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php index 4909d50e661..1af59ff3ce9 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php @@ -8,6 +8,7 @@ use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Events; +use Neos\EventStore\Model\EventStore\CommitResult; /** * Internal service to persist {@see EventInterface} with the proper normalization, and triggering the @@ -24,22 +25,28 @@ public function __construct( } /** + * TODO Will be refactored via https://github.com/neos/neos-development-collection/pull/5321 * @throws ConcurrencyException in case the expectedVersion does not match */ public function publishEvents(ContentRepository $contentRepository, EventsToPublish $eventsToPublish): void { - if ($eventsToPublish->events->isEmpty()) { - return; - } + $this->publishWithoutCatchup($eventsToPublish); + $contentRepository->catchUpProjections(); + } + + /** + * TODO Will be refactored via https://github.com/neos/neos-development-collection/pull/5321 + * @throws ConcurrencyException in case the expectedVersion does not match + */ + public function publishWithoutCatchup(EventsToPublish $eventsToPublish): CommitResult + { $normalizedEvents = Events::fromArray( $eventsToPublish->events->map($this->eventNormalizer->normalize(...)) ); - $this->eventStore->commit( + return $this->eventStore->commit( $eventsToPublish->streamName, $normalizedEvents, $eventsToPublish->expectedVersion ); - - $contentRepository->catchUpProjections(); } } diff --git a/Neos.ContentRepository.Core/Classes/EventStore/Events.php b/Neos.ContentRepository.Core/Classes/EventStore/Events.php index 872aab9a56d..f9d9abc8191 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/Events.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/Events.php @@ -56,11 +56,6 @@ public function map(\Closure $callback): array return array_map($callback, $this->events); } - public function isEmpty(): bool - { - return empty($this->events); - } - public function count(): int { return count($this->events); diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php index d7b4ab5bcfe..ee9e22ef103 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php @@ -25,15 +25,6 @@ public function __construct( ) { } - public static function empty(): self - { - return new EventsToPublish( - StreamName::fromString("empty"), - Events::fromArray([]), - ExpectedVersion::ANY() - ); - } - public function withAppendedEvents(Events $events): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php index 87be6d0b2cd..260226553e2 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php @@ -4,7 +4,7 @@ namespace Neos\ContentRepository\Core\EventStore; -use Neos\ContentRepository\Core\SharedModel\User\UserId; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\EventStore\Model\Event\EventMetadata; /** diff --git a/Neos.ContentRepository.Core/Classes/Factory/CommandHookFactoryInterface.php b/Neos.ContentRepository.Core/Classes/Factory/CommandHookFactoryInterface.php new file mode 100644 index 00000000000..2418556ff5c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Factory/CommandHookFactoryInterface.php @@ -0,0 +1,17 @@ + + */ + private array $commandHookFactories; + + public function __construct( + CommandHookFactoryInterface ...$commandHookFactories, + ) { + $this->commandHookFactories = $commandHookFactories; + } + + public function build( + CommandHooksFactoryDependencies $commandHooksFactoryDependencies, + ): CommandHooks { + return CommandHooks::fromArray(array_map( + static fn (CommandHookFactoryInterface $factory) => $factory->build($commandHooksFactoryDependencies), + $this->commandHookFactories + )); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Factory/CommandHooksFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/CommandHooksFactoryDependencies.php new file mode 100644 index 00000000000..3082edcd7f6 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Factory/CommandHooksFactoryDependencies.php @@ -0,0 +1,56 @@ +projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies); } @@ -133,7 +134,14 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->eventNormalizer, ) ); - + $authProvider = $this->authProviderFactory->build($this->contentRepositoryId, $contentGraphReadModel); + $commandHooks = $this->commandHooksFactory->build(CommandHooksFactoryDependencies::create( + $this->contentRepositoryId, + $this->projectionsAndCatchUpHooks->contentGraphProjection->getState(), + $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->contentDimensionSource, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + )); $this->contentRepository = new ContentRepository( $this->contentRepositoryId, $publicCommandBus, @@ -144,9 +152,10 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->nodeTypeManager, $this->projectionFactoryDependencies->interDimensionalVariationGraph, $this->projectionFactoryDependencies->contentDimensionSource, - $this->userIdProvider, + $authProvider, $this->clock, - $contentGraphReadModel + $contentGraphReadModel, + $commandHooks, ); $this->isBuilding = false; return $this->contentRepository; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php index 2be9688f065..6e4ec8a7391 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php @@ -81,14 +81,9 @@ protected function requireContentStream( CommandHandlingDependencies $commandHandlingDependencies ): ContentStreamId { $contentStreamId = $commandHandlingDependencies->getContentGraph($workspaceName)->getContentStreamId(); - if (!$commandHandlingDependencies->contentStreamExists($contentStreamId)) { - throw new ContentStreamDoesNotExistYet( - 'Content stream for "' . $workspaceName->value . '" does not exist yet.', - 1521386692 - ); - } + $isContentStreamClosed = $commandHandlingDependencies->isContentStreamClosed($contentStreamId); - if ($commandHandlingDependencies->isContentStreamClosed($contentStreamId)) { + if ($isContentStreamClosed) { throw new ContentStreamIsClosed( 'Content stream "' . $contentStreamId->value . '" is closed.', 1710260081 diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php index c7b3d5cbc59..a77ad194420 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php @@ -15,24 +15,29 @@ namespace Neos\ContentRepository\Core\Feature\Common; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulator; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** - * This interface is implemented by **commands** which can be rebased to other Content Streams. This is basically all - * node-based commands. + * Common (marker) interface for all **commands** that need to be serialized for rebasing to other workspaces + * + * If the api command {@see CommandInterface} is serializable on its own it will directly implement this interface. + * For complex commands a serialized counterpart - which is not api - will be build which implements this interface. + * + * During a rebase, the command (either the original {@see CommandInterface} or its serialized counterpart) will be deserialized + * from array {@see RebasableToOtherWorkspaceInterface::fromArray()} and reapplied via the {@see CommandSimulator} * * Reminder: a rebase can fail, because the target content stream might contain conflicting changes. * * @internal used internally for the rebasing mechanism of content streams */ -interface RebasableToOtherWorkspaceInterface extends CommandInterface +interface RebasableToOtherWorkspaceInterface extends \JsonSerializable { public function createCopyForWorkspace( WorkspaceName $targetWorkspaceName, ): self; /** - * called during deserialization from metadata * @param array $array */ public static function fromArray(array $array): self; diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php index c3027d71147..a228ca7c864 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php @@ -9,55 +9,25 @@ use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasClosed; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened; -use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Event\ContentStreamWasCreated; use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked; use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamAlreadyExists; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamIsClosed; -use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamIsNotClosed; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\EventStore\Model\Event\Version; use Neos\EventStore\Model\EventStream\ExpectedVersion; 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, - CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { - $this->requireContentStreamToNotExistYet($contentStreamId, $commandHandlingDependencies); - $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId) - ->getEventStreamName(); - - return new EventsToPublish( - $streamName, - Events::with( - new ContentStreamWasCreated( - $contentStreamId, - ) - ), - ExpectedVersion::NO_STREAM() - ); - } - /** * @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, - CommandHandlingDependencies $commandHandlingDependencies, + Version $contentStreamVersion, ): EventsToPublish { - $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); - $expectedVersion = $this->getExpectedVersionOfContentStream($contentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotBeClosed($contentStreamId, $commandHandlingDependencies); $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(); return new EventsToPublish( @@ -67,7 +37,7 @@ private function closeContentStream( $contentStreamId, ), ), - $expectedVersion + ExpectedVersion::fromVersion($contentStreamVersion) ); } @@ -75,21 +45,18 @@ private function closeContentStream( * @param ContentStreamId $contentStreamId The id of the content stream to reopen * @phpstan-pure this method is pure, to persist the events they must be handled outside */ - private function reopenContentStream( + private function reopenContentStreamWithoutConstraintChecks( ContentStreamId $contentStreamId, - CommandHandlingDependencies $commandHandlingDependencies, ): EventsToPublish { - $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToBeClosed($contentStreamId, $commandHandlingDependencies); - $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(); - return new EventsToPublish( - $streamName, + ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(), Events::with( new ContentStreamWasReopened( $contentStreamId ), ), + // We operate here without constraints on purpose to ensure this can be commited. + //Constraints have been checked beforehand and its expected that the content stream is closed. ExpectedVersion::ANY() ); } @@ -104,19 +71,10 @@ private function reopenContentStream( private function forkContentStream( ContentStreamId $newContentStreamId, ContentStreamId $sourceContentStreamId, - CommandHandlingDependencies $commandHandlingDependencies + Version $sourceContentStreamVersion ): EventsToPublish { - $this->requireContentStreamToExist($sourceContentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotBeClosed($sourceContentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotExistYet($newContentStreamId, $commandHandlingDependencies); - - $sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($sourceContentStreamId); - - $streamName = ContentStreamEventStreamName::fromContentStreamId($newContentStreamId) - ->getEventStreamName(); - return new EventsToPublish( - $streamName, + ContentStreamEventStreamName::fromContentStreamId($newContentStreamId)->getEventStreamName(), Events::with( new ContentStreamWasForked( $newContentStreamId, @@ -133,25 +91,19 @@ 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( + private function removeContentStreamWithoutConstraintChecks( ContentStreamId $contentStreamId, - CommandHandlingDependencies $commandHandlingDependencies ): EventsToPublish { - $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); - $expectedVersion = $this->getExpectedVersionOfContentStream($contentStreamId, $commandHandlingDependencies); - - $streamName = ContentStreamEventStreamName::fromContentStreamId( - $contentStreamId - )->getEventStreamName(); - return new EventsToPublish( - $streamName, + ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(), Events::with( new ContentStreamWasRemoved( $contentStreamId, ), ), - $expectedVersion + // We operate here without constraints on purpose to ensure this can be commited. + // Constraints have been checked beforehand and its expected that the content stream is closed. + ExpectedVersion::ANY() ); } @@ -172,23 +124,6 @@ private function requireContentStreamToNotExistYet( } } - /** - * @param ContentStreamId $contentStreamId - * @param CommandHandlingDependencies $commandHandlingDependencies - * @throws ContentStreamDoesNotExistYet - */ - private function requireContentStreamToExist( - ContentStreamId $contentStreamId, - CommandHandlingDependencies $commandHandlingDependencies - ): void { - if (!$commandHandlingDependencies->contentStreamExists($contentStreamId)) { - throw new ContentStreamDoesNotExistYet( - 'Content stream "' . $contentStreamId->value . '" does not exist yet.', - 1521386692 - ); - } - } - private function requireContentStreamToNotBeClosed( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies @@ -200,24 +135,4 @@ private function requireContentStreamToNotBeClosed( ); } } - - private function requireContentStreamToBeClosed( - ContentStreamId $contentStreamId, - CommandHandlingDependencies $commandHandlingDependencies - ): void { - if (!$commandHandlingDependencies->isContentStreamClosed($contentStreamId)) { - throw new ContentStreamIsNotClosed( - 'Content stream "' . $contentStreamId->value . '" is not closed.', - 1710405911 - ); - } - } - - private function getExpectedVersionOfContentStream( - ContentStreamId $contentStreamId, - CommandHandlingDependencies $commandHandlingDependencies - ): ExpectedVersion { - $version = $commandHandlingDependencies->getContentStreamVersion($contentStreamId); - return ExpectedVersion::fromVersion($version); - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php index 936505d278d..f3e3f55bdad 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php @@ -63,9 +63,6 @@ public static function create( return new self($workspaceName, $source, $target); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php index 498ba261e49..2862312256b 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php @@ -58,9 +58,6 @@ public static function create( return new self($workspaceName, $source, $target); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php index 4433ac05661..72695af98c7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/DimensionSpaceCommandHandler.php @@ -15,8 +15,8 @@ */ use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; @@ -26,13 +26,14 @@ 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\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Event\DimensionShineThroughWasAdded; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Event\DimensionSpacePointWasMoved; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Exception\DimensionSpacePointAlreadyExists; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\EventStore\Model\EventStream\ExpectedVersion; @@ -48,12 +49,12 @@ public function __construct( ) { } - public function canHandle(CommandInterface $command): bool + public function canHandle(CommandInterface|RebasableToOtherWorkspaceInterface $command): bool { return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish { /** @phpstan-ignore-next-line */ return match ($command::class) { diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php index eea2319dd3a..f014440718c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php @@ -15,13 +15,14 @@ namespace Neos\ContentRepository\Core\Feature; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\Common\TetheredNodeInternals; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; @@ -86,12 +87,12 @@ public function __construct( ) { } - public function canHandle(CommandInterface $command): bool + public function canHandle(CommandInterface|RebasableToOtherWorkspaceInterface $command): bool { return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish { /** @phpstan-ignore-next-line */ return match ($command::class) { diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php index 4aac2bb8761..ebca78299b4 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php @@ -18,7 +18,9 @@ use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -76,6 +78,32 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?: PropertyValuesToWrite::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty(), $references ?: NodeReferencesToWrite::createEmpty()); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + NodeAggregateId::fromString($array['nodeAggregateId']), + NodeTypeName::fromString($array['nodeTypeName']), + isset($array['originDimensionSpacePoint']) + ? OriginDimensionSpacePoint::fromArray($array['originDimensionSpacePoint']) + : OriginDimensionSpacePoint::createWithoutDimensions(), + NodeAggregateId::fromString($array['parentNodeAggregateId']), + isset($array['initialPropertyValues']) + ? PropertyValuesToWrite::fromArray($array['initialPropertyValues']) + : PropertyValuesToWrite::createEmpty(), + isset($array['succeedingSiblingNodeAggregateId']) + ? NodeAggregateId::fromString($array['succeedingSiblingNodeAggregateId']) + : null, + isset($array['nodeName']) + ? NodeName::fromString($array['nodeName']) + : null, + isset($array['tetheredDescendantNodeAggregateIds']) + ? NodeAggregateIdsByNodePaths::fromArray($array['tetheredDescendantNodeAggregateIds']) + : NodeAggregateIdsByNodePaths::createEmpty(), + isset($array['references']) ? NodeReferencesToWrite::fromArray($array['references']) : NodeReferencesToWrite::createEmpty(), + ); + } + public function withInitialPropertyValues(PropertyValuesToWrite $newInitialPropertyValues): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php index 958b1a30590..2eb1324ae2e 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php @@ -14,7 +14,6 @@ namespace Neos\ContentRepository\Core\Feature\NodeCreation\Command; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; @@ -34,7 +33,6 @@ * @internal implementation detail, use {@see CreateNodeAggregateWithNode} instead. */ final readonly class CreateNodeAggregateWithNodeAndSerializedProperties implements - CommandInterface, \JsonSerializable, MatchableWithNodeIdToPublishOrDiscardInterface, RebasableToOtherWorkspaceInterface @@ -80,9 +78,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?? SerializedPropertyValues::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty(), $references ?: SerializedNodeReferences::createEmpty()); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php index f92f938df9c..90c725e6a69 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php @@ -60,9 +60,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $coveredDimensionSpacePoint, $nodeVariantSelectionStrategy); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php index de0ad11d57d..37c8407b0c7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php @@ -60,9 +60,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $coveredDimensionSpacePoint, $nodeVariantSelectionStrategy); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Exception/NodeAggregateIsAlreadyDisabled.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Exception/NodeAggregateIsAlreadyDisabled.php new file mode 100644 index 00000000000..c659a1532c5 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Exception/NodeAggregateIsAlreadyDisabled.php @@ -0,0 +1,22 @@ +coveredDimensionSpacePoint ); if ($nodeAggregate->getDimensionSpacePointsTaggedWith(SubtreeTag::disabled())->contains($command->coveredDimensionSpacePoint)) { - // already disabled, so we can return a no-operation. - return EventsToPublish::empty(); + throw new NodeAggregateIsAlreadyDisabled(sprintf('Node aggregate "%s" cannot be disabled because it is already explicitly disabled for dimension space point %s', $nodeAggregate->nodeAggregateId->value, $command->coveredDimensionSpacePoint->toJson()), 1731166196); } $affectedDimensionSpacePoints = $command->nodeVariantSelectionStrategy @@ -114,8 +115,7 @@ public function handleEnableNodeAggregate( $command->coveredDimensionSpacePoint ); if (!$nodeAggregate->getDimensionSpacePointsTaggedWith(SubtreeTag::disabled())->contains($command->coveredDimensionSpacePoint)) { - // already enabled, so we can return a no-operation. - return EventsToPublish::empty(); + throw new NodeAggregateIsAlreadyEnabled(sprintf('Node aggregate "%s" cannot be enabled because is not explicitly disabled for dimension space point %s', $nodeAggregate->nodeAggregateId->value, $command->coveredDimensionSpacePoint->toJson()), 1731166142); } $affectedDimensionSpacePoints = $command->nodeVariantSelectionStrategy diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php index ba31a07c601..e3219fa695a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php @@ -14,19 +14,17 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication\Command; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeAggregateIdMapping; use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeSubtreeSnapshot; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping; /** * CopyNodesRecursively command @@ -35,10 +33,10 @@ * The node will be appended as child node of the given `parentNodeId` which must cover the given * `dimensionSpacePoint`. * - * @api commands are the write-API of the ContentRepository + * @internal + * @deprecated with Neos 9 Beta 16, please use Neos's {@see \Neos\Neos\Domain\Service\NodeDuplicationService} instead. */ final readonly class CopyNodesRecursively implements - CommandInterface, \JsonSerializable, MatchableWithNodeIdToPublishOrDiscardInterface, RebasableToOtherWorkspaceInterface @@ -100,9 +98,6 @@ public static function createFromSubgraphAndStartNode( ); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php index edffe38ad5c..d102d58e26a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php @@ -14,9 +14,8 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication; -use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; @@ -27,17 +26,18 @@ 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\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeCreationInternals; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeSubtreeSnapshot; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\SharedModel\Exception\NodeConstraintException; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** * @internal from userland, you'll use ContentRepository::handle to dispatch commands @@ -64,12 +64,12 @@ protected function getAllowedDimensionSubspace(): DimensionSpacePointSet return $this->contentDimensionZookeeper->getAllowedDimensionSubspace(); } - public function canHandle(CommandInterface $command): bool + public function canHandle(CommandInterface|RebasableToOtherWorkspaceInterface $command): bool { return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish { /** @phpstan-ignore-next-line */ return match ($command::class) { @@ -170,7 +170,7 @@ private function handleCopyNodesRecursively( private function requireNewNodeAggregateIdsToNotExist( ContentGraphInterface $contentGraph, - Dto\NodeAggregateIdMapping $nodeAggregateIdMapping + \Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping $nodeAggregateIdMapping ): void { foreach ($nodeAggregateIdMapping->getAllNewNodeAggregateIds() as $nodeAggregateId) { $this->requireProjectedNodeAggregateToNotExist( @@ -191,7 +191,7 @@ private function createEventsForNodeToInsert( ?NodeAggregateId $targetSucceedingSiblingNodeAggregateId, ?NodeName $targetNodeName, NodeSubtreeSnapshot $nodeToInsert, - Dto\NodeAggregateIdMapping $nodeAggregateIdMapping, + \Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping $nodeAggregateIdMapping, array &$events, ): void { $events[] = new NodeAggregateWithNodeWasCreated( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetNodeProperties.php index e2a3d63f587..bb978e7e948 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetNodeProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetNodeProperties.php @@ -60,4 +60,14 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod { return new self($workspaceName, $nodeAggregateId, $originDimensionSpacePoint, $propertyValues); } + + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + NodeAggregateId::fromString($array['nodeAggregateId']), + OriginDimensionSpacePoint::fromArray($array['originDimensionSpacePoint']), + PropertyValuesToWrite::fromArray($array['propertyValues']), + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php index a2d684f6035..c3906bae03f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php @@ -14,7 +14,6 @@ namespace Neos\ContentRepository\Core\Feature\NodeModification\Command; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; @@ -22,7 +21,6 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -33,7 +31,6 @@ * @internal implementation detail, use {@see SetNodeProperties} instead. */ final readonly class SetSerializedNodeProperties implements - CommandInterface, \JsonSerializable, MatchableWithNodeIdToPublishOrDiscardInterface, RebasableToOtherWorkspaceInterface @@ -77,9 +74,6 @@ public static function create( ); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php index 3a756163488..4a657dc507f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php @@ -82,16 +82,15 @@ public static function create(WorkspaceName $workspaceName, DimensionSpacePoint return new self($workspaceName, $dimensionSpacePoint, $nodeAggregateId, $relationDistributionStrategy, $newParentNodeAggregateId, $newPrecedingSiblingNodeAggregateId, $newSucceedingSiblingNodeAggregateId); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( WorkspaceName::fromString($array['workspaceName']), DimensionSpacePoint::fromArray($array['dimensionSpacePoint']), NodeAggregateId::fromString($array['nodeAggregateId']), - RelationDistributionStrategy::fromString($array['relationDistributionStrategy']), + isset($array['relationDistributionStrategy']) + ? RelationDistributionStrategy::from($array['relationDistributionStrategy']) + : RelationDistributionStrategy::default(), isset($array['newParentNodeAggregateId']) ? NodeAggregateId::fromString($array['newParentNodeAggregateId']) : null, diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Dto/RelationDistributionStrategy.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Dto/RelationDistributionStrategy.php index 7b01ee90ce6..8ca1fd33f08 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Dto/RelationDistributionStrategy.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Dto/RelationDistributionStrategy.php @@ -34,11 +34,9 @@ enum RelationDistributionStrategy: string implements \JsonSerializable case STRATEGY_GATHER_ALL = 'gatherAll'; case STRATEGY_GATHER_SPECIALIZATIONS = 'gatherSpecializations'; - public static function fromString(?string $serialization): self + public static function default(): self { - return !is_null($serialization) - ? self::from($serialization) - : self::STRATEGY_GATHER_ALL; + return self::STRATEGY_GATHER_ALL; } public function jsonSerialize(): string diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php index 70da56b2c2d..30424548e8f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php @@ -6,6 +6,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; @@ -47,4 +48,14 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $sou { return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $references); } + + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + NodeAggregateId::fromString($array['sourceNodeAggregateId']), + OriginDimensionSpacePoint::fromArray($array['sourceOriginDimensionSpacePoint']), + NodeReferencesToWrite::fromArray($array['references']), + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php index ae96cc5af13..132f1299ed7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php @@ -14,7 +14,6 @@ namespace Neos\ContentRepository\Core\Feature\NodeReferencing\Command; -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; @@ -31,7 +30,6 @@ * @internal implementation detail, use {@see SetNodeReferences} instead. */ final readonly class SetSerializedNodeReferences implements - CommandInterface, \JsonSerializable, MatchableWithNodeIdToPublishOrDiscardInterface, RebasableToOtherWorkspaceInterface @@ -61,9 +59,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $sou return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $references); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php index 085af255b8c..92292bd34da 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php @@ -59,9 +59,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $coveredDimensionSpacePoint, $nodeVariantSelectionStrategy, null); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php index 38d1f195ed6..d19d6210a48 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php @@ -59,9 +59,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $newNodeName); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php index e979e4d25c1..4a13da56621 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php @@ -61,9 +61,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $newNodeTypeName, $strategy, NodeAggregateIdsByNodePaths::createEmpty()); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php index 54873f489dc..2064e99e0b2 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php @@ -60,9 +60,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $sourceOrigin, $targetOrigin); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php index 210f91b8b8a..b62d85eddd8 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php @@ -9,9 +9,11 @@ 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; use Neos\EventStore\Model\Event\EventId; use Neos\EventStore\Model\Event\EventMetadata; use Neos\EventStore\Model\Event\SequenceNumber; +use Neos\EventStore\Model\EventEnvelope; /** * @internal @@ -20,17 +22,18 @@ { public function __construct( public RebasableToOtherWorkspaceInterface $originalCommand, + public Event $originalEvent, public EventMetadata $initiatingMetaData, public SequenceNumber $originalSequenceNumber ) { } - public static function extractFromEventMetaData(EventMetadata $eventMetadata, SequenceNumber $sequenceNumber): self + public static function extractFromEventEnvelope(EventEnvelope $eventEnvelope): self { - $commandToRebaseClass = $eventMetadata->value['commandClass'] ?? null; - $commandToRebasePayload = $eventMetadata->value['commandPayload'] ?? null; + $commandToRebaseClass = $eventEnvelope->event->metadata?->value['commandClass'] ?? null; + $commandToRebasePayload = $eventEnvelope->event->metadata?->value['commandPayload'] ?? null; - if ($commandToRebaseClass === null || $commandToRebasePayload === null) { + if ($commandToRebaseClass === null || $commandToRebasePayload === null || $eventEnvelope->event->metadata === null) { throw new \RuntimeException('Command cannot be extracted from metadata, missing commandClass or commandPayload.', 1729847804); } @@ -46,8 +49,9 @@ public static function extractFromEventMetaData(EventMetadata $eventMetadata, Se $commandInstance = $commandToRebaseClass::fromArray($commandToRebasePayload); return new self( $commandInstance, - InitiatingEventMetadata::extractInitiatingMetadata($eventMetadata), - $sequenceNumber + $eventEnvelope->event, + InitiatingEventMetadata::extractInitiatingMetadata($eventEnvelope->event->metadata), + $eventEnvelope->sequenceNumber ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php index 5f4146321fe..fd746e051a4 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php @@ -30,7 +30,7 @@ public static function extractFromEventStream(EventStreamInterface $eventStream) $commands = []; foreach ($eventStream as $eventEnvelope) { if ($eventEnvelope->event->metadata && isset($eventEnvelope->event->metadata?->value['commandClass'])) { - $commands[] = RebaseableCommand::extractFromEventMetaData($eventEnvelope->event->metadata, $eventEnvelope->sequenceNumber); + $commands[] = RebaseableCommand::extractFromEventEnvelope($eventEnvelope); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php index 9bbb1f320cc..27c946fb04c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php @@ -102,9 +102,6 @@ public function withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePat ); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php index 302c05ed895..fa4c9a42158 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php @@ -51,9 +51,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php new file mode 100644 index 00000000000..e6f8524e3e1 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php @@ -0,0 +1,27 @@ +reason; + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php similarity index 96% rename from Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php index 86f78e31a21..b43e9b5feb1 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\User; +namespace Neos\ContentRepository\Core\Feature\Security\Dto; use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php new file mode 100644 index 00000000000..21528122e84 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php @@ -0,0 +1,34 @@ +value, $reason), 1729014760); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php new file mode 100644 index 00000000000..2d44bcf63fe --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php @@ -0,0 +1,44 @@ +userId; + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return VisibilityConstraints::default(); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + return Privilege::granted(self::class . ' always grants privileges'); + } + + public function canExecuteCommand(CommandInterface $command): Privilege + { + return Privilege::granted(self::class . ' always grants privileges'); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php index d4d37c8b8cb..824bb402ca9 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php @@ -22,7 +22,6 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -64,9 +63,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $coveredDimensionSpacePoint, $nodeVariantSelectionStrategy, $tag); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php index 1ae9b4624a2..02bea45d58b 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php @@ -22,7 +22,6 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -65,9 +64,6 @@ public static function create(WorkspaceName $workspaceName, NodeAggregateId $nod return new self($workspaceName, $nodeAggregateId, $coveredDimensionSpacePoint, $nodeVariantSelectionStrategy, $tag); } - /** - * @param array $array - */ public static function fromArray(array $array): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Exception/SubtreeIsAlreadyTagged.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Exception/SubtreeIsAlreadyTagged.php new file mode 100644 index 00000000000..8a1388e7d9c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Exception/SubtreeIsAlreadyTagged.php @@ -0,0 +1,22 @@ +getDimensionSpacePointsTaggedWith($command->tag)->contains($command->coveredDimensionSpacePoint)) { - // already explicitly tagged with the same Subtree Tag, so we can return a no-operation. - return EventsToPublish::empty(); + throw new SubtreeIsAlreadyTagged(sprintf('Cannot add subtree tag "%s" because node aggregate "%s" is already explicitly tagged with that tag in dimension space point %s', $command->tag->value, $nodeAggregate->nodeAggregateId->value, $command->coveredDimensionSpacePoint->toJson()), 1731167142); } $affectedDimensionSpacePoints = $command->nodeVariantSelectionStrategy @@ -93,8 +94,7 @@ public function handleUntagSubtree(UntagSubtree $command, CommandHandlingDepende ); if (!$nodeAggregate->getDimensionSpacePointsTaggedWith($command->tag)->contains($command->coveredDimensionSpacePoint)) { - // not explicitly tagged with the given Subtree Tag, so we can return a no-operation. - return EventsToPublish::empty(); + throw new SubtreeIsNotTagged(sprintf('Cannot remove subtree tag "%s" because node aggregate "%s" is not explicitly tagged with that tag in dimension space point %s', $command->tag->value, $nodeAggregate->nodeAggregateId->value, $command->coveredDimensionSpacePoint->toJson()), 1731167464); } $affectedDimensionSpacePoints = $command->nodeVariantSelectionStrategy diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index 20143536272..a29b7777544 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -20,14 +20,15 @@ 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\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; 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\ContentStreamCreation\Event\ContentStreamWasCreated; +use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; @@ -55,6 +56,7 @@ use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamAlreadyExists; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; +use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamIsClosed; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -62,7 +64,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; use Neos\EventStore\EventStoreInterface; -use Neos\EventStore\Model\Event\EventType; +use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\Event\Version; use Neos\EventStore\Model\EventStream\EventStreamInterface; @@ -82,12 +84,15 @@ public function __construct( ) { } - public function canHandle(CommandInterface $command): bool + public function canHandle(CommandInterface|RebasableToOtherWorkspaceInterface $command): bool { return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): \Generator + /** + * @return \Generator + */ + public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $command, CommandHandlingDependencies $commandHandlingDependencies): \Generator { /** @phpstan-ignore-next-line */ return match ($command::class) { @@ -115,7 +120,6 @@ private function handleCreateWorkspace( ): \Generator { $this->requireWorkspaceToNotExist($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $commandHandlingDependencies->findWorkspaceByName($command->baseWorkspaceName); - if ($baseWorkspace === null) { throw new BaseWorkspaceDoesNotExist(sprintf( 'The workspace %s (base workspace of %s) does not exist', @@ -123,12 +127,15 @@ private function handleCreateWorkspace( $command->workspaceName->value ), 1513890708); } + $sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); + $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies); // When the workspace is created, we first have to fork the content stream yield $this->forkContentStream( $command->newContentStreamId, $baseWorkspace->currentContentStreamId, - $commandHandlingDependencies + $sourceContentStreamVersion ); yield new EventsToPublish( @@ -154,11 +161,16 @@ private function handleCreateRootWorkspace( CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { $this->requireWorkspaceToNotExist($command->workspaceName, $commandHandlingDependencies); + $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies); - $newContentStreamId = $command->newContentStreamId; - yield $this->createContentStream( - $newContentStreamId, - $commandHandlingDependencies + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($command->newContentStreamId)->getEventStreamName(), + Events::with( + new ContentStreamWasCreated( + $command->newContentStreamId, + ) + ), + ExpectedVersion::NO_STREAM() ); yield new EventsToPublish( @@ -166,7 +178,7 @@ private function handleCreateRootWorkspace( Events::with( new RootWorkspaceWasCreated( $command->workspaceName, - $newContentStreamId + $command->newContentStreamId ) ), ExpectedVersion::ANY() @@ -183,17 +195,8 @@ private function handlePublishWorkspace( // no-op return; } - - 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); - - yield $this->closeContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); + $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); + $baseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($baseWorkspace, $commandHandlingDependencies); $rebaseableCommands = RebaseableCommands::extractFromEventStream( $this->eventStore->load( @@ -202,31 +205,30 @@ private function handlePublishWorkspace( ) ); - try { - yield from $this->publishWorkspace( - $workspace, - $baseWorkspace, - $command->newContentStreamId, - $baseContentStreamVersion, - $rebaseableCommands, - $commandHandlingDependencies - ); - } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); - throw $workspaceRebaseFailed; - } + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $workspaceContentStreamVersion + ); + + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, + $baseWorkspaceContentStreamVersion, + $command->newContentStreamId, + $rebaseableCommands + ); } + /** + * Note that the workspaces content stream must be closed beforehand. + * It will be reopened here in case of error. + */ private function publishWorkspace( Workspace $workspace, Workspace $baseWorkspace, + Version $baseWorkspaceContentStreamVersion, ContentStreamId $newContentStreamId, - Version $baseContentStreamVersion, - RebaseableCommands $rebaseableCommands, - CommandHandlingDependencies $commandHandlingDependencies, + RebaseableCommands $rebaseableCommands ): \Generator { $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); @@ -238,25 +240,37 @@ static function ($handle) use ($rebaseableCommands): void { } ); - if ($commandSimulator->hasCommandsThatFailed()) { - throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); + if ($commandSimulator->hasConflicts()) { + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId + ); + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getConflictingEvents()); } - yield new EventsToPublish( - ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) - ->getEventStreamName(), - $this->getCopiedEventsOfEventStream( - $baseWorkspace->workspaceName, - $baseWorkspace->currentContentStreamId, - $commandSimulator->eventStream(), - ), - ExpectedVersion::fromVersion($baseContentStreamVersion) + $eventsOfWorkspaceToPublish = $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream(), ); + try { + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $eventsOfWorkspaceToPublish, + ExpectedVersion::fromVersion($baseWorkspaceContentStreamVersion) + ); + } catch (ConcurrencyException $concurrencyException) { + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId + ); + throw $concurrencyException; + } + yield $this->forkContentStream( $newContentStreamId, $baseWorkspace->currentContentStreamId, - $commandHandlingDependencies + Version::fromInteger($baseWorkspaceContentStreamVersion->value + $eventsOfWorkspaceToPublish->count()) ); yield new EventsToPublish( @@ -272,19 +286,19 @@ static function ($handle) use ($rebaseableCommands): void { ExpectedVersion::ANY() ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } private function rebaseWorkspaceWithoutChanges( Workspace $workspace, Workspace $baseWorkspace, - ContentStreamId $newContentStreamId, - CommandHandlingDependencies $commandHandlingDependencies, + Version $baseWorkspaceContentStreamVersion, + ContentStreamId $newContentStreamId ): \Generator { yield $this->forkContentStream( $newContentStreamId, $baseWorkspace->currentContentStreamId, - $commandHandlingDependencies + $baseWorkspaceContentStreamVersion ); yield new EventsToPublish( @@ -299,11 +313,11 @@ private function rebaseWorkspaceWithoutChanges( ExpectedVersion::ANY() ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } /** - * Copy all events from the passed event stream which implement the {@see PublishableToOtherContentStreamsInterface} + * Copy all events from the passed event stream which implement the {@see PublishableToWorkspaceInterface} */ private function getCopiedEventsOfEventStream( WorkspaceName $targetWorkspaceName, @@ -336,9 +350,9 @@ private function handleRebaseWorkspace( ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { - throw new \RuntimeException('Cannot rebase a workspace with a stateless content stream', 1711718314); - } + + $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); + $baseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($baseWorkspace, $commandHandlingDependencies); if ( $workspace->status === WorkspaceStatus::UP_TO_DATE @@ -348,18 +362,18 @@ private function handleRebaseWorkspace( return; } - yield $this->closeContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); - if (!$workspace->hasPublishableChanges()) { // if we have no changes in the workspace we can fork from the base directly + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $workspaceContentStreamVersion + ); + yield from $this->rebaseWorkspaceWithoutChanges( $workspace, $baseWorkspace, - $command->rebasedContentStreamId, - $commandHandlingDependencies + $baseWorkspaceContentStreamVersion, + $command->rebasedContentStreamId ); return; } @@ -371,6 +385,11 @@ private function handleRebaseWorkspace( ) ); + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $workspaceContentStreamVersion + ); + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); $commandSimulator->run( @@ -383,21 +402,21 @@ static function ($handle) use ($rebaseableCommands): void { if ( $command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FAIL - && $commandSimulator->hasCommandsThatFailed() + && $commandSimulator->hasConflicts() ) { - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId ); // throw an exception that contains all the information about what exactly failed - throw WorkspaceRebaseFailed::duringRebase($commandSimulator->getCommandsThatFailed()); + throw WorkspaceRebaseFailed::duringRebase($commandSimulator->getConflictingEvents()); } // 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, + $baseWorkspaceContentStreamVersion, new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), Events::with( @@ -413,21 +432,16 @@ static function ($handle) use ($rebaseableCommands): void { $command->workspaceName, $command->rebasedContentStreamId, $commandSimulator->eventStream(), - ), - $commandHandlingDependencies + ) ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } /** * This method is like a combined Rebase and Publish! * - * @throws BaseWorkspaceDoesNotExist - * @throws ContentStreamAlreadyExists - * @throws ContentStreamDoesNotExistYet - * @throws WorkspaceDoesNotExist - * @throws \Exception + * @return \Generator */ private function handlePublishIndividualNodesFromWorkspace( PublishIndividualNodesFromWorkspace $command, @@ -440,17 +454,8 @@ private function handlePublishIndividualNodesFromWorkspace( return; } - // todo check that fetching workspace throws if there is no content stream id for it - if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { - throw new \RuntimeException('Cannot publish nodes on a workspace with a stateless content stream', 1710410114); - } - $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); - $baseContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); - - yield $this->closeContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); + $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); + $baseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($baseWorkspace, $commandHandlingDependencies); $rebaseableCommands = RebaseableCommands::extractFromEventStream( $this->eventStore->load( @@ -463,32 +468,24 @@ private function handlePublishIndividualNodesFromWorkspace( if ($matchingCommands->isEmpty()) { // almost a noop (e.g. random node ids were specified) ;) - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); return; } + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $workspaceContentStreamVersion + ); + if ($remainingCommands->isEmpty()) { - try { - // do a full publish, this is simpler for the projections to handle - yield from $this->publishWorkspace( - $workspace, - $baseWorkspace, - $command->contentStreamIdForRemainingPart, - $baseContentStreamVersion, - $matchingCommands, - $commandHandlingDependencies - ); - return; - } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); - throw $workspaceRebaseFailed; - } + // do a full publish, this is simpler for the projections to handle + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, + $baseWorkspaceContentStreamVersion, + $command->contentStreamIdForRemainingPart, + $matchingCommands + ); + return; } $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); @@ -506,30 +503,39 @@ static function ($handle) use ($commandSimulator, $matchingCommands, $remainingC } ); - if ($commandSimulator->hasCommandsThatFailed()) { - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies + if ($commandSimulator->hasConflicts()) { + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId ); - throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getConflictingEvents()); } - // 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) + // this could empty and a no-op for the rare case when a command returns empty events e.g. the node was already tagged with this subtree tag + $selectedEventsOfWorkspaceToPublish = $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream()->withMaximumSequenceNumber($highestSequenceNumberForMatching), ); + try { + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $selectedEventsOfWorkspaceToPublish, + ExpectedVersion::fromVersion($baseWorkspaceContentStreamVersion) + ); + } catch (ConcurrencyException $concurrencyException) { + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId + ); + throw $concurrencyException; + } + yield from $this->forkNewContentStreamAndApplyEvents( $command->contentStreamIdForRemainingPart, $baseWorkspace->currentContentStreamId, + Version::fromInteger($baseWorkspaceContentStreamVersion->value + $selectedEventsOfWorkspaceToPublish->count()), new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), Events::fromArray([ @@ -547,11 +553,10 @@ static function ($handle) use ($commandSimulator, $matchingCommands, $remainingC $command->workspaceName, $command->contentStreamIdForRemainingPart, $commandSimulator->eventStream()->withMinimumSequenceNumber($highestSequenceNumberForMatching->next()) - ), - $commandHandlingDependencies + ) ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } /** @@ -576,40 +581,37 @@ private function handleDiscardIndividualNodesFromWorkspace( return; } - if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { - throw new \RuntimeException('Cannot discard nodes on a workspace with a stateless content stream', 1710408112); - } - - yield $this->closeContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies - ); + $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); + $baseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($baseWorkspace, $commandHandlingDependencies); - // 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() ) ); + + // filter commands, only keeping the ones NOT MATCHING the nodes from the command (i.e. the modifications we want to keep) [$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, - $commandHandlingDependencies - ); return; } + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $workspaceContentStreamVersion + ); + if ($commandsToKeep->isEmpty()) { // quick path everything was discarded yield from $this->discardWorkspace( $workspace, + $workspaceContentStreamVersion, $baseWorkspace, - $command->newContentStreamId, - $commandHandlingDependencies + $baseWorkspaceContentStreamVersion, + $command->newContentStreamId ); return; } @@ -624,17 +626,17 @@ static function ($handle) use ($commandsToKeep): void { } ); - if ($commandSimulator->hasCommandsThatFailed()) { - yield $this->reopenContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies + if ($commandSimulator->hasConflicts()) { + yield $this->reopenContentStreamWithoutConstraintChecks( + $workspace->currentContentStreamId ); - throw WorkspaceRebaseFailed::duringDiscard($commandSimulator->getCommandsThatFailed()); + throw WorkspaceRebaseFailed::duringDiscard($commandSimulator->getConflictingEvents()); } yield from $this->forkNewContentStreamAndApplyEvents( $command->newContentStreamId, $baseWorkspace->currentContentStreamId, + $baseWorkspaceContentStreamVersion, new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), Events::with( @@ -651,11 +653,10 @@ static function ($handle) use ($commandsToKeep): void { $command->workspaceName, $command->newContentStreamId, $commandSimulator->eventStream(), - ), - $commandHandlingDependencies + ) ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } /** @@ -674,31 +675,32 @@ private function handleDiscardWorkspace( return; } + $workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies); + $baseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($baseWorkspace, $commandHandlingDependencies); + yield from $this->discardWorkspace( $workspace, + $workspaceContentStreamVersion, $baseWorkspace, - $command->newContentStreamId, - $commandHandlingDependencies + $baseWorkspaceContentStreamVersion, + $command->newContentStreamId ); } /** - * @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, + Version $workspaceContentStreamVersion, Workspace $baseWorkspace, - ContentStreamId $newContentStream, - CommandHandlingDependencies $commandHandlingDependencies + Version $baseWorkspaceContentStreamVersion, + ContentStreamId $newContentStream ): \Generator { yield $this->forkContentStream( $newContentStream, $baseWorkspace->currentContentStreamId, - $commandHandlingDependencies + $baseWorkspaceContentStreamVersion ); yield new EventsToPublish( @@ -713,7 +715,7 @@ private function discardWorkspace( ExpectedVersion::ANY() ); - yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + yield $this->removeContentStreamWithoutConstraintChecks($workspace->currentContentStreamId); } /** @@ -731,6 +733,8 @@ private function handleChangeBaseWorkspace( $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $currentBaseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + $this->requireContentStreamToNotBeClosed($workspace->currentContentStreamId, $commandHandlingDependencies); + if ($currentBaseWorkspace->workspaceName->equals($command->baseWorkspaceName)) { // no-op return; @@ -738,13 +742,14 @@ private function handleChangeBaseWorkspace( $this->requireEmptyWorkspace($workspace); $newBaseWorkspace = $this->requireWorkspace($command->baseWorkspaceName, $commandHandlingDependencies); - $this->requireNonCircularRelationBetweenWorkspaces($workspace, $newBaseWorkspace, $commandHandlingDependencies); + $newBaseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($newBaseWorkspace, $commandHandlingDependencies); + yield $this->forkContentStream( $command->newContentStreamId, $newBaseWorkspace->currentContentStreamId, - $commandHandlingDependencies + $newBaseWorkspaceContentStreamVersion ); yield new EventsToPublish( @@ -768,10 +773,16 @@ private function handleDeleteWorkspace( CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); + $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId); - yield $this->removeContentStream( - $workspace->currentContentStreamId, - $commandHandlingDependencies + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(), + Events::with( + new ContentStreamWasRemoved( + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::fromVersion($contentStreamVersion) ); yield new EventsToPublish( @@ -788,14 +799,14 @@ private function handleDeleteWorkspace( private function forkNewContentStreamAndApplyEvents( ContentStreamId $newContentStreamId, ContentStreamId $sourceContentStreamId, + Version $sourceContentStreamVersion, EventsToPublish $pointWorkspaceToNewContentStream, Events $eventsToApplyOnNewContentStream, - CommandHandlingDependencies $commandHandlingDependencies, ): \Generator { yield $this->forkContentStream( $newContentStreamId, $sourceContentStreamId, - $commandHandlingDependencies + $sourceContentStreamVersion )->withAppendedEvents(Events::with( new ContentStreamWasClosed( $newContentStreamId @@ -830,6 +841,17 @@ private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, Comman ), 1715341085); } + private function requireOpenContentStreamAndVersion(Workspace $workspace, CommandHandlingDependencies $commandHandlingDependencies): Version + { + if ($commandHandlingDependencies->isContentStreamClosed($workspace->currentContentStreamId)) { + throw new ContentStreamIsClosed( + 'Content stream "' . $workspace->currentContentStreamId . '" is closed.', + 1730730516 + ); + } + return $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId); + } + /** * @throws WorkspaceDoesNotExist */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateRootWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateRootWorkspace.php index 2a36c7655cb..c6b30e77711 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateRootWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateRootWorkspace.php @@ -45,4 +45,12 @@ public static function create(WorkspaceName $workspaceName, ContentStreamId $new { return new self($workspaceName, $newContentStreamId); } + + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + ContentStreamId::fromString($array['newContentStreamId']), + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateWorkspace.php index 4add3ccf28f..7c329e86f37 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCreation/Command/CreateWorkspace.php @@ -46,4 +46,13 @@ public static function create(WorkspaceName $workspaceName, WorkspaceName $baseW { return new self($workspaceName, $baseWorkspaceName, $newContentStreamId); } + + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + WorkspaceName::fromString($array['baseWorkspaceName']), + ContentStreamId::fromString($array['newContentStreamId']), + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php index dd833312b5f..3bf1ace5b50 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php @@ -36,6 +36,15 @@ public static function create(WorkspaceName $workspaceName, WorkspaceName $baseW return new self($workspaceName, $baseWorkspaceName, ContentStreamId::create()); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + WorkspaceName::fromString($array['baseWorkspaceName']), + isset($array['newContentStreamId']) ? ContentStreamId::fromString($array['newContentStreamId']) : ContentStreamId::create(), + ); + } + /** * During the publish process, we create a new content stream. * diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/DeleteWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/DeleteWorkspace.php index ab182194ba0..3cd87fb25b6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/DeleteWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/DeleteWorkspace.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceModification\Command; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -29,4 +30,11 @@ public static function create(WorkspaceName $workspaceName): self { return new self($workspaceName); } + + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardIndividualNodesFromWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardIndividualNodesFromWorkspace.php index fec436be434..7f6a2915c3d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardIndividualNodesFromWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardIndividualNodesFromWorkspace.php @@ -53,6 +53,15 @@ public static function create( ); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + NodeIdsToPublishOrDiscard::fromArray($array['nodesToDiscard']), + isset($array['newContentStreamId']) ? ContentStreamId::fromString($array['newContentStreamId']) : ContentStreamId::create(), + ); + } + /** * Call this method if you want to run this command fully deterministically, f.e. during test cases */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php index 5a5b0355945..e62465e8627 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php @@ -43,6 +43,14 @@ public static function create(WorkspaceName $workspaceName): self return new self($workspaceName, ContentStreamId::create()); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + isset($array['newContentStreamId']) ? ContentStreamId::fromString($array['newContentStreamId']) : ContentStreamId::create(), + ); + } + /** * Call this method if you want to run this command fully deterministically, f.e. during test cases */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php index 2394c80ab99..7f9cf111dd8 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php @@ -51,6 +51,15 @@ public static function create(WorkspaceName $workspaceName, NodeIdsToPublishOrDi ); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + NodeIdsToPublishOrDiscard::fromArray($array['nodesToPublish']), + isset($array['contentStreamIdForRemainingPart']) ? ContentStreamId::fromString($array['contentStreamIdForRemainingPart']) : ContentStreamId::create(), + ); + } + /** * The id of the new content stream that will contain all remaining events * diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishWorkspace.php index 5364d13054d..fa76f2caa03 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishWorkspace.php @@ -34,6 +34,14 @@ private function __construct( ) { } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + isset($array['newContentStreamId']) ? ContentStreamId::fromString($array['newContentStreamId']) : ContentStreamId::create(), + ); + } + /** * During the publish process, we create a new content stream. * diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php index 5e9aa2cbc91..d9440c74a9d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Command/RebaseWorkspace.php @@ -42,6 +42,15 @@ public static function create(WorkspaceName $workspaceName): self return new self($workspaceName, ContentStreamId::create(), RebaseErrorHandlingStrategy::STRATEGY_FAIL); } + public static function fromArray(array $array): self + { + return new self( + WorkspaceName::fromString($array['workspaceName']), + isset($array['rebasedContentStreamId']) ? ContentStreamId::fromString($array['rebasedContentStreamId']) : ContentStreamId::create(), + isset($array['rebaseErrorHandlingStrategy']) ? RebaseErrorHandlingStrategy::from($array['rebaseErrorHandlingStrategy']) : RebaseErrorHandlingStrategy::STRATEGY_FAIL, + ); + } + /** * Call this method if you want to run this command fully deterministically, f.e. during test cases */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php deleted file mode 100644 index bcfd9627256..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php +++ /dev/null @@ -1,46 +0,0 @@ -sequenceNumber; - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvent.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvent.php new file mode 100644 index 00000000000..21a0fe146e9 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvent.php @@ -0,0 +1,74 @@ +event instanceof EmbedsNodeAggregateId + ? $this->event->getNodeAggregateId() + : null; + } + + /** + * The exception for the conflict + */ + public function getException(): \Throwable + { + return $this->exception; + } + + /** + * The event store sequence number of the event containing the command to be rebased + * + * @internal exposed for testing + */ + public function getSequenceNumber(): SequenceNumber + { + return $this->sequenceNumber; + } + + /** + * The event that conflicts + * + * @internal exposed for testing and experimental use cases + */ + public function getEvent(): EventInterface + { + return $this->event; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvents.php similarity index 69% rename from Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php rename to Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvents.php index 24f1087ee81..1b30aa61007 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/ConflictingEvents.php @@ -15,28 +15,28 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase; /** - * @implements \IteratorAggregate + * @implements \IteratorAggregate * * @api part of the exception exposed when rebasing failed */ -final readonly class CommandsThatFailedDuringRebase implements \IteratorAggregate, \Countable +final readonly class ConflictingEvents implements \IteratorAggregate, \Countable { /** - * @var array + * @var array */ private array $items; - public function __construct(CommandThatFailedDuringRebase ...$items) + public function __construct(ConflictingEvent ...$items) { $this->items = array_values($items); } - public function withAppended(CommandThatFailedDuringRebase $item): self + public function withAppended(ConflictingEvent $item): self { return new self(...[...$this->items, $item]); } - public function first(): ?CommandThatFailedDuringRebase + public function first(): ?ConflictingEvent { return $this->items[0] ?? null; } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php index b1bae671156..499ea7a247c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\ConflictingEvents; /** * @api this exception contains information about what exactly went wrong during rebase @@ -22,7 +22,7 @@ final class WorkspaceRebaseFailed extends \Exception { private function __construct( - public readonly CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase, + public readonly ConflictingEvents $conflictingEvents, string $message, int $code, ?\Throwable $previous, @@ -30,39 +30,39 @@ private function __construct( parent::__construct($message, $code, $previous); } - public static function duringRebase(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + public static function duringRebase(ConflictingEvents $conflictingEvents): self { return new self( - $commandsThatFailedDuringRebase, - sprintf('Rebase failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + $conflictingEvents, + sprintf('Rebase failed: %s', self::renderMessage($conflictingEvents)), 1729974936, - $commandsThatFailedDuringRebase->first()?->exception + $conflictingEvents->first()?->getException() ); } - public static function duringPublish(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + public static function duringPublish(ConflictingEvents $conflictingEvents): self { return new self( - $commandsThatFailedDuringRebase, - sprintf('Publication failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + $conflictingEvents, + sprintf('Publication failed: %s', self::renderMessage($conflictingEvents)), 1729974980, - $commandsThatFailedDuringRebase->first()?->exception + $conflictingEvents->first()?->getException() ); } - public static function duringDiscard(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + public static function duringDiscard(ConflictingEvents $conflictingEvents): self { return new self( - $commandsThatFailedDuringRebase, - sprintf('Discard failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + $conflictingEvents, + sprintf('Discard failed: %s', self::renderMessage($conflictingEvents)), 1729974982, - $commandsThatFailedDuringRebase->first()?->exception + $conflictingEvents->first()?->getException() ); } - private static function renderMessage(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): string + private static function renderMessage(ConflictingEvents $conflictingEvents): string { - $firstFailure = $commandsThatFailedDuringRebase->first(); - return sprintf('"%s" and %d further failures', $firstFailure?->exception->getMessage(), count($commandsThatFailedDuringRebase) - 1); + $firstConflict = $conflictingEvents->first(); + return sprintf('"%s" and %d further conflicts', $firstConflict?->getException()->getMessage(), count($conflictingEvents) - 1); } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php index 037e4164150..0a98f7e11c3 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php +++ b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php @@ -30,7 +30,7 @@ * @param ContentRepositoryId $contentRepositoryId the content repository the catchup was registered in * @param ProjectionStateInterface&T $projectionState the state of the projection the catchup was registered to (Its only safe to access this projections state) */ - public function __construct( + private function __construct( public ContentRepositoryId $contentRepositoryId, public ProjectionStateInterface $projectionState, public NodeTypeManager $nodeTypeManager, @@ -38,4 +38,26 @@ public function __construct( public InterDimensionalVariationGraph $variationGraph ) { } + + /** + * @template U of ProjectionStateInterface + * @param ProjectionStateInterface&U $projectionState + * @return CatchUpHookFactoryDependencies + * @internal + */ + public static function create( + ContentRepositoryId $contentRepositoryId, + ProjectionStateInterface $projectionState, + NodeTypeManager $nodeTypeManager, + ContentDimensionSourceInterface $contentDimensionSource, + InterDimensionalVariationGraph $variationGraph + ): self { + return new self( + $contentRepositoryId, + $projectionState, + $nodeTypeManager, + $contentDimensionSource, + $variationGraph + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php index abb0858233e..3bae2f7828f 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/AbsoluteNodePath.php @@ -70,7 +70,7 @@ public static function fromRootNodeTypeNameAndRelativePath( public static function fromLeafNodeAndAncestors(Node $leafNode, Nodes $ancestors): self { if ($leafNode->classification->isRoot()) { - return new self($leafNode->nodeTypeName, NodePath::forRoot()); + return new self($leafNode->nodeTypeName, NodePath::createEmpty()); } $rootNode = $ancestors->first(); if (!$rootNode || !$rootNode->classification->isRoot()) { @@ -147,7 +147,7 @@ public function appendPathSegment(NodeName $nodeName): self */ public function isRoot(): bool { - return $this->path->isRoot(); + return $this->path->isEmpty(); } /** diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php index 91d35a9e4ad..1dc3613824c 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php @@ -14,6 +14,7 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph; +use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; @@ -51,7 +52,7 @@ public function getContentRepositoryId(): ContentRepositoryId; public function getWorkspaceName(): WorkspaceName; /** - * @api main API method of ContentGraph + * @api You most likely want to use {@see ContentRepository::getContentSubgraph()} because it automatically determines VisibilityConstraints for the current user. */ public function getSubgraph( DimensionSpacePoint $dimensionSpacePoint, diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php index 6ef34fe7d8a..43be4c36620 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodePath.php @@ -17,7 +17,9 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeName; /** - * The relative node path is a collection of node names {@see NodeName}. If it contains no elements, it is considered root. + * The relative node path is a collection of node names {@see NodeName}. + * + * If it contains no elements, it is considered root in combination with {@see AbsoluteNodePath}. * * Example: * root path: '' is resolved to [] @@ -48,7 +50,7 @@ private function __construct(NodeName ...$nodeNames) $this->nodeNames = $nodeNames; } - public static function forRoot(): self + public static function createEmpty(): self { return new self(); } @@ -57,7 +59,7 @@ public static function fromString(string $path): self { $path = ltrim($path, '/'); if ($path === '') { - return self::forRoot(); + return self::createEmpty(); } return self::fromPathSegments( @@ -92,7 +94,7 @@ public static function fromNodeNames(NodeName ...$nodeNames): self return new self(...$nodeNames); } - public function isRoot(): bool + public function isEmpty(): bool { return $this->getLength() === 0; } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php index 1f0ea1f5033..db0b5af29ed 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtree.php @@ -5,17 +5,25 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph; /** - * @api returned by {@see ContentSubgraphInterface} + * @api returned by {@see ContentSubgraphInterface::findSubtree()} */ final readonly class Subtree { - /** - * @param array $children - */ - public function __construct( + private function __construct( public int $level, public Node $node, - public array $children + public Subtrees $children ) { } + + /** + * @internal + */ + public static function create( + int $level, + Node $node, + Subtrees $children + ): self { + return new self($level, $node, $children); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php new file mode 100644 index 00000000000..2a946946b82 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/Subtrees.php @@ -0,0 +1,48 @@ + + */ +final readonly class Subtrees implements \IteratorAggregate, \Countable +{ + /** @var array */ + private array $items; + + private function __construct( + Subtree ...$items + ) { + $this->items = $items; + } + + /** + * @internal + */ + public static function createEmpty(): self + { + return new self(); + } + + /** + * @internal + * @param array $items + */ + public static function fromArray(array $items): self + { + return new self(...$items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php index 7462e28a4e1..f1c4f3b93ae 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php @@ -34,6 +34,14 @@ private function __construct( ) { } + /** + * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query + */ + public static function fromTagConstraints(SubtreeTags $tagConstraints): self + { + return new self($tagConstraints); + } + public function getHash(): string { return md5(implode('|', $this->tagConstraints->toStringArray())); @@ -48,11 +56,16 @@ public static function withoutRestrictions(): self return new self(SubtreeTags::createEmpty()); } - public static function frontend(): VisibilityConstraints + public static function default(): VisibilityConstraints { return new self(SubtreeTags::fromStrings('disabled')); } + public function withAddedSubtreeTag(SubtreeTag $subtreeTag): self + { + return new self($this->tagConstraints->merge(SubtreeTags::fromArray([$subtreeTag]))); + } + /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php deleted file mode 100644 index 5bc969ed6b6..00000000000 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php +++ /dev/null @@ -1,23 +0,0 @@ -userId; - } -} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php deleted file mode 100644 index 8530a34e30d..00000000000 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $expectedParts */ array $expectedParts, int $expectedLength @@ -30,7 +30,7 @@ public function testDeserialization( $subject = NodePath::fromString($serializedPath); self::assertSame($serializedPath, $subject->serializeToString()); - self::assertSame($expectedRootState, $subject->isRoot()); + self::assertSame($expectedEmptyState, $subject->isEmpty()); self::assertEquals($expectedParts, $subject->getParts()); self::assertSame($expectedLength, $subject->getLength()); } @@ -39,7 +39,7 @@ public static function serializedPathProvider(): iterable { yield 'nonRoot' => [ 'serializedPath' => 'child/grandchild', - 'expectedRootState' => false, + 'isEmpty' => false, 'expectedParts' => [ NodeName::fromString('child'), NodeName::fromString('grandchild'), @@ -49,7 +49,7 @@ public static function serializedPathProvider(): iterable yield 'root' => [ 'serializedPath' => '', - 'expectedRootState' => true, + 'isEmpty' => true, 'expectedParts' => [], 'expectedLength' => 0 ]; diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php index e603c12fd04..658c509834b 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php @@ -16,15 +16,17 @@ use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use League\Flysystem\FileAttributes; 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; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Export\Asset\ValueObject\SerializedImageVariant; use Neos\ContentRepository\Export\Event\ValueObject\ExportedEvents; -use Neos\ContentRepository\Export\ProcessorResult; +use Neos\ContentRepository\Export\Factory\EventExportProcessorFactory; +use Neos\ContentRepository\Export\Factory\EventStoreImportProcessorFactory; +use Neos\ContentRepository\Export\ProcessingContext; +use Neos\ContentRepository\Export\ProcessorInterface; use Neos\ContentRepository\Export\Processors\EventExportProcessor; use Neos\ContentRepository\Export\Processors\EventStoreImportProcessor; use Neos\ContentRepository\Export\Severity; @@ -40,7 +42,7 @@ trait CrImportExportTrait private Filesystem $crImportExportTrait_filesystem; - private ?ProcessorResult $crImportExportTrait_lastMigrationResult = null; + private \Throwable|null $crImportExportTrait_lastMigrationException = null; /** @var array */ private array $crImportExportTrait_loggedErrors = []; @@ -48,96 +50,71 @@ trait CrImportExportTrait /** @var array */ private array $crImportExportTrait_loggedWarnings = []; - public function setupCrImportExportTrait() + private function setupCrImportExportTrait(): void { $this->crImportExportTrait_filesystem = new Filesystem(new InMemoryFilesystemAdapter()); } /** - * @When /^the events are exported$/ + * @AfterScenario */ - public function theEventsAreExportedIExpectTheFollowingJsonl() + public function failIfLastMigrationHasErrors(): void { - $eventExporter = $this->getContentRepositoryService( - new class ($this->crImportExportTrait_filesystem) implements ContentRepositoryServiceFactoryInterface { - public function __construct(private readonly Filesystem $filesystem) - { - } - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventExportProcessor { - return new EventExportProcessor( - $this->filesystem, - $serviceFactoryDependencies->contentRepository->findWorkspaceByName(WorkspaceName::forLive()), - $serviceFactoryDependencies->eventStore - ); - } - } - ); - assert($eventExporter instanceof EventExportProcessor); + if ($this->crImportExportTrait_lastMigrationException !== null) { + throw new \RuntimeException(sprintf('The last migration run led to an exception: %s', $this->crImportExportTrait_lastMigrationException->getMessage())); + } + if ($this->crImportExportTrait_loggedErrors !== []) { + throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->crImportExportTrait_loggedErrors), count($this->crImportExportTrait_loggedErrors) === 1 ? '' : 's')); + } + } - $eventExporter->onMessage(function (Severity $severity, string $message) { + private function runCrImportExportProcessors(ProcessorInterface ...$processors): void + { + $processingContext = new ProcessingContext($this->crImportExportTrait_filesystem, function (Severity $severity, string $message) { if ($severity === Severity::ERROR) { $this->crImportExportTrait_loggedErrors[] = $message; } elseif ($severity === Severity::WARNING) { $this->crImportExportTrait_loggedWarnings[] = $message; } }); - $this->crImportExportTrait_lastMigrationResult = $eventExporter->run(); + foreach ($processors as $processor) { + assert($processor instanceof ProcessorInterface); + try { + $processor->run($processingContext); + } catch (\Throwable $e) { + $this->crImportExportTrait_lastMigrationException = $e; + break; + } + } } /** - * @When /^I import the events\.jsonl(?: into "([^"]*)")?$/ + * @When /^the events are exported$/ */ - public function iImportTheFollowingJson(?string $contentStreamId = null) - { - $eventImporter = $this->getContentRepositoryService( - new class ($this->crImportExportTrait_filesystem, $contentStreamId ? ContentStreamId::fromString($contentStreamId) : null) implements ContentRepositoryServiceFactoryInterface { - public function __construct( - private readonly Filesystem $filesystem, - private readonly ?ContentStreamId $contentStreamId - ) { - } - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventStoreImportProcessor { - return new EventStoreImportProcessor( - false, - $this->filesystem, - $serviceFactoryDependencies->eventStore, - $serviceFactoryDependencies->eventNormalizer, - $this->contentStreamId - ); - } - } - ); - assert($eventImporter instanceof EventStoreImportProcessor); - - $eventImporter->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->crImportExportTrait_loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->crImportExportTrait_loggedWarnings[] = $message; - } - }); - $this->crImportExportTrait_lastMigrationResult = $eventImporter->run(); + public function theEventsAreExported(): void + { + $eventExporter = $this->getContentRepositoryService(new EventExportProcessorFactory($this->currentContentRepository->findWorkspaceByName(WorkspaceName::forLive())->currentContentStreamId)); + assert($eventExporter instanceof EventExportProcessor); + $this->runCrImportExportProcessors($eventExporter); } /** - * @Given /^using the following events\.jsonl:$/ + * @When /^I import the events\.jsonl(?: into workspace "([^"]*)")?$/ */ - public function usingTheFollowingEventsJsonl(PyStringNode $string) + public function iImportTheEventsJsonl(?string $workspace = null): void { - $this->crImportExportTrait_filesystem->write('events.jsonl', $string->getRaw()); + $workspaceName = $workspace !== null ? WorkspaceName::fromString($workspace) : $this->currentWorkspaceName; + $eventImporter = $this->getContentRepositoryService(new EventStoreImportProcessorFactory($workspaceName, true)); + assert($eventImporter instanceof EventStoreImportProcessor); + $this->runCrImportExportProcessors($eventImporter); } /** - * @AfterScenario + * @Given /^using the following events\.jsonl:$/ */ - public function failIfLastMigrationHasErrors(): void + public function usingTheFollowingEventsJsonl(PyStringNode $string): void { - if ($this->crImportExportTrait_lastMigrationResult !== null && $this->crImportExportTrait_lastMigrationResult->severity === Severity::ERROR) { - throw new \RuntimeException(sprintf('The last migration run led to an error: %s', $this->crImportExportTrait_lastMigrationResult->message)); - } - if ($this->crImportExportTrait_loggedErrors !== []) { - throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->crImportExportTrait_loggedErrors), count($this->crImportExportTrait_loggedErrors) === 1 ? '' : 's')); - } + $this->crImportExportTrait_filesystem->write('events.jsonl', $string->getRaw()); } /** @@ -167,6 +144,66 @@ public function iExpectTheFollowingJsonL(PyStringNode $string): void Assert::assertSame($string->getRaw(), ExportedEvents::fromIterable($eventsWithoutRandomIds)->toJsonl()); } + /** + * @Then I expect the following events to be exported + */ + public function iExpectTheFollowingEventsToBeExported(TableNode $table): void + { + + if (!$this->crImportExportTrait_filesystem->has('events.jsonl')) { + Assert::fail('No events were exported'); + } + $eventsJson = $this->crImportExportTrait_filesystem->read('events.jsonl'); + $exportedEvents = iterator_to_array(ExportedEvents::fromJsonl($eventsJson)); + + $expectedEvents = $table->getHash(); + foreach ($exportedEvents as $exportedEvent) { + $expectedEventRow = array_shift($expectedEvents); + if ($expectedEventRow === null) { + Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); + } + if (!empty($expectedEventRow['Type'])) { + Assert::assertSame($expectedEventRow['Type'], $exportedEvent->type, 'Event: ' . $exportedEvent->toJson()); + } + try { + $expectedEventPayload = json_decode($expectedEventRow['Payload'], true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode expected JSON: %s', $expectedEventRow['Payload']), 1655811083); + } + $actualEventPayload = $exportedEvent->payload; + foreach (array_keys($actualEventPayload) as $key) { + if (!array_key_exists($key, $expectedEventPayload)) { + unset($actualEventPayload[$key]); + } + } + Assert::assertEquals($expectedEventPayload, $actualEventPayload, 'Actual event: ' . $exportedEvent->toJson()); + } + Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); + } + + /** + * @Then I expect the following sites to be exported + */ + public function iExpectTheFollowingSitesToBeExported(TableNode $table): void + { + if (!$this->crImportExportTrait_filesystem->has('sites.json')) { + Assert::fail('No events were exported'); + } + $actualSitesJson = $this->crImportExportTrait_filesystem->read('sites.json'); + $actualSiteRows = json_decode($actualSitesJson, true, 512, JSON_THROW_ON_ERROR); + + $expectedSites = $table->getHash(); + foreach ($expectedSites as $key => $expectedSiteData) { + $actualSiteData = $actualSiteRows[$key] ?? []; + $expectedSiteData = array_map( + fn(string $value) => json_decode($value, true, 512, JSON_THROW_ON_ERROR), + $expectedSiteData + ); + Assert::assertEquals($expectedSiteData, $actualSiteData, 'Actual site: ' . json_encode($actualSiteData, JSON_THROW_ON_ERROR)); + } + Assert::assertCount(count($table->getHash()), $actualSiteRows, 'Expected number of sites does not match actual number'); + } + /** * @Then I expect the following errors to be logged */ @@ -186,24 +223,82 @@ public function iExpectTheFollowingWarningsToBeLogged(TableNode $table): void } /** - * @Then I expect a MigrationError - * @Then I expect a MigrationError with the message + * @Then I expect a migration exception + * @Then I expect a migration exception with the message */ - public function iExpectAMigrationErrorWithTheMessage(PyStringNode $expectedMessage = null): void + public function iExpectAMigrationExceptionWithTheMessage(PyStringNode $expectedMessage = null): void { - Assert::assertNotNull($this->crImportExportTrait_lastMigrationResult, 'Expected the previous migration to contain errors, but no migration has been executed'); - Assert::assertSame(Severity::ERROR, $this->crImportExportTrait_lastMigrationResult->severity, sprintf('Expected the previous migration to contain errors, but it ended with severity "%s"', $this->crImportExportTrait_lastMigrationResult->severity->name)); + Assert::assertNotNull($this->crImportExportTrait_lastMigrationException, 'Expected the previous migration to lead to an exception, but no exception was thrown'); if ($expectedMessage !== null) { - Assert::assertSame($expectedMessage->getRaw(), $this->crImportExportTrait_lastMigrationResult->message); + Assert::assertSame($expectedMessage->getRaw(), $this->crImportExportTrait_lastMigrationException->getMessage()); } - $this->crImportExportTrait_lastMigrationResult = null; + $this->crImportExportTrait_lastMigrationException = null; } /** - * @template T of object - * @param class-string $className - * - * @return T + * @Given the following ImageVariants exist */ - abstract private function getObject(string $className): object; + public function theFollowingImageVariantsExist(TableNode $imageVariants): void + { + foreach ($imageVariants->getHash() as $variantData) { + try { + $variantData['imageAdjustments'] = json_decode($variantData['imageAdjustments'], true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to JSON decode imageAdjustments for variant "%s"', $variantData['identifier']), 1659530081, $e); + } + $variantData['width'] = (int)$variantData['width']; + $variantData['height'] = (int)$variantData['height']; + $mockImageVariant = SerializedImageVariant::fromArray($variantData); + $this->mockAssets[$mockImageVariant->identifier] = $mockImageVariant; + } + } + + /** + * @Then /^I expect the following (Assets|ImageVariants) to be exported:$/ + */ + public function iExpectTheFollowingAssetsOrImageVariantsToBeExported(string $type, PyStringNode $expectedAssets): void + { + $actualAssets = []; + if (!$this->crImportExportTrait_filesystem->directoryExists($type)) { + Assert::fail(sprintf('No %1$s have been exported (Directory "/%1$s" does not exist)', $type)); + } + /** @var FileAttributes $file */ + foreach ($this->crImportExportTrait_filesystem->listContents($type) as $file) { + $actualAssets[] = json_decode($this->crImportExportTrait_filesystem->read($file->path()), true, 512, JSON_THROW_ON_ERROR); + } + Assert::assertJsonStringEqualsJsonString($expectedAssets->getRaw(), json_encode($actualAssets, JSON_THROW_ON_ERROR)); + } + + + /** + * @Then /^I expect no (Assets|ImageVariants) to be exported$/ + */ + public function iExpectNoAssetsToBeExported(string $type): void + { + Assert::assertFalse($this->crImportExportTrait_filesystem->directoryExists($type)); + } + + /** + * @Then I expect the following PersistentResources to be exported: + */ + public function iExpectTheFollowingPersistentResourcesToBeExported(TableNode $expectedResources): void + { + $actualResources = []; + if (!$this->crImportExportTrait_filesystem->directoryExists('Resources')) { + Assert::fail('No PersistentResources have been exported (Directory "/Resources" does not exist)'); + } + /** @var FileAttributes $file */ + foreach ($this->crImportExportTrait_filesystem->listContents('Resources') as $file) { + $actualResources[] = ['Filename' => basename($file->path()), 'Contents' => $this->crImportExportTrait_filesystem->read($file->path())]; + } + Assert::assertSame($expectedResources->getHash(), $actualResources); + } + + /** + * @Then /^I expect no PersistentResources to be exported$/ + */ + public function iExpectNoPersistentResourcesToBeExported(): void + { + Assert::assertFalse($this->crImportExportTrait_filesystem->directoryExists('Resources')); + } } diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature similarity index 58% rename from Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature rename to Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature index 06c9273ad54..7b139c617a0 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature @@ -1,5 +1,5 @@ @contentrepository -Feature: As a user of the CR I want to export the event stream +Feature: As a user of the CR I want to export the event stream using the EventExportProcessor Background: Given using the following content dimensions: @@ -12,9 +12,9 @@ Feature: As a user of the CR I want to export the event stream 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" | + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | And I am in workspace "live" And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | @@ -37,7 +37,7 @@ Feature: As a user of the CR I want to export the event stream When the events are exported Then I expect the following jsonl: """ - {"identifier":"random-event-uuid","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","coveredDimensionSpacePoints":[{"language":"de"},{"language":"gsw"},{"language":"fr"}],"nodeAggregateClassification":"root"},"metadata":{"commandClass":"Neos\\ContentRepository\\Core\\Feature\\RootNodeCreation\\Command\\CreateRootNodeAggregateWithNode","commandPayload":{"workspaceName":"live","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","tetheredDescendantNodeAggregateIds":[]},"initiatingUserId":"system","initiatingTimestamp":"random-time"}} - {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular","nodeReferences":[]},"metadata":{"initiatingTimestamp":"random-time"}} + {"identifier":"random-event-uuid","type":"RootNodeAggregateWithNodeWasCreated","payload":{"nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","coveredDimensionSpacePoints":[{"language":"de"},{"language":"gsw"},{"language":"fr"}],"nodeAggregateClassification":"root"},"metadata":{"commandClass":"Neos\\ContentRepository\\Core\\Feature\\RootNodeCreation\\Command\\CreateRootNodeAggregateWithNode","commandPayload":{"workspaceName":"live","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","tetheredDescendantNodeAggregateIds":[]},"initiatingUserId":"system","initiatingTimestamp":"random-time"}} + {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular","nodeReferences":[]},"metadata":{"initiatingTimestamp":"random-time"}} """ diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature similarity index 82% rename from Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature rename to Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature index 6c61f644b57..443ae5ff474 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature @@ -1,5 +1,5 @@ @contentrepository -Feature: As a user of the CR I want to export the event stream +Feature: As a user of the CR I want to import events using the EventStoreImportProcessor Background: Given using no content dimensions @@ -11,56 +11,59 @@ Feature: As a user of the CR I want to export the event stream """ And using identifier "default", I define a content repository And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" Scenario: Import the event stream into a specific content stream - Then I expect exactly 0 events to be published on stream with prefix "ContentStream:cs-identifier" Given using the following events.jsonl: """ {"identifier":"9f64c281-e5b0-48d9-900b-288a8faf92a9","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site-sites","nodeTypeName":"Neos.Neos:Sites","coveredDimensionSpacePoints":[[]],"nodeAggregateClassification":"root"},"metadata":[]} {"identifier":"1640ebbf-7ffe-4526-b0f4-7575cefabfab","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site","nodeTypeName":"Vendor.Site:HomePage","originDimensionSpacePoint":[],"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":[],"nodeAggregateId":null}],"parentNodeAggregateId":"acme-site-sites","nodeName":"acme-site","initialPropertyValues":{"title":{"value":"My Site","type":"string"},"uriPathSegment":{"value":"my-site","type":"string"}},"nodeAggregateClassification":"regular"},"metadata":[]} """ - And I import the events.jsonl into "cs-identifier" + And I import the events.jsonl into workspace "live" Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-identifier" And event at index 0 is of type "ContentStreamWasCreated" with payload: | Key | Expected | | contentStreamId | "cs-identifier" | And event at index 1 is of type "RootNodeAggregateWithNodeWasCreated" with payload: | Key | Expected | - | workspaceName | "workspace-name" | + | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "acme-site-sites" | | nodeTypeName | "Neos.Neos:Sites" | And event at index 2 is of type "NodeAggregateWithNodeWasCreated" with payload: | Key | Expected | - | workspaceName | "workspace-name" | + | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "acme-site" | | nodeTypeName | "Vendor.Site:HomePage" | Scenario: Import the event stream - Then I expect exactly 0 events to be published on stream with prefix "ContentStream:cs-imported-identifier" Given using the following events.jsonl: """ {"identifier":"9f64c281-e5b0-48d9-900b-288a8faf92a9","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site-sites","nodeTypeName":"Neos.Neos:Sites","coveredDimensionSpacePoints":[[]],"nodeAggregateClassification":"root"},"metadata":[]} {"identifier":"1640ebbf-7ffe-4526-b0f4-7575cefabfab","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site","nodeTypeName":"Vendor.Site:HomePage","originDimensionSpacePoint":[],"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":[],"nodeAggregateId":null}],"parentNodeAggregateId":"acme-site-sites","nodeName":"acme-site","initialPropertyValues":{"title":{"value":"My Site","type":"string"},"uriPathSegment":{"value":"my-site","type":"string"}},"nodeAggregateClassification":"regular"},"metadata":[]} """ And I import the events.jsonl - Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-imported-identifier" + Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-identifier" And event at index 0 is of type "ContentStreamWasCreated" with payload: - | Key | Expected | - | contentStreamId | "cs-imported-identifier" | + | Key | Expected | + | contentStreamId | "cs-identifier" | And event at index 1 is of type "RootNodeAggregateWithNodeWasCreated" with payload: - | Key | Expected | - | workspaceName | "workspace-name" | - | contentStreamId | "cs-imported-identifier" | - | nodeAggregateId | "acme-site-sites" | - | nodeTypeName | "Neos.Neos:Sites" | + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "acme-site-sites" | + | nodeTypeName | "Neos.Neos:Sites" | And event at index 2 is of type "NodeAggregateWithNodeWasCreated" with payload: - | Key | Expected | - | workspaceName | "workspace-name" | - | contentStreamId | "cs-imported-identifier" | - | nodeAggregateId | "acme-site" | - | nodeTypeName | "Vendor.Site:HomePage" | + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "acme-site" | + | nodeTypeName | "Vendor.Site:HomePage" | Scenario: Import faulty event stream with explicit "ContentStreamWasCreated" does not duplicate content-stream see issue https://github.com/neos/neos-development-collection/issues/4298 @@ -73,7 +76,7 @@ Feature: As a user of the CR I want to export the event stream """ And I import the events.jsonl - And I expect a MigrationError with the message + And I expect a migration exception with the message """ Failed to read events. ContentStreamWasCreated is not expected in imported event stream. """ diff --git a/Neos.ContentRepository.Export/src/Asset/AssetExporter.php b/Neos.ContentRepository.Export/src/Asset/AssetExporter.php index 3ce343eebb2..0dc915dbf03 100644 --- a/Neos.ContentRepository.Export/src/Asset/AssetExporter.php +++ b/Neos.ContentRepository.Export/src/Asset/AssetExporter.php @@ -1,5 +1,7 @@ data->value, true, 512, JSON_THROW_ON_ERROR); + // unset content stream id as this is overwritten during import + unset($payload['contentStreamId'], $payload['workspaceName']); + return new self( $event->id->value, $event->type->value, - \json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR), + $payload, $event->metadata?->value ?? [], ); } @@ -40,6 +44,7 @@ public static function fromJson(string $json): self } catch (\JsonException $e) { throw new \InvalidArgumentException(sprintf('Failed to decode JSON "%s": %s', $json, $e->getMessage()), 1638432979, $e); } + return new self( $data['identifier'], $data['type'], diff --git a/Neos.ContentRepository.Export/src/ExportService.php b/Neos.ContentRepository.Export/src/ExportService.php deleted file mode 100644 index 6f7f6491531..00000000000 --- a/Neos.ContentRepository.Export/src/ExportService.php +++ /dev/null @@ -1,63 +0,0 @@ - $processors */ - $processors = [ - 'Exporting events' => new EventExportProcessor( - $this->filesystem, - $this->targetWorkspace, - $this->eventStore - ), - 'Exporting assets' => new AssetExportProcessor( - $this->contentRepositoryId, - $this->filesystem, - $this->assetRepository, - $this->targetWorkspace, - $this->assetUsageService - ) - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $verbose && $processor->onMessage( - fn(Severity $severity, string $message) => $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]) - ); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . ($result->message ?? '')); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - } -} diff --git a/Neos.ContentRepository.Export/src/ExportServiceFactory.php b/Neos.ContentRepository.Export/src/ExportServiceFactory.php deleted file mode 100644 index e36d50be983..00000000000 --- a/Neos.ContentRepository.Export/src/ExportServiceFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class ExportServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - - public function __construct( - private readonly Filesystem $filesystem, - private readonly Workspace $targetWorkspace, - private readonly AssetRepository $assetRepository, - private readonly AssetUsageService $assetUsageService, - ) { - } - - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ExportService - { - return new ExportService( - $serviceFactoryDependencies->contentRepositoryId, - $this->filesystem, - $this->targetWorkspace, - $this->assetRepository, - $this->assetUsageService, - $serviceFactoryDependencies->eventStore, - ); - } -} diff --git a/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php b/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php new file mode 100644 index 00000000000..636a3a5a1be --- /dev/null +++ b/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php @@ -0,0 +1,29 @@ + + */ +final readonly class EventExportProcessorFactory implements ContentRepositoryServiceFactoryInterface +{ + public function __construct( + private ContentStreamId $contentStreamId, + ) { + } + + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventExportProcessor + { + return new EventExportProcessor( + $this->contentStreamId, + $serviceFactoryDependencies->eventStore, + ); + } +} diff --git a/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php b/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php new file mode 100644 index 00000000000..459a746c1e6 --- /dev/null +++ b/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php @@ -0,0 +1,33 @@ + + */ +final readonly class EventStoreImportProcessorFactory implements ContentRepositoryServiceFactoryInterface +{ + public function __construct( + private WorkspaceName $targetWorkspaceName, + private bool $keepEventIds, + ) { + } + + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventStoreImportProcessor + { + return new EventStoreImportProcessor( + $this->targetWorkspaceName, + $this->keepEventIds, + $serviceFactoryDependencies->eventStore, + $serviceFactoryDependencies->eventNormalizer, + $serviceFactoryDependencies->contentRepository, + ); + } +} diff --git a/Neos.ContentRepository.Export/src/ImportService.php b/Neos.ContentRepository.Export/src/ImportService.php deleted file mode 100644 index de06ced5b3c..00000000000 --- a/Neos.ContentRepository.Export/src/ImportService.php +++ /dev/null @@ -1,86 +0,0 @@ -liveWorkspaceContentStreamExists()) { - throw new LiveWorkspaceContentStreamExistsException(); - } - - /** @var ProcessorInterface[] $processors */ - $processors = [ - 'Importing assets' => new AssetRepositoryImportProcessor( - $this->filesystem, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - ), - 'Importing events' => new EventStoreImportProcessor( - false, - $this->filesystem, - $this->eventStore, - $this->eventNormalizer, - $this->contentStreamIdentifier, - ) - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $verbose && $processor->onMessage( - fn(Severity $severity, string $message) => $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]) - ); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . ($result->message ?? '')); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - } - - private function liveWorkspaceContentStreamExists(): bool - { - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName(WorkspaceName::forLive())->getEventStreamName(); - $eventStream = $this->eventStore->load($workspaceStreamName); - foreach ($eventStream as $event) { - return true; - } - return false; - } -} diff --git a/Neos.ContentRepository.Export/src/ImportServiceFactory.php b/Neos.ContentRepository.Export/src/ImportServiceFactory.php deleted file mode 100644 index b5504818664..00000000000 --- a/Neos.ContentRepository.Export/src/ImportServiceFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class ImportServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - - public function __construct( - private readonly Filesystem $filesystem, - private readonly ContentStreamId $contentStreamIdentifier, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, - private readonly PersistenceManagerInterface $persistenceManager, - ) { - } - - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ImportService - { - return new ImportService( - $this->filesystem, - $this->contentStreamIdentifier, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - $serviceFactoryDependencies->eventNormalizer, - $serviceFactoryDependencies->eventStore, - ); - } -} diff --git a/Neos.ContentRepository.Export/src/ProcessingContext.php b/Neos.ContentRepository.Export/src/ProcessingContext.php new file mode 100644 index 00000000000..cd6fd16bba0 --- /dev/null +++ b/Neos.ContentRepository.Export/src/ProcessingContext.php @@ -0,0 +1,24 @@ +onEvent)($severity, $message); + } +} diff --git a/Neos.ContentRepository.Export/src/ProcessorInterface.php b/Neos.ContentRepository.Export/src/ProcessorInterface.php index 6ee2a5246d7..e033c200256 100644 --- a/Neos.ContentRepository.Export/src/ProcessorInterface.php +++ b/Neos.ContentRepository.Export/src/ProcessorInterface.php @@ -1,14 +1,13 @@ + */ +final readonly class Processors implements \IteratorAggregate, \Countable +{ + /** + * @param array $processors + */ + private function __construct( + private array $processors + ) { + } + + /** + * @param array $processors + */ + public static function fromArray(array $processors): self + { + return new self($processors); + } + + public function getIterator(): \Traversable + { + yield from $this->processors; + } + + public function count(): int + { + return count($this->processors); + } +} diff --git a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php index 3bceb6dc77f..78deda81ee3 100644 --- a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php @@ -1,14 +1,15 @@ */ - private array $callbacks = []; - public function __construct( private readonly ContentRepositoryId $contentRepositoryId, - private readonly Filesystem $files, private readonly AssetRepository $assetRepository, private readonly Workspace $targetWorkspace, private readonly AssetUsageService $assetUsageService, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $assetFilter = AssetUsageFilter::create()->withWorkspaceName($this->targetWorkspace->workspaceName)->groupByAsset(); - $numberOfExportedAssets = 0; - $numberOfExportedImageVariants = 0; - $numberOfErrors = 0; - foreach ($this->assetUsageService->findByFilter($this->contentRepositoryId, $assetFilter) as $assetUsage) { /** @var Asset|null $asset */ $asset = $this->assetRepository->findByIdentifier($assetUsage->assetId); if ($asset === null) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Skipping asset "%s" because it does not exist in the database', $assetUsage->assetId); + $context->dispatch(Severity::ERROR, "Skipping asset \"{$assetUsage->assetId}\" because it does not exist in the database"); continue; } @@ -63,64 +50,47 @@ public function run(): ProcessorResult /** @var Asset $originalAsset */ $originalAsset = $asset->getOriginalAsset(); try { - $this->exportAsset($originalAsset); - $numberOfExportedAssets ++; + $this->exportAsset($context, $originalAsset); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to export original asset "%s" (for variant "%s"): %s', $originalAsset->getIdentifier(), $asset->getIdentifier(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to export original asset \"{$originalAsset->getIdentifier()}\" (for variant \"{$asset->getIdentifier()}\"): {$e->getMessage()}"); } } try { - $this->exportAsset($asset); - if ($asset instanceof AssetVariantInterface) { - $numberOfExportedImageVariants ++; - } else { - $numberOfExportedAssets ++; - } + $this->exportAsset($context, $asset); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to export asset "%s": %s', $asset->getIdentifier(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to export asset \"{$asset->getIdentifier()}\": {$e->getMessage()}"); } } - return ProcessorResult::success(sprintf('Exported %d Asset%s and %d Image Variant%s. Errors: %d', $numberOfExportedAssets, $numberOfExportedAssets === 1 ? '' : 's', $numberOfExportedImageVariants, $numberOfExportedImageVariants === 1 ? '' : 's', $numberOfErrors)); } /** --------------------------------------- */ - private function exportAsset(Asset $asset): void + private function exportAsset(ProcessingContext $context, Asset $asset): void { $fileLocation = $asset instanceof ImageVariant ? "ImageVariants/{$asset->getIdentifier()}.json" : "Assets/{$asset->getIdentifier()}.json"; - if ($this->files->has($fileLocation)) { + if ($context->files->has($fileLocation)) { return; } if ($asset instanceof ImageVariant) { - $this->files->write($fileLocation, SerializedImageVariant::fromImageVariant($asset)->toJson()); + $context->files->write($fileLocation, SerializedImageVariant::fromImageVariant($asset)->toJson()); return; } /** @var PersistentResource|null $resource */ $resource = $asset->getResource(); if ($resource === null) { - $this->dispatch(Severity::ERROR, 'Skipping asset "%s" because the corresponding PersistentResource does not exist in the database', $asset->getIdentifier()); + $context->dispatch(Severity::ERROR, "Skipping asset \"{$asset->getIdentifier()}\" because the corresponding PersistentResource does not exist in the database"); return; } - $this->files->write($fileLocation, SerializedAsset::fromAsset($asset)->toJson()); - $this->exportResource($resource); + $context->files->write($fileLocation, SerializedAsset::fromAsset($asset)->toJson()); + $this->exportResource($context, $resource); } - private function exportResource(PersistentResource $resource): void + private function exportResource(ProcessingContext $context, PersistentResource $resource): void { $fileLocation = "Resources/{$resource->getSha1()}"; - if ($this->files->has($fileLocation)) { + if ($context->files->has($fileLocation)) { return; } - $this->files->writeStream($fileLocation, $resource->getStream()); - } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } + $context->files->writeStream($fileLocation, $resource->getStream()); } } diff --git a/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php b/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php index b389adababa..c7f7535069f 100644 --- a/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php @@ -1,15 +1,16 @@ */ - private array $callbacks = []; - public function __construct( - private readonly Filesystem $files, private readonly AssetRepository $assetRepository, private readonly ResourceRepository $resourceRepository, private readonly ResourceManager $resourceManager, private readonly PersistenceManagerInterface $persistenceManager, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $this->persistenceManager->clearState(); - $numberOfErrors = 0; - $numberOfImportedAssets = 0; - foreach ($this->files->listContents('/Assets') as $file) { + foreach ($context->files->listContents('/Assets') as $file) { if (!$file->isFile()) { continue; } try { - $this->importAsset($file); - $numberOfImportedAssets ++; + $this->importAsset($context, $file); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to import asset from file "%s": %s', $file->path(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to import asset from file \"{$file->path()}\": {$e->getMessage()}"); } } - $numberOfImportedImageVariants = 0; - foreach ($this->files->listContents('/ImageVariants') as $file) { + foreach ($context->files->listContents('/ImageVariants') as $file) { if (!$file->isFile()) { continue; } try { - $this->importImageVariant($file); - $numberOfImportedImageVariants ++; + $this->importImageVariant($context, $file); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to import image variant from file "%s": %s', $file->path(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to import image variant from file \"{$file->path()}\": {$e->getMessage()}"); } } - return ProcessorResult::success(sprintf('Imported %d Asset%s and %d Image Variant%s. Errors: %d', $numberOfImportedAssets, $numberOfImportedAssets === 1 ? '' : 's', $numberOfImportedImageVariants, $numberOfImportedImageVariants === 1 ? '' : 's', $numberOfErrors)); } /** --------------------------------------- */ - private function importAsset(StorageAttributes $file): void + private function importAsset(ProcessingContext $context, StorageAttributes $file): void { - $fileContents = $this->files->read($file->path()); + $fileContents = $context->files->read($file->path()); $serializedAsset = SerializedAsset::fromJson($fileContents); /** @var Asset|null $existingAsset */ $existingAsset = $this->assetRepository->findByIdentifier($serializedAsset->identifier); if ($existingAsset !== null) { if ($serializedAsset->matches($existingAsset)) { - $this->dispatch(Severity::NOTICE, 'Asset "%s" was skipped because it already exists!', $serializedAsset->identifier); + $context->dispatch(Severity::NOTICE, "Asset \"{$serializedAsset->identifier}\" was skipped because it already exists!"); } else { - $this->dispatch(Severity::ERROR, 'Asset "%s" has been changed in the meantime, it was NOT updated!', $serializedAsset->identifier); + $context->dispatch(Severity::ERROR, "Asset \"{$serializedAsset->identifier}\" has been changed in the meantime, it was NOT updated!"); } return; } /** @var PersistentResource|null $resource */ $resource = $this->resourceRepository->findBySha1AndCollectionName($serializedAsset->resource->sha1, $serializedAsset->resource->collectionName)[0] ?? null; if ($resource === null) { - $content = $this->files->read('/Resources/' . $serializedAsset->resource->sha1); + $content = $context->files->read('/Resources/' . $serializedAsset->resource->sha1); $resource = $this->resourceManager->importResourceFromContent($content, $serializedAsset->resource->filename, $serializedAsset->resource->collectionName); $resource->setMediaType($serializedAsset->resource->mediaType); } @@ -116,27 +101,28 @@ private function importAsset(StorageAttributes $file): void ObjectAccess::setProperty($asset, 'Persistence_Object_Identifier', $serializedAsset->identifier, true); $asset->setTitle($serializedAsset->title); $asset->setCaption($serializedAsset->caption); + $asset->setCopyrightNotice($serializedAsset->copyrightNotice); $this->assetRepository->add($asset); $this->persistenceManager->persistAll(); } - private function importImageVariant(StorageAttributes $file): void + private function importImageVariant(ProcessingContext $context, StorageAttributes $file): void { - $fileContents = $this->files->read($file->path()); + $fileContents = $context->files->read($file->path()); $serializedImageVariant = SerializedImageVariant::fromJson($fileContents); $existingImageVariant = $this->assetRepository->findByIdentifier($serializedImageVariant->identifier); assert($existingImageVariant === null || $existingImageVariant instanceof ImageVariant); if ($existingImageVariant !== null) { if ($serializedImageVariant->matches($existingImageVariant)) { - $this->dispatch(Severity::NOTICE, 'Image Variant "%s" was skipped because it already exists!', $serializedImageVariant->identifier); + $context->dispatch(Severity::NOTICE, "Image Variant \"{$serializedImageVariant->identifier}\" was skipped because it already exists!"); } else { - $this->dispatch(Severity::ERROR, 'Image Variant "%s" has been changed in the meantime, it was NOT updated!', $serializedImageVariant->identifier); + $context->dispatch(Severity::ERROR, "Image Variant \"{$serializedImageVariant->identifier}\" has been changed in the meantime, it was NOT updated!"); } return; } $originalImage = $this->assetRepository->findByIdentifier($serializedImageVariant->originalAssetIdentifier); if ($originalImage === null) { - $this->dispatch(Severity::ERROR, 'Failed to find original asset "%s", skipping image variant "%s"', $serializedImageVariant->originalAssetIdentifier, $serializedImageVariant->identifier); + $context->dispatch(Severity::ERROR, "Failed to find original asset \"{$serializedImageVariant->originalAssetIdentifier}\", skipping image variant \"{$serializedImageVariant->identifier}\""); return; } assert($originalImage instanceof Image); @@ -154,12 +140,4 @@ private function importImageVariant(StorageAttributes $file): void $this->assetRepository->add($imageVariant); $this->persistenceManager->persistAll(); } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } } diff --git a/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php b/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php index b7d0b486188..f32019a6e65 100644 --- a/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php @@ -1,49 +1,43 @@ */ - private array $callbacks = []; - + /** + * @param ContentStreamId $contentStreamId Identifier of the content stream to export + */ public function __construct( - private readonly Filesystem $files, - private readonly Workspace $targetWorkspace, - private readonly EventStoreInterface $eventStore, + private ContentStreamId $contentStreamId, + private EventStoreInterface $eventStore, ) { } - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { - $streamName = ContentStreamEventStreamName::fromContentStreamId($this->targetWorkspace->currentContentStreamId)->getEventStreamName(); + $streamName = ContentStreamEventStreamName::fromContentStreamId($this->contentStreamId)->getEventStreamName(); $eventStream = $this->eventStore->load($streamName); $eventFileResource = fopen('php://temp/maxmemory:5242880', 'rb+'); if ($eventFileResource === false) { - return ProcessorResult::error('Failed to create temporary event file resource'); + throw new \RuntimeException('Failed to create temporary event file resource', 1729506599); } - $numberOfExportedEvents = 0; foreach ($eventStream as $eventEnvelope) { if ($eventEnvelope->event->type->value === 'ContentStreamWasCreated') { // the content stream will be created in the import dynamically, so we prevent duplication here @@ -51,28 +45,12 @@ public function run(): ProcessorResult } $event = ExportedEvent::fromRawEvent($eventEnvelope->event); fwrite($eventFileResource, $event->toJson() . chr(10)); - $numberOfExportedEvents ++; } try { - $this->files->writeStream('events.jsonl', $eventFileResource); + $context->files->writeStream('events.jsonl', $eventFileResource); } catch (FilesystemException $e) { - return ProcessorResult::error(sprintf('Failed to write events.jsonl: %s', $e->getMessage())); + throw new \RuntimeException(sprintf('Failed to write events.jsonl: %s', $e->getMessage()), 1729506623, $e); } fclose($eventFileResource); - return ProcessorResult::success(sprintf('Exported %d event%s', $numberOfExportedEvents, $numberOfExportedEvents === 1 ? '' : 's')); - } - - /** --------------------------------------- */ - - - /** - * @phpstan-ignore-next-line currently this private method is unused ... but it does no harm keeping it - */ - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } } } diff --git a/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php b/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php index 50f4474f485..15e9c718427 100644 --- a/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php @@ -1,9 +1,10 @@ */ - private array $callbacks = []; - - private ?ContentStreamId $contentStreamId = null; - public function __construct( - private readonly bool $keepEventIds, - private readonly Filesystem $files, - private readonly EventStoreInterface $eventStore, - private readonly EventNormalizer $eventNormalizer, - ?ContentStreamId $overrideContentStreamId + private WorkspaceName $targetWorkspaceName, + private bool $keepEventIds, + private EventStoreInterface $eventStore, + private EventNormalizer $eventNormalizer, + private ContentRepository $contentRepository, ) { - if ($overrideContentStreamId) { - $this->contentStreamId = $overrideContentStreamId; - } } - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { /** @var array $domainEvents */ $domainEvents = []; - $eventFileResource = $this->files->readStream('events.jsonl'); + $eventFileResource = $context->files->readStream('events.jsonl'); /** @var array $eventIdMap */ $eventIdMap = []; - $keepStreamName = false; + $workspace = $this->contentRepository->findWorkspaceByName($this->targetWorkspaceName); + if ($workspace === null) { + throw new \InvalidArgumentException("Workspace {$this->targetWorkspaceName} does not exist", 1729530978); + } + while (($line = fgets($eventFileResource)) !== false) { - $event = ExportedEvent::fromJson(trim($line)); - if ($this->contentStreamId === null) { - $this->contentStreamId = self::extractContentStreamId($event->payload); - $keepStreamName = true; - } - if (!$keepStreamName) { - $event = $event->processPayload(fn(array $payload) => isset($payload['contentStreamId']) ? [...$payload, 'contentStreamId' => $this->contentStreamId->value] : $payload); - } + $event = + ExportedEvent::fromJson(trim($line)) + ->processPayload(fn (array $payload) => [...$payload, 'contentStreamId' => $workspace->currentContentStreamId->value, 'workspaceName' => $this->targetWorkspaceName->value]); if (!$this->keepEventIds) { try { $newEventId = Algorithms::generateUUID(); @@ -106,74 +90,17 @@ public function run(): ProcessorResult ) ); if (in_array($domainEvent::class, [ContentStreamWasCreated::class, ContentStreamWasForked::class, ContentStreamWasRemoved::class], true)) { - return ProcessorResult::error(sprintf('Failed to read events. %s is not expected in imported event stream.', $event->type)); + throw new \RuntimeException(sprintf('Failed to read events. %s is not expected in imported event stream.', $event->type), 1729506757); } $domainEvent = DecoratedEvent::create($domainEvent, eventId: EventId::fromString($event->identifier), metadata: $event->metadata); $domainEvents[] = $this->eventNormalizer->normalize($domainEvent); } - assert($this->contentStreamId !== null); - - $contentStreamStreamName = ContentStreamEventStreamName::fromContentStreamId($this->contentStreamId)->getEventStreamName(); - $events = Events::with( - $this->eventNormalizer->normalize( - new ContentStreamWasCreated( - $this->contentStreamId, - ) - ) - ); + $contentStreamStreamName = ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(); try { - $contentStreamCreationCommitResult = $this->eventStore->commit($contentStreamStreamName, $events, ExpectedVersion::NO_STREAM()); + $this->eventStore->commit($contentStreamStreamName, Events::fromArray($domainEvents), ExpectedVersion::fromVersion(Version::first())); } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish workspace events because the event stream "%s" already exists (1)', $this->contentStreamId->value)); - } - - $workspaceName = WorkspaceName::forLive(); - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName($workspaceName)->getEventStreamName(); - $events = Events::with( - $this->eventNormalizer->normalize( - new RootWorkspaceWasCreated( - $workspaceName, - $this->contentStreamId - ) - ) - ); - try { - $this->eventStore->commit($workspaceStreamName, $events, ExpectedVersion::NO_STREAM()); - } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish workspace events because the event stream "%s" already exists (2)', $workspaceStreamName->value)); - } - - try { - $this->eventStore->commit($contentStreamStreamName, Events::fromArray($domainEvents), ExpectedVersion::fromVersion($contentStreamCreationCommitResult->highestCommittedVersion)); - } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish %d events because the event stream "%s" already exists (3)', count($domainEvents), $contentStreamStreamName->value)); - } - return ProcessorResult::success(sprintf('Imported %d event%s into stream "%s"', count($domainEvents), count($domainEvents) === 1 ? '' : 's', $contentStreamStreamName->value)); - } - - /** --------------------------- */ - - /** - * @param array $payload - * @return ContentStreamId - */ - private static function extractContentStreamId(array $payload): ContentStreamId - { - if (!isset($payload['contentStreamId']) || !is_string($payload['contentStreamId'])) { - throw new \RuntimeException('Failed to extract "contentStreamId" from event', 1646404169); - } - return ContentStreamId::fromString($payload['contentStreamId']); - } - - /** - * @phpstan-ignore-next-line currently this private method is unused ... but it does no harm keeping it - */ - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); + throw new \RuntimeException(sprintf('Failed to publish %d events because the event stream "%s" for workspace "%s" already contains events.', count($domainEvents), $contentStreamStreamName->value, $workspace->workspaceName->value), 1729506818, $e); } } } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php similarity index 55% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php index 249908cc680..9f2cc2d4a93 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php @@ -18,38 +18,22 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception as DBALException; use Doctrine\DBAL\Exception\ConnectionException; -use Neos\ContentRepository\Core\Projection\CatchUpOptions; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\LegacyNodeMigration\LegacyMigrationService; -use Neos\ContentRepository\LegacyNodeMigration\LegacyMigrationServiceFactory; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Export\Severity; +use Neos\ContentRepository\LegacyNodeMigration\LegacyExportServiceFactory; use Neos\ContentRepository\LegacyNodeMigration\RootNodeTypeMapping; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; -use Neos\ContentRepositoryRegistry\Factory\EventStore\DoctrineEventStoreFactory; -use Neos\ContentRepositoryRegistry\Service\ProjectionReplayServiceFactory; use Neos\Flow\Cli\CommandController; -use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Property\PropertyMapper; -use Neos\Flow\ResourceManagement\ResourceManager; -use Neos\Flow\ResourceManagement\ResourceRepository; -use Neos\Flow\Utility\Environment; -use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\Domain\Model\Site; -use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Utility\Files; -class CrCommandController extends CommandController +class SiteCommandController extends CommandController { public function __construct( private readonly Connection $connection, - private readonly Environment $environment, - private readonly PersistenceManagerInterface $persistenceManager, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, private readonly PropertyMapper $propertyMapper, private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly SiteRepository $siteRepository, - private readonly ProjectionReplayServiceFactory $projectionReplayServiceFactory, ) { parent::__construct(); } @@ -57,12 +41,22 @@ public function __construct( /** * Migrate from the Legacy CR * - * @param bool $verbose If set, all notices will be rendered - * @param string|null $config JSON encoded configuration, for example '{"dbal": {"dbname": "some-other-db"}, "resourcesPath": "/some/absolute/path", "rootNodes": {"/sites": "Neos.Neos:Sites", "/other": "My.Package:SomeOtherRoot"}}' + * This command creates a Neos 9 export format based on the data from the specified legacy content repository database connection + * The export will be placed in the specified directory path, and can be imported via "site:importAll": + * + * ./flow site:exportLegacyData --path ./migratedContent + * ./flow site:importAll --path ./migratedContent + * + * Note that the dimension configuration and the node type schema must be migrated of the reference content repository + * + * @param string $contentRepository The reference content repository that can later be used for importing into + * @param string $path The path to the directory to export to, will be created if missing + * @param string|null $config JSON encoded configuration, for example --config '{"dbal": {"dbname": "some-other-db"}, "resourcesPath": "/absolute-path/Data/Persistent/Resources", "rootNodes": {"/sites": "Neos.Neos:Sites", "/other": "My.Package:SomeOtherRoot"}}' * @throws \Exception */ - public function migrateLegacyDataCommand(bool $verbose = false, string $config = null): void + public function exportLegacyDataCommand(string $path, string $contentRepository = 'default', string $config = null, bool $verbose = false): void { + Files::createDirectoryRecursively($path); if ($config !== null) { try { $parsedConfig = json_decode($config, true, 512, JSON_THROW_ON_ERROR); @@ -80,86 +74,36 @@ public function migrateLegacyDataCommand(bool $verbose = false, string $config = $resourcesPath = $this->determineResourcesPath(); $rootNodes = $this->getDefaultRootNodes(); if (!$this->output->askConfirmation(sprintf('Do you want to migrate nodes from the current database "%s@%s" (y/n)? ', $this->connection->getParams()['dbname'] ?? '?', $this->connection->getParams()['host'] ?? '?'))) { - $connection = $this->adjustDataBaseConnection($this->connection); + $connection = $this->adjustDatabaseConnection($this->connection); } else { $connection = $this->connection; } } $this->verifyDatabaseConnection($connection); - - $siteRows = $connection->fetchAllAssociativeIndexed('SELECT nodename, name, siteresourcespackagekey FROM neos_neos_domain_model_site'); - $siteNodeName = $this->output->select('Which site to migrate?', array_map(static fn (array $siteRow) => $siteRow['name'] . ' (' . $siteRow['siteresourcespackagekey'] . ')', $siteRows)); - assert(is_string($siteNodeName)); - $siteRow = $siteRows[$siteNodeName]; - - $site = $this->siteRepository->findOneByNodeName($siteNodeName); - if ($site !== null) { - if (!$this->output->askConfirmation(sprintf('Site "%s" already exists, update it? [n] ', $siteNodeName), false)) { - $this->outputLine('Cancelled...'); - $this->quit(); - } - - $site->setSiteResourcesPackageKey($siteRow['siteresourcespackagekey']); - $site->setState(Site::STATE_ONLINE); - $site->setName($siteRow['name']); - $this->siteRepository->update($site); - $this->persistenceManager->persistAll(); - } else { - $site = new Site($siteNodeName); - $site->setSiteResourcesPackageKey($siteRow['siteresourcespackagekey']); - $site->setState(Site::STATE_ONLINE); - $site->setName($siteRow['name']); - $this->siteRepository->add($site); - $this->persistenceManager->persistAll(); - } - - $contentRepositoryId = $site->getConfiguration()->contentRepositoryId; - - $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); - $confirmed = $this->output->askConfirmation(sprintf('We will clear the events from "%s". ARE YOU SURE [n]? ', $eventTableName), false); - if (!$confirmed) { - $this->outputLine('Cancelled...'); - $this->quit(); - } - $this->connection->executeStatement('TRUNCATE ' . $connection->quoteIdentifier($eventTableName)); - // we also need to reset the projections; in order to ensure the system runs deterministically - $projectionService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->projectionReplayServiceFactory); - $projectionService->resetAllProjections(); - $this->outputLine('Truncated events'); - - $liveContentStreamId = ContentStreamId::create(); - - $legacyMigrationService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new LegacyMigrationServiceFactory( + $legacyExportService = $this->contentRepositoryRegistry->buildService( + ContentRepositoryId::fromString($contentRepository), + new LegacyExportServiceFactory( $connection, $resourcesPath, - $this->environment, - $this->persistenceManager, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, $this->propertyMapper, - $liveContentStreamId, $rootNodes, ) ); - assert($legacyMigrationService instanceof LegacyMigrationService); - $legacyMigrationService->runAllProcessors($this->outputLine(...), $verbose); - - $this->outputLine(); + $legacyExportService->exportToPath( + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); - $this->outputLine('Replaying projections'); - $projectionService->replayAllProjections(CatchUpOptions::create()); $this->outputLine('Done'); } /** * @throws DBALException */ - private function adjustDataBaseConnection(Connection $connection): Connection + private function adjustDatabaseConnection(Connection $connection): Connection { $connectionParams = $connection->getParams(); $connectionParams['driver'] = $this->output->select(sprintf('Driver? [%s] ', $connectionParams['driver'] ?? ''), ['pdo_mysql', 'pdo_sqlite', 'pdo_pgsql'], $connectionParams['driver'] ?? null); @@ -184,7 +128,7 @@ private function verifyDatabaseConnection(Connection $connection): void } catch (ConnectionException $exception) { $this->outputLine('Failed to connect to database "%s": %s', [$connection->getDatabase(), $exception->getMessage()]); $this->outputLine('Please verify connection parameters...'); - $this->adjustDataBaseConnection($connection); + $this->adjustDatabaseConnection($connection); } } while (true); } @@ -208,6 +152,28 @@ private static function defaultResourcesPath(): string return FLOW_PATH_DATA . 'Persistent/Resources'; } + protected function createOnProcessorClosure(): \Closure + { + $onProcessor = function (string $processorLabel) { + $this->outputLine('%s...', [$processorLabel]); + }; + return $onProcessor; + } + + protected function createOnMessageClosure(bool $verbose): \Closure + { + return function (Severity $severity, string $message) use ($verbose) { + if (!$verbose && $severity === Severity::NOTICE) { + return; + } + $this->outputLine(match ($severity) { + Severity::NOTICE => $message, + Severity::WARNING => sprintf('Warning: %s', $message), + Severity::ERROR => sprintf('Error: %s', $message), + }); + }; + } + private function getDefaultRootNodes(): RootNodeTypeMapping { return RootNodeTypeMapping::fromArray(['/sites' => NodeTypeNameFactory::NAME_SITES]); diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php new file mode 100644 index 00000000000..beb74a87fee --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php @@ -0,0 +1,33 @@ +> + */ +final class DomainDataLoader implements \IteratorAggregate +{ + public function __construct( + private readonly Connection $connection, + ) { + } + + /** + * @return \Traversable> + */ + public function getIterator(): \Traversable + { + $query = $this->connection->executeQuery(' + SELECT + * + FROM + neos_neos_domain_model_domain + '); + return $query->iterateAssociative(); + } +} + + diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php new file mode 100644 index 00000000000..2d878f9302f --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php @@ -0,0 +1,33 @@ +> + */ +final class SiteDataLoader implements \IteratorAggregate +{ + public function __construct( + private readonly Connection $connection, + ) { + } + + /** + * @return \Traversable> + */ + public function getIterator(): \Traversable + { + $query = $this->connection->executeQuery(' + SELECT + * + FROM + neos_neos_domain_model_site + '); + return $query->iterateAssociative(); + } +} + + diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php new file mode 100644 index 00000000000..dc59b6a79ce --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php @@ -0,0 +1,74 @@ +connection), new FileSystemResourceLoader($this->resourcesPath)); + + $processors = Processors::fromArray([ + 'Exporting assets' => new AssetExportProcessor($this->nodeTypeManager, $assetExporter, new NodeDataLoader($this->connection)), + 'Exporting node data' => new EventExportProcessor($this->nodeTypeManager, $this->propertyMapper, $this->propertyConverter, $this->interDimensionalVariationGraph, $this->eventNormalizer, $this->rootNodeTypeMapping, new NodeDataLoader($this->connection)), + 'Exporting sites data' => new SitesExportProcessor(new SiteDataLoader($this->connection), new DomainDataLoader($this->connection)), + ]); + + $processingContext = new ProcessingContext($filesystem, $onMessage); + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($processingContext); + } + } +} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php similarity index 54% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php index 2010bc989e0..884fc639620 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php @@ -1,4 +1,5 @@ + * @implements ContentRepositoryServiceFactoryInterface */ -class LegacyMigrationServiceFactory implements ContentRepositoryServiceFactoryInterface +class LegacyExportServiceFactory implements ContentRepositoryServiceFactoryInterface { - public function __construct( private readonly Connection $connection, private readonly string $resourcesPath, - private readonly Environment $environment, - private readonly PersistenceManagerInterface $persistenceManager, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, private readonly PropertyMapper $propertyMapper, - private readonly ContentStreamId $contentStreamId, private readonly RootNodeTypeMapping $rootNodeTypeMapping, ) { } public function build( ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies - ): LegacyMigrationService - { - return new LegacyMigrationService( + ): LegacyExportService { + return new LegacyExportService( $this->connection, $this->resourcesPath, - $this->environment, - $this->persistenceManager, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, $serviceFactoryDependencies->interDimensionalVariationGraph, $serviceFactoryDependencies->nodeTypeManager, $this->propertyMapper, $serviceFactoryDependencies->eventNormalizer, $serviceFactoryDependencies->propertyConverter, - $serviceFactoryDependencies->eventStore, - $this->contentStreamId, $this->rootNodeTypeMapping, ); } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php deleted file mode 100644 index f2ef4113a04..00000000000 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php +++ /dev/null @@ -1,98 +0,0 @@ -environment->getPathToTemporaryDirectory() . uniqid('Export', true); - Files::createDirectoryRecursively($temporaryFilePath); - $filesystem = new Filesystem(new LocalFilesystemAdapter($temporaryFilePath)); - - $assetExporter = new AssetExporter($filesystem, new DbalAssetLoader($this->connection), new FileSystemResourceLoader($this->resourcesPath)); - - /** @var ProcessorInterface[] $processors */ - $processors = [ - 'Exporting assets' => new NodeDataToAssetsProcessor($this->nodeTypeManager, $assetExporter, new NodeDataLoader($this->connection)), - 'Exporting node data' => new NodeDataToEventsProcessor($this->nodeTypeManager, $this->propertyMapper, $this->propertyConverter, $this->interDimensionalVariationGraph, $this->eventNormalizer, $filesystem, $this->rootNodeTypeMapping,new NodeDataLoader($this->connection)), - 'Importing assets' => new AssetRepositoryImportProcessor($filesystem, $this->assetRepository, $this->resourceRepository, $this->resourceManager, $this->persistenceManager), - 'Importing events' => new EventStoreImportProcessor(true, $filesystem, $this->eventStore, $this->eventNormalizer, $this->contentStreamId), - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $processor->onMessage(function (Severity $severity, string $message) use ($verbose, $outputLineFn) { - if ($severity !== Severity::NOTICE || $verbose) { - $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]); - } - }); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . $result->message); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - Files::unlink($temporaryFilePath); - } -} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php similarity index 63% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php index 86d38a6d8a5..57794d129eb 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php @@ -1,29 +1,25 @@ */ private array $processedAssetIds = []; - /** - * @var array<\Closure> - */ - private array $callbacks = []; /** * @param iterable> $nodeDataRows @@ -32,16 +28,11 @@ public function __construct( private readonly NodeTypeManager $nodeTypeManager, private readonly AssetExporter $assetExporter, private readonly iterable $nodeDataRows, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { - $numberOfErrors = 0; foreach ($this->nodeDataRows as $nodeDataRow) { if ($nodeDataRow['path'] === '/sites') { // the sites node has no properties and is unstructured @@ -50,21 +41,20 @@ public function run(): ProcessorResult $nodeTypeName = NodeTypeName::fromString($nodeDataRow['nodetype']); $nodeType = $this->nodeTypeManager->getNodeType($nodeTypeName); if (!$nodeType) { - $this->dispatch(Severity::ERROR, 'The node type "%s" is not available. Node: "%s"', $nodeTypeName->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::ERROR, "The node type \"{$nodeTypeName->value}\" is not available. Node: \"{$nodeDataRow['identifier']}\""); continue; } try { $properties = json_decode($nodeDataRow['properties'], true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to JSON-decode properties %s of node "%s" (type: "%s"): %s', $nodeDataRow['properties'], $nodeDataRow['identifier'], $nodeTypeName->value, $exception->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to JSON-decode properties {$nodeDataRow['properties']} of node \"{$nodeDataRow['identifier']}\" (type: \"{$nodeTypeName->value}\"): {$exception->getMessage()}"); continue; } foreach ($properties as $propertyName => $propertyValue) { try { $propertyType = $nodeType->getPropertyType($propertyName); - } catch (\InvalidArgumentException $exception) { - $this->dispatch(Severity::WARNING, 'Skipped node data processing for the property "%s". The property name is not part of the NodeType schema for the NodeType "%s". (Node: %s)', $propertyName, $nodeType->name->value, $nodeDataRow['identifier']); + } catch (\InvalidArgumentException $e) { + $context->dispatch(Severity::WARNING, "Skipped node data processing for the property \"{$propertyName}\". The property name is not part of the NodeType schema for the NodeType \"{$nodeType->name->value}\". (Node: {$nodeDataRow['identifier']})"); continue; } foreach ($this->extractAssetIdentifiers($propertyType, $propertyValue) as $assetId) { @@ -75,15 +65,11 @@ public function run(): ProcessorResult try { $this->assetExporter->exportAsset($assetId); } catch (\Exception $exception) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to extract assets of property "%s" of node "%s" (type: "%s"): %s', $propertyName, $nodeDataRow['identifier'], $nodeTypeName->value, $exception->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to extract assets of property \"{$propertyName}\" of node \"{$nodeDataRow['identifier']}\" (type: \"{$nodeTypeName->value}\"): {$exception->getMessage()}"); } } } } - $numberOfExportedAssets = count($this->processedAssetIds); - $this->processedAssetIds = []; - return ProcessorResult::success(sprintf('Exported %d asset%s. Errors: %d', $numberOfExportedAssets, $numberOfExportedAssets === 1 ? '' : 's', $numberOfErrors)); } /** ----------------------------- */ @@ -110,8 +96,7 @@ private function extractAssetIdentifiers(string $type, mixed $value): array if ($parsedType['elementType'] === null) { return []; } - if (!is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class, true) - && !is_subclass_of($parsedType['elementType'], \Stringable::class, true)) { + if (!is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class) && !is_subclass_of($parsedType['elementType'], \Stringable::class)) { return []; } /** @var array> $assetIdentifiers */ @@ -122,13 +107,4 @@ private function extractAssetIdentifiers(string $type, mixed $value): array } return array_merge(...$assetIdentifiers); } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } - } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php similarity index 85% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php index 4358241bcc4..6d547c385bc 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php @@ -1,12 +1,11 @@ - */ - private array $callbacks = []; private WorkspaceName $workspaceName; private ContentStreamId $contentStreamId; private VisitedNodeAggregates $visitedNodes; @@ -73,8 +69,6 @@ final class NodeDataToEventsProcessor implements ProcessorInterface private int $numberOfExportedEvents = 0; - private bool $metaDataExported = false; - /** * @var resource|null */ @@ -89,7 +83,6 @@ public function __construct( private readonly PropertyConverter $propertyConverter, private readonly InterDimensionalVariationGraph $interDimensionalVariationGraph, private readonly EventNormalizer $eventNormalizer, - private readonly Filesystem $files, private readonly RootNodeTypeMapping $rootNodeTypeMapping, private readonly iterable $nodeDataRows, ) { @@ -98,17 +91,7 @@ public function __construct( $this->visitedNodes = new VisitedNodeAggregates(); } - public function setContentStreamId(ContentStreamId $contentStreamId): void - { - $this->contentStreamId = $contentStreamId; - } - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $this->resetRuntimeState(); @@ -122,15 +105,7 @@ public function run(): ProcessorResult continue; } } - if ($this->metaDataExported === false && $nodeDataRow['parentpath'] === '/sites') { - $this->exportMetaData($nodeDataRow); - $this->metaDataExported = true; - } - try { - $this->processNodeData($nodeDataRow); - } catch (MigrationException $exception) { - return ProcessorResult::error($exception->getMessage()); - } + $this->processNodeData($context, $nodeDataRow); } // Set References, now when the full import is done. foreach ($this->nodeReferencesWereSetEvents as $nodeReferencesWereSetEvent) { @@ -138,11 +113,10 @@ public function run(): ProcessorResult } try { - $this->files->writeStream('events.jsonl', $this->eventFileResource); - } catch (FilesystemException $exception) { - return ProcessorResult::error(sprintf('Failed to write events.jsonl: %s', $exception->getMessage())); + $context->files->writeStream('events.jsonl', $this->eventFileResource); + } catch (FilesystemException $e) { + throw new \RuntimeException(sprintf('Failed to write events.jsonl: %s', $e->getMessage()), 1729506930, $e); } - return ProcessorResult::success(sprintf('Exported %d event%s', $this->numberOfExportedEvents, $this->numberOfExportedEvents === 1 ? '' : 's')); } /** ----------------------------- */ @@ -152,7 +126,6 @@ private function resetRuntimeState(): void $this->visitedNodes = new VisitedNodeAggregates(); $this->nodeReferencesWereSetEvents = []; $this->numberOfExportedEvents = 0; - $this->metaDataExported = false; $this->eventFileResource = fopen('php://temp/maxmemory:5242880', 'rb+') ?: null; Assert::resource($this->eventFileResource, null, 'Failed to create temporary event file resource'); } @@ -165,6 +138,8 @@ private function exportEvent(EventInterface $event): void } catch (\JsonException $e) { throw new \RuntimeException(sprintf('Failed to JSON-decode "%s": %s', $normalizedEvent->data->value, $e->getMessage()), 1723032243, $e); } + // do not export crid and workspace as they are always imported into a single workspace + unset($exportedEventPayload['contentStreamId'], $exportedEventPayload['workspaceName']); $exportedEvent = new ExportedEvent( $normalizedEvent->id->value, $normalizedEvent->type->value, @@ -179,24 +154,7 @@ private function exportEvent(EventInterface $event): void /** * @param array $nodeDataRow */ - private function exportMetaData(array $nodeDataRow): void - { - if ($this->files->fileExists('meta.json')) { - $data = json_decode($this->files->read('meta.json'), true, 512, JSON_THROW_ON_ERROR); - } else { - $data = []; - } - $data['version'] = 1; - $data['sitePackageKey'] = strtok($nodeDataRow['nodetype'], ':'); - $data['siteNodeName'] = substr($nodeDataRow['path'], 7); - $data['siteNodeType'] = $nodeDataRow['nodetype']; - $this->files->write('meta.json', json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); - } - - /** - * @param array $nodeDataRow - */ - private function processNodeData(array $nodeDataRow): void + private function processNodeData(ProcessingContext $context, array $nodeDataRow): void { $nodeAggregateId = NodeAggregateId::fromString($nodeDataRow['identifier']); @@ -226,11 +184,11 @@ private function processNodeData(array $nodeDataRow): void foreach ($this->interDimensionalVariationGraph->getDimensionSpacePoints() as $dimensionSpacePoint) { $originDimensionSpacePoint = OriginDimensionSpacePoint::fromDimensionSpacePoint($dimensionSpacePoint); if (!$this->visitedNodes->alreadyVisitedOriginDimensionSpacePoints($nodeAggregateId)->contains($originDimensionSpacePoint)) { - $this->processNodeDataWithoutFallbackToEmptyDimension($nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); + $this->processNodeDataWithoutFallbackToEmptyDimension($context, $nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); } } } else { - $this->processNodeDataWithoutFallbackToEmptyDimension($nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); + $this->processNodeDataWithoutFallbackToEmptyDimension($context, $nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); } } @@ -241,12 +199,12 @@ private function processNodeData(array $nodeDataRow): void * @param array $nodeDataRow * @return NodeName[]|void */ - public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $originDimensionSpacePoint, array $nodeDataRow) + public function processNodeDataWithoutFallbackToEmptyDimension(ProcessingContext $context, NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $originDimensionSpacePoint, array $nodeDataRow) { $nodePath = NodePath::fromString(strtolower($nodeDataRow['path'])); $parentNodeAggregate = $this->visitedNodes->findMostSpecificParentNodeInDimensionGraph($nodePath, $originDimensionSpacePoint, $this->interDimensionalVariationGraph); if ($parentNodeAggregate === null) { - $this->dispatch(Severity::ERROR, 'Failed to find parent node for node with id "%s" and dimensions: %s. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes.', $nodeAggregateId->value, $originDimensionSpacePoint->toJson()); + $context->dispatch(Severity::ERROR, "Failed to find parent node for node with id \"{$nodeAggregateId->value}\" and dimensions: {$originDimensionSpacePoint->toJson()}. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes."); return; } $pathParts = $nodePath->getParts(); @@ -257,18 +215,18 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ $nodeType = $this->nodeTypeManager->getNodeType($nodeTypeName); $isSiteNode = $nodeDataRow['parentpath'] === '/sites'; - if ($isSiteNode && !$nodeType?->isOfType(NodeTypeNameFactory::NAME_SITE)) { - throw new MigrationException(sprintf( - 'The site node "%s" (type: "%s") must be of type "%s"', $nodeDataRow['identifier'], $nodeTypeName->value, NodeTypeNameFactory::NAME_SITE - ), 1695801620); - } if (!$nodeType) { - $this->dispatch(Severity::ERROR, 'The node type "%s" is not available. Node: "%s"', $nodeTypeName->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::ERROR, "The node type \"{$nodeTypeName->value}\" is not available. Node: \"{$nodeDataRow['identifier']}\""); return; } - $serializedPropertyValuesAndReferences = $this->extractPropertyValuesAndReferences($nodeDataRow, $nodeType); + if ($isSiteNode && !$nodeType->isOfType(NodeTypeNameFactory::NAME_SITE)) { + $declaredSuperTypes = array_keys($nodeType->getDeclaredSuperTypes()); + throw new MigrationException(sprintf('The site node "%s" (type: "%s") must be of type "%s". Currently declared super types: "%s"', $nodeDataRow['identifier'], $nodeTypeName->value, NodeTypeNameFactory::NAME_SITE, join(',', $declaredSuperTypes)), 1695801620); + } + + $serializedPropertyValuesAndReferences = $this->extractPropertyValuesAndReferences($context, $nodeDataRow, $nodeType); if ($this->isAutoCreatedChildNode($parentNodeAggregate->nodeTypeName, $nodeName) && !$this->visitedNodes->containsNodeAggregate($nodeAggregateId)) { // Create tethered node if the node was not found before. @@ -329,7 +287,7 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ /** * @param array $nodeDataRow */ - public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType $nodeType): SerializedPropertyValuesAndReferences + public function extractPropertyValuesAndReferences(ProcessingContext $context, array $nodeDataRow, NodeType $nodeType): SerializedPropertyValuesAndReferences { $properties = []; $references = []; @@ -359,7 +317,7 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } if (!$nodeType->hasProperty($propertyName)) { - $this->dispatch(Severity::WARNING, 'Skipped node data processing for the property "%s". The property name is not part of the NodeType schema for the NodeType "%s". (Node: %s)', $propertyName, $nodeType->name->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::WARNING, "Skipped node data processing for the property \"{$propertyName}\". The property name is not part of the NodeType schema for the NodeType \"{$nodeType->name->value}\". (Node: {$nodeDataRow['identifier']})"); continue; } $type = $nodeType->getPropertyType($propertyName); @@ -372,7 +330,6 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } else { $properties[$propertyName] = $this->propertyMapper->convert($propertyValue, $type); } - } catch (\Exception $e) { throw new MigrationException(sprintf('Failed to convert property "%s" of type "%s" (Node: %s): %s', $propertyName, $type, $nodeDataRow['identifier'], $e->getMessage()), 1655912878, $e); } @@ -394,7 +351,7 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } } else { if ($nodeDataRow['hiddenbeforedatetime'] || $nodeDataRow['hiddenafterdatetime']) { - $this->dispatch(Severity::WARNING, 'Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them.'); + $context->dispatch(Severity::WARNING, 'Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them.'); } } @@ -505,14 +462,6 @@ private function isAutoCreatedChildNode(NodeTypeName $parentNodeTypeName, NodeNa return $nodeTypeOfParent->tetheredNodeTypeDefinitions->contain($nodeName); } - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } - /** * Determines actual hidden state based on "hidden", "hiddenafterdatetime" and "hiddenbeforedatetime" * @@ -530,7 +479,8 @@ private function isNodeHidden(array $nodeDataRow): bool $hiddenBeforeDateTime = $nodeDataRow['hiddenbeforedatetime'] ? new \DateTimeImmutable($nodeDataRow['hiddenbeforedatetime']) : null; // Hidden after a date time, without getting already re-enabled by hidden before date time - afterward - if ($hiddenAfterDateTime != null + if ( + $hiddenAfterDateTime != null && $hiddenAfterDateTime < $now && ( $hiddenBeforeDateTime == null @@ -542,7 +492,8 @@ private function isNodeHidden(array $nodeDataRow): bool } // Hidden before a date time, without getting enabled by hidden after date time - before - if ($hiddenBeforeDateTime != null + if ( + $hiddenBeforeDateTime != null && $hiddenBeforeDateTime > $now && ( $hiddenAfterDateTime == null diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php new file mode 100644 index 00000000000..0f2510586a3 --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php @@ -0,0 +1,68 @@ +> $siteRows + * @param iterable> $domainRows + */ + public function __construct( + private readonly iterable $siteRows, + private readonly iterable $domainRows + ) { + } + + public function run(ProcessingContext $context): void + { + $sitesData = $this->getSiteData(); + $context->files->write('sites.json', json_encode($sitesData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * @return SiteShape[] + */ + private function getSiteData(): array + { + $siteData = []; + foreach ($this->siteRows as $siteRow) { + $siteData[] = [ + "name" => $siteRow['name'], + "nodeName" => $siteRow['nodename'], + "siteResourcesPackageKey" => $siteRow['siteresourcespackagekey'], + "online" => $siteRow['state'] === 1, + "domains" => array_values( + array_filter( + array_map( + function(array $domainRow) use ($siteRow) { + if ($siteRow['persistence_object_identifier'] !== $domainRow['site']) { + return null; + } + return [ + 'hostname' => $domainRow['hostname'], + 'scheme' => $domainRow['scheme'], + 'port' => $domainRow['port'], + 'active' => (bool)$domainRow['active'], + 'primary' => $domainRow['persistence_object_identifier'] === $siteRow['primarydomain'], + ]; + }, + iterator_to_array($this->domainRows) + ) + ) + ) + ]; + } + + return $siteData; + } +} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php index eeae7a06620..bf3dbb7a436 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php @@ -1,13 +1,13 @@ */ private array $mockResources = []; /** @var array */ private array $mockAssets = []; - private InMemoryFilesystemAdapter $mockFilesystemAdapter; - private Filesystem $mockFilesystem; - - private ProcessorResult|null $lastMigrationResult = null; - - /** - * @var array - */ - private array $loggedErrors = []; - - /** - * @var array - */ - private array $loggedWarnings = []; protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -76,21 +61,7 @@ public function __construct() self::bootstrapFlow(); $this->contentRepositoryRegistry = $this->getObject(ContentRepositoryRegistry::class); - $this->mockFilesystemAdapter = new InMemoryFilesystemAdapter(); - $this->mockFilesystem = new Filesystem($this->mockFilesystemAdapter); - } - - /** - * @AfterScenario - */ - public function failIfLastMigrationHasErrors(): void - { - if ($this->lastMigrationResult !== null && $this->lastMigrationResult->severity === Severity::ERROR) { - throw new \RuntimeException(sprintf('The last migration run led to an error: %s', $this->lastMigrationResult->message)); - } - if ($this->loggedErrors !== []) { - throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->loggedErrors), count($this->loggedErrors) === 1 ? '' : 's')); - } + $this->setupCrImportExportTrait(); } /** @@ -115,20 +86,18 @@ public function iHaveTheFollowingNodeDataRows(TableNode $nodeDataRows): void } /** - * @When /^I run the event migration for content stream (.*) with rootNode mapping (.*)$/ + * @When /^I run the event migration with rootNode mapping (.*)$/ */ - public function iRunTheEventMigrationForContentStreamWithRootnodeMapping(string $contentStream = null, string $rootNodeMapping): void + public function iRunTheEventMigrationWithRootnodeMapping(string $rootNodeMapping): void { - $contentStream = trim($contentStream, '"'); $rootNodeTypeMapping = RootNodeTypeMapping::fromArray(json_decode($rootNodeMapping, true)); - $this->iRunTheEventMigration($contentStream, $rootNodeTypeMapping); + $this->iRunTheEventMigration($rootNodeTypeMapping); } /** * @When I run the event migration - * @When I run the event migration for content stream :contentStream */ - public function iRunTheEventMigration(string $contentStream = null, RootNodeTypeMapping $rootNodeTypeMapping = null): void + public function iRunTheEventMigration(RootNodeTypeMapping $rootNodeTypeMapping = null): void { $nodeTypeManager = $this->currentContentRepository->getNodeTypeManager(); $propertyMapper = $this->getObject(PropertyMapper::class); @@ -146,96 +115,17 @@ public function build(ContentRepositoryServiceFactoryDependencies $serviceFactor }; $this->getContentRepositoryService($propertyConverterAccess); - $migration = new NodeDataToEventsProcessor( + $eventExportProcessor = new EventExportProcessor( $nodeTypeManager, $propertyMapper, $propertyConverterAccess->propertyConverter, $this->currentContentRepository->getVariationGraph(), $this->getObject(EventNormalizer::class), - $this->mockFilesystem, $rootNodeTypeMapping ?? RootNodeTypeMapping::fromArray(['/sites' => NodeTypeNameFactory::NAME_SITES]), $this->nodeDataRows ); - if ($contentStream !== null) { - $migration->setContentStreamId(ContentStreamId::fromString($contentStream)); - } - $migration->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->loggedWarnings[] = $message; - } - }); - $this->lastMigrationResult = $migration->run(); - } - - /** - * @Then I expect the following events to be exported - */ - public function iExpectTheFollowingEventsToBeExported(TableNode $table): void - { - - if (!$this->mockFilesystem->has('events.jsonl')) { - Assert::fail('No events were exported'); - } - $eventsJson = $this->mockFilesystem->read('events.jsonl'); - $exportedEvents = iterator_to_array(ExportedEvents::fromJsonl($eventsJson)); - - $expectedEvents = $table->getHash(); - foreach ($exportedEvents as $exportedEvent) { - $expectedEventRow = array_shift($expectedEvents); - if ($expectedEventRow === null) { - Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); - } - if (!empty($expectedEventRow['Type'])) { - Assert::assertSame($expectedEventRow['Type'], $exportedEvent->type, 'Event: ' . $exportedEvent->toJson()); - } - try { - $expectedEventPayload = json_decode($expectedEventRow['Payload'], true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode expected JSON: %s', $expectedEventRow['Payload']), 1655811083); - } - $actualEventPayload = $exportedEvent->payload; - foreach (array_keys($actualEventPayload) as $key) { - if (!array_key_exists($key, $expectedEventPayload)) { - unset($actualEventPayload[$key]); - } - } - Assert::assertEquals($expectedEventPayload, $actualEventPayload, 'Actual event: ' . $exportedEvent->toJson()); - } - Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); - } - - /** - * @Then I expect the following errors to be logged - */ - public function iExpectTheFollowingErrorsToBeLogged(TableNode $table): void - { - Assert::assertSame($table->getColumn(0), $this->loggedErrors, 'Expected logged errors do not match'); - $this->loggedErrors = []; - } - /** - * @Then I expect the following warnings to be logged - */ - public function iExpectTheFollowingWarningsToBeLogged(TableNode $table): void - { - Assert::assertSame($table->getColumn(0), $this->loggedWarnings, 'Expected logged warnings do not match'); - $this->loggedWarnings = []; - } - - /** - * @Then I expect a MigrationError - * @Then I expect a MigrationError with the message - */ - public function iExpectAMigrationErrorWithTheMessage(PyStringNode $expectedMessage = null): void - { - Assert::assertNotNull($this->lastMigrationResult, 'Expected the previous migration to contain errors, but no migration has been executed'); - Assert::assertSame(Severity::ERROR, $this->lastMigrationResult->severity, sprintf('Expected the previous migration to contain errors, but it ended with severity "%s"', $this->lastMigrationResult->severity->name)); - if ($expectedMessage !== null) { - Assert::assertSame($expectedMessage->getRaw(), $this->lastMigrationResult->message); - } - $this->lastMigrationResult = null; + $this->runCrImportExportProcessors($eventExportProcessor); } /** @@ -276,36 +166,20 @@ public function theFollowingAssetsExist(TableNode $images): void } } - /** - * @Given the following ImageVariants exist - */ - public function theFollowingImageVariantsExist(TableNode $imageVariants): void - { - foreach ($imageVariants->getHash() as $variantData) { - try { - $variantData['imageAdjustments'] = json_decode($variantData['imageAdjustments'], true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new \RuntimeException(sprintf('Failed to JSON decode imageAdjustments for variant "%s"', $variantData['identifier']), 1659530081, $e); - } - $variantData['width'] = (int)$variantData['width']; - $variantData['height'] = (int)$variantData['height']; - $mockImageVariant = SerializedImageVariant::fromArray($variantData); - $this->mockAssets[$mockImageVariant->identifier] = $mockImageVariant; - } - } - /** * @When I run the asset migration */ public function iRunTheAssetMigration(): void { $nodeTypeManager = $this->currentContentRepository->getNodeTypeManager(); - $mockResourceLoader = new class ($this->mockResources) implements ResourceLoaderInterface { - + $mockResourceLoader = new class ($this->mockResources) implements ResourceLoaderInterface + { /** * @param array $mockResources */ - public function __construct(private array $mockResources) {} + public function __construct(private array $mockResources) + { + } public function getStreamBySha1(string $sha1) { @@ -322,7 +196,9 @@ public function getStreamBySha1(string $sha1) /** * @param array $mockAssets */ - public function __construct(private array $mockAssets) {} + public function __construct(private array $mockAssets) + { + } public function findAssetById(string $assetId): SerializedAsset|SerializedImageVariant { @@ -333,68 +209,47 @@ public function findAssetById(string $assetId): SerializedAsset|SerializedImageV } }; - $this->mockFilesystemAdapter->deleteEverything(); - $assetExporter = new AssetExporter($this->mockFilesystem, $mockAssetLoader, $mockResourceLoader); - $migration = new NodeDataToAssetsProcessor($nodeTypeManager, $assetExporter, $this->nodeDataRows); - $migration->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->loggedWarnings[] = $message; - } - }); - $this->lastMigrationResult = $migration->run(); + $assetExporter = new AssetExporter($this->crImportExportTrait_filesystem, $mockAssetLoader, $mockResourceLoader); + $migration = new AssetExportProcessor($nodeTypeManager, $assetExporter, $this->nodeDataRows); + $this->runCrImportExportProcessors($migration); } /** - * @Then /^I expect the following (Assets|ImageVariants) to be exported:$/ + * @When I have the following site data rows: */ - public function iExpectTheFollowingToBeExported(string $type, PyStringNode $expectedAssets): void + public function iHaveTheFollowingSiteDataRows(TableNode $siteDataRows): void { - $actualAssets = []; - if (!$this->mockFilesystem->directoryExists($type)) { - Assert::fail(sprintf('No %1$s have been exported (Directory "/%1$s" does not exist)', $type)); - } - /** @var FileAttributes $file */ - foreach ($this->mockFilesystem->listContents($type) as $file) { - $actualAssets[] = json_decode($this->mockFilesystem->read($file->path()), true, 512, JSON_THROW_ON_ERROR); - } - Assert::assertJsonStringEqualsJsonString($expectedAssets->getRaw(), json_encode($actualAssets, JSON_THROW_ON_ERROR)); - } - - /** - * @Then /^I expect no (Assets|ImageVariants) to be exported$/ - */ - public function iExpectNoAssetsToBeExported(string $type): void - { - Assert::assertFalse($this->mockFilesystem->directoryExists($type)); + $this->siteDataRows = array_map( + fn (array $row) => array_map( + fn(string $value) => json_decode($value, true), + $row + ), + $siteDataRows->getHash() + ); } /** - * @Then I expect the following PersistentResources to be exported: + * @When I have the following domain data rows: */ - public function iExpectTheFollowingPersistentResourcesToBeExported(TableNode $expectedResources): void + public function iHaveTheFollowingDomainDataRows(TableNode $domainDataRows): void { - $actualResources = []; - if (!$this->mockFilesystem->directoryExists('Resources')) { - Assert::fail('No PersistentResources have been exported (Directory "/Resources" does not exist)'); - } - /** @var FileAttributes $file */ - foreach ($this->mockFilesystem->listContents('Resources') as $file) { - $actualResources[] = ['Filename' => basename($file->path()), 'Contents' => $this->mockFilesystem->read($file->path())]; - } - Assert::assertSame($expectedResources->getHash(), $actualResources); + $this->domainDataRows = array_map(static function (array $row) { + return array_map( + fn(string $value) => json_decode($value, true), + $row + ); + }, $domainDataRows->getHash()); } /** - * @Then /^I expect no PersistentResources to be exported$/ + * @When I run the site migration */ - public function iExpectNoPersistentResourcesToBeExported(): void + public function iRunTheSiteMigration(): void { - Assert::assertFalse($this->mockFilesystem->directoryExists('Resources')); + $migration = new SitesExportProcessor($this->siteDataRows, $this->domainDataRows); + $this->runCrImportExportProcessors($migration); } - /** ---------------------------------- */ /** diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature index 6dd05a8d31c..f99d5d3c399 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature @@ -22,8 +22,8 @@ Feature: Simple migrations without content dimensions | Identifier | Path | Node Type | Properties | | sites-node-id | /sites | unstructured | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature index 49b2b021c08..3dc37a50acc 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature @@ -5,6 +5,7 @@ Feature: Exceptional cases during migrations Given using no content dimensions And using the following node types: """yaml + 'unstructured': {} 'Neos.Neos:Site': {} 'Some.Package:Homepage': superTypes: @@ -34,7 +35,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | | site-node-id | /sites/test-site | Some.Package:SomeOtherHomepage | {"language": ["en"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node aggregate with id "site-node-id" has a type of "Some.Package:SomeOtherHomepage" in content dimension [{"language":"en"}]. I was visited previously for content dimension [{"language":"de"}] with the type "Some.Package:Homepage". Node variants must not have different types """ @@ -94,7 +95,7 @@ Feature: Exceptional cases during migrations | sites | /sites | | | a | /sites/a | not json | And I run the event migration - Then I expect a MigrationError + Then I expect a migration exception Scenario: Invalid node properties (no JSON) When I have the following node data rows: @@ -102,7 +103,7 @@ Feature: Exceptional cases during migrations | sites | /sites | | | | a | /sites/a | not json | Some.Package:Homepage | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Failed to decode properties "not json" of node "a" (type: "Some.Package:Homepage"): Could not convert database value "not json" to Doctrine Type flow_json_array """ @@ -118,7 +119,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["ch"]} | | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["ch"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node "site-node-id" with dimension space point "{"language":"ch"}" was already visited before """ @@ -133,7 +134,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node "site-node-id" for dimension {"language":"de"} was already created previously """ @@ -144,7 +145,7 @@ Feature: Exceptional cases during migrations | sites-node-id | /sites | unstructured | | site-node-id | /sites/test-site | unstructured | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ - The site node "site-node-id" (type: "unstructured") must be of type "Neos.Neos:Site" + The site node "site-node-id" (type: "unstructured") must be of type "Neos.Neos:Site". Currently declared super types: "" """ diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature index ef3ec403e0e..bb212a59393 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature @@ -30,46 +30,46 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 1 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden" property false must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | Scenario: A node with active "hidden after" property, after a "hidden before" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 1989-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with active "hidden before" property, after a "hidden after" property must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1989-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden before" property and a "hidden after" property in future must not get disabled @@ -77,90 +77,90 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden after" property and a "hidden before" property in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden after" property in future and a "hidden before" property later in future must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2098-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | Scenario: A node with a "hidden before" property in future and a "hidden after" property later in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 2098-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a active "hidden before" property must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden after" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden after" property in future must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | Scenario: A node with a "hidden before" property in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature index bb701dab822..59d82c5b8e8 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature @@ -23,35 +23,35 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 1 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden" property false must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | Scenario: A node with active "hidden after" property, after a "hidden before" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 1989-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -60,11 +60,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1989-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -73,11 +73,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -86,12 +86,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -100,11 +100,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2098-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -113,12 +113,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 2098-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -127,11 +127,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -140,12 +140,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -154,11 +154,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -167,11 +167,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature index 2834b27a237..6fd919dbf24 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature @@ -24,7 +24,7 @@ Feature: Simple migrations without content dimensions but other root nodetype na | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | | test-root-node-id | /test | unstructured | | | test-node-id | /test/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following errors to be logged | Failed to find parent node for node with id "test-root-node-id" and dimensions: []. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes. | | Failed to find parent node for node with id "test-node-id" and dimensions: []. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes. | @@ -37,10 +37,10 @@ Feature: Simple migrations without content dimensions but other root nodetype na | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | | test-root-node-id | /test | unstructured | | | test-node-id | /test/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" with rootNode mapping {"/sites": "Neos.Neos:Sites", "/test": "Neos.ContentRepository.LegacyNodeMigration:TestRoot"} + And I run the event migration with rootNode mapping {"/sites": "Neos.Neos:Sites", "/test": "Neos.ContentRepository.LegacyNodeMigration:TestRoot"} Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "test-root-node-id", "nodeTypeName": "Neos.ContentRepository.LegacyNodeMigration:TestRoot", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "test-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "test-root-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "test-root-node-id", "nodeTypeName": "Neos.ContentRepository.LegacyNodeMigration:TestRoot", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "test-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "test-root-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature new file mode 100644 index 00000000000..12a1e808c17 --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature @@ -0,0 +1,46 @@ +@contentrepository +Feature: Simple migrations without content dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.Neos:Site': {} + 'Some.Package:Homepage': + superTypes: + 'Neos.Neos:Site': true + properties: + 'text': + type: string + defaultValue: 'My default text' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + + Scenario: Site records without domains + When I have the following site data rows: + | persistence_object_identifier | name | nodename | siteresourcespackagekey | state | domains | primarydomain | + | "site1" | "Site 1" | "site_1_node" | "Site1.Package" | 1 | null | null | + | "site2" | "Site 2" | "site_2_node" | "Site2.Package" | 2 | null | null | + And I run the site migration + Then I expect the following sites to be exported + | name | nodeName | siteResourcesPackageKey | online | domains | + | "Site 1" | "site_1_node" | "Site1.Package" | true | [] | + | "Site 2" | "site_2_node" | "Site2.Package" | false | [] | + + Scenario: Site records with domains + When I have the following site data rows: + | persistence_object_identifier | name | nodename | siteresourcespackagekey | state | domains | primarydomain | + | "site1" | "Site 1" | "site_1_node" | "Site1.Package" | 1 | null | "domain2" | + | "site2" | "Site 2" | "site_2_node" | "Site2.Package" | 1 | null | null | + When I have the following domain data rows: + | persistence_object_identifier | hostname | scheme | port | active | site | + | "domain1" | "domain_1.tld" | "https" | 123 | true | "site1" | + | "domain2" | "domain_2.tld" | "http" | null | true | "site1" | + | "domain3" | "domain_3.tld" | null | null | true | "site2" | + | "domain4" | "domain_4.tld" | null | null | false | "site2" | + And I run the site migration + Then I expect the following sites to be exported + | name | nodeName | siteResourcesPackageKey | online | domains | + | "Site 1" | "site_1_node" | "Site1.Package" | true | [{"hostname": "domain_1.tld", "scheme": "https", "port": 123, "active": true, "primary": false},{"hostname": "domain_2.tld", "scheme": "http", "port": null, "active": true, "primary": true}] | + | "Site 2" | "site_2_node" | "Site2.Package" | true | [{"hostname": "domain_3.tld", "scheme": null, "port": null, "active": true, "primary": false},{"hostname": "domain_4.tld", "scheme": null, "port": null, "active": false, "primary": false}] | diff --git a/Neos.ContentRepository.LegacyNodeMigration/composer.json b/Neos.ContentRepository.LegacyNodeMigration/composer.json index acb3ddbb2d1..e6a0f1c067f 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/composer.json +++ b/Neos.ContentRepository.LegacyNodeMigration/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": ">=8.2", + "neos/neos": "self.version", "neos/contentrepository-core": "self.version", "neos/contentrepository-export": "self.version", "league/flysystem": "^3" diff --git a/Neos.ContentRepository.NodeMigration/src/NodeMigrationServiceFactory.php b/Neos.ContentRepository.NodeMigration/src/NodeMigrationServiceFactory.php index f6c3f5fee33..7a293ea5ae0 100644 --- a/Neos.ContentRepository.NodeMigration/src/NodeMigrationServiceFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/NodeMigrationServiceFactory.php @@ -39,7 +39,7 @@ public function build(ContentRepositoryServiceFactoryDependencies $serviceFactor $filtersFactory->registerFilter('PropertyNotEmpty', new PropertyNotEmptyFilterFactory()); $filtersFactory->registerFilter('PropertyValue', new PropertyValueFilterFactory()); - $transformationsFactory = new TransformationsFactory($serviceFactoryDependencies->contentRepository); + $transformationsFactory = new TransformationsFactory($serviceFactoryDependencies->contentRepository, $serviceFactoryDependencies->propertyConverter); $transformationsFactory->registerTransformation('AddDimensionShineThrough', new AddDimensionShineThroughTransformationFactory()); $transformationsFactory->registerTransformation('AddNewProperty', new AddNewPropertyTransformationFactory()); $transformationsFactory->registerTransformation('ChangeNodeType', new ChangeNodeTypeTransformationFactory()); diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/AddDimensionShineThroughTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/AddDimensionShineThroughTransformationFactory.php index f8decab4901..ea774b966c1 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/AddDimensionShineThroughTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/AddDimensionShineThroughTransformationFactory.php @@ -17,6 +17,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -33,7 +34,8 @@ class AddDimensionShineThroughTransformationFactory implements TransformationFac */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { return new class ( DimensionSpacePoint::fromArray($settings['from']), diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/AddNewPropertyTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/AddNewPropertyTransformationFactory.php index b6ad21e946a..1a3dd9cdbc7 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/AddNewPropertyTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/AddNewPropertyTransformationFactory.php @@ -16,9 +16,11 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValue; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -31,13 +33,15 @@ class AddNewPropertyTransformationFactory implements TransformationFactoryInterf */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { return new class ( $settings['newPropertyName'], $settings['type'], $settings['serializedValue'], - $contentRepository + $contentRepository, + $propertyConverter, ) implements NodeBasedTransformationInterface { public function __construct( /** @@ -50,6 +54,7 @@ public function __construct( */ private readonly mixed $serializedValue, private readonly ContentRepository $contentRepository, + private readonly PropertyConverter $propertyConverter, ) { } @@ -63,20 +68,17 @@ public function execute( // we don't need to unset a non-existing property return; } + $deserializedPropertyValue = $this->propertyConverter->deserializePropertyValue(SerializedPropertyValue::create($this->serializedValue, $this->type)); // @phpstan-ignore neos.cr.internal if (!$node->hasProperty($this->newPropertyName)) { $this->contentRepository->handle( - SetSerializedNodeProperties::create( + SetNodeProperties::create( $workspaceNameForWriting, $node->aggregateId, $node->originDimensionSpacePoint, - SerializedPropertyValues::fromArray([ - $this->newPropertyName => SerializedPropertyValue::create( - $this->serializedValue, - $this->type - ) - ]), - PropertyNames::createEmpty() + PropertyValuesToWrite::fromArray([ + $this->newPropertyName => $deserializedPropertyValue, + ]) ) ); } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/ChangeNodeTypeTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/ChangeNodeTypeTransformationFactory.php index 42ec7df2b4c..d6f8a0bb328 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/ChangeNodeTypeTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/ChangeNodeTypeTransformationFactory.php @@ -17,6 +17,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Dto\NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -35,7 +36,8 @@ class ChangeNodeTypeTransformationFactory implements TransformationFactoryInterf */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { // by default, we won't delete anything. $nodeAggregateTypeChangeChildConstraintConflictResolutionStrategy diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/ChangePropertyValueTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/ChangePropertyValueTransformationFactory.php index c0b51852b6b..bd3d31c915b 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/ChangePropertyValueTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/ChangePropertyValueTransformationFactory.php @@ -16,9 +16,12 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValue; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -41,7 +44,8 @@ class ChangePropertyValueTransformationFactory implements TransformationFactoryI */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $newSerializedValue = '{current}'; if (isset($settings['newSerializedValue'])) { @@ -69,7 +73,8 @@ public function build( $search, $replace, $currentValuePlaceholder, - $contentRepository + $contentRepository, + $propertyConverter, ) implements NodeBasedTransformationInterface { public function __construct( /** @@ -96,7 +101,8 @@ public function __construct( * current property value into the new value. */ private readonly string $currentValuePlaceholder, - private readonly ContentRepository $contentRepository + private readonly ContentRepository $contentRepository, + private readonly PropertyConverter $propertyConverter, ) { } @@ -126,19 +132,16 @@ public function execute( $this->replace, $newValueWithReplacedCurrentValue ); + $deserializedPropertyValue = $this->propertyConverter->deserializePropertyValue(SerializedPropertyValue::create($newValueWithReplacedSearch, $currentProperty->type)); // @phpstan-ignore neos.cr.internal $this->contentRepository->handle( - SetSerializedNodeProperties::create( + SetNodeProperties::create( $workspaceNameForWriting, $node->aggregateId, $node->originDimensionSpacePoint, - SerializedPropertyValues::fromArray([ - $this->propertyName => SerializedPropertyValue::create( - $newValueWithReplacedSearch, - $currentProperty->type - ) - ]), - PropertyNames::createEmpty() + PropertyValuesToWrite::fromArray([ + $this->propertyName => $deserializedPropertyValue, + ]) ) ); } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/MoveDimensionSpacePointTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/MoveDimensionSpacePointTransformationFactory.php index 0b7cc1b7679..47699871c57 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/MoveDimensionSpacePointTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/MoveDimensionSpacePointTransformationFactory.php @@ -17,6 +17,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -29,7 +30,8 @@ class MoveDimensionSpacePointTransformationFactory implements TransformationFact */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $from = DimensionSpacePoint::fromArray($settings['from']); $to = DimensionSpacePoint::fromArray($settings['to']); diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/RemoveNodeTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/RemoveNodeTransformationFactory.php index fbd0a2b46fa..0dfca855a8e 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/RemoveNodeTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/RemoveNodeTransformationFactory.php @@ -18,6 +18,7 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -33,7 +34,8 @@ class RemoveNodeTransformationFactory implements TransformationFactoryInterface */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $strategy = null; if (isset($settings['strategy'])) { diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/RemovePropertyTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/RemovePropertyTransformationFactory.php index c0990547e2d..30e182e42bc 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/RemovePropertyTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/RemovePropertyTransformationFactory.php @@ -16,10 +16,10 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -33,7 +33,8 @@ class RemovePropertyTransformationFactory implements TransformationFactoryInterf */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $propertyName = $settings['property']; return new class ( @@ -56,12 +57,13 @@ public function execute( ): void { if ($node->hasProperty($this->propertyName)) { $this->contentRepository->handle( - SetSerializedNodeProperties::create( + SetNodeProperties::create( $workspaceNameForWriting, $node->aggregateId, $node->originDimensionSpacePoint, - SerializedPropertyValues::createEmpty(), - PropertyNames::fromArray([$this->propertyName]) + PropertyValuesToWrite::fromArray([ + $this->propertyName => null, + ]), ) ); } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/RenameNodeAggregateTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/RenameNodeAggregateTransformationFactory.php index 3e05d002d1b..cd277a27641 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/RenameNodeAggregateTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/RenameNodeAggregateTransformationFactory.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -28,7 +29,8 @@ class RenameNodeAggregateTransformationFactory implements TransformationFactoryI */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $newNodeName = $settings['newNodeName']; diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/RenamePropertyTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/RenamePropertyTransformationFactory.php index ff70a13bd7f..3d76ca3ba37 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/RenamePropertyTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/RenamePropertyTransformationFactory.php @@ -16,10 +16,10 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -33,7 +33,8 @@ class RenamePropertyTransformationFactory implements TransformationFactoryInterf */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { return new class ( @@ -62,20 +63,21 @@ public function execute( ContentStreamId $contentStreamForWriting ): void { - $serializedPropertyValue = $node->properties->serialized()->getProperty($this->from); - if ($serializedPropertyValue !== null) { - $this->contentRepository->handle( - SetSerializedNodeProperties::create( - $workspaceNameForWriting, - $node->aggregateId, - $node->originDimensionSpacePoint, - SerializedPropertyValues::fromArray([ - $this->to => $serializedPropertyValue - ]), - PropertyNames::fromArray([$this->from]) - ) - ); + $propertyValue = $node->properties[$this->from]; + if ($propertyValue === null) { + return; } + $this->contentRepository->handle( + SetNodeProperties::create( + $workspaceNameForWriting, + $node->aggregateId, + $node->originDimensionSpacePoint, + PropertyValuesToWrite::fromArray([ + $this->to => $propertyValue, + $this->from => null, + ]), + ) + ); } }; } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/StripTagsOnPropertyTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/StripTagsOnPropertyTransformationFactory.php index 9803c9509b2..6c372de3c3c 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/StripTagsOnPropertyTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/StripTagsOnPropertyTransformationFactory.php @@ -16,11 +16,10 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValue; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -34,7 +33,8 @@ class StripTagsOnPropertyTransformationFactory implements TransformationFactoryI */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { return new class ( $settings['property'], @@ -55,31 +55,27 @@ public function execute( WorkspaceName $workspaceNameForWriting, ContentStreamId $contentStreamForWriting ): void { - $serializedPropertyValue = $node->properties->serialized()->getProperty($this->propertyName); - if ($serializedPropertyValue !== null) { - $propertyValue = $serializedPropertyValue->value; - if (!is_string($propertyValue)) { - throw new \Exception( - 'StripTagsOnProperty can only be applied to properties of type string.', - 1645391885 - ); - } - $newValue = strip_tags($propertyValue); - $this->contentRepository->handle( - SetSerializedNodeProperties::create( - $workspaceNameForWriting, - $node->aggregateId, - $node->originDimensionSpacePoint, - SerializedPropertyValues::fromArray([ - $this->propertyName => SerializedPropertyValue::create( - $newValue, - $serializedPropertyValue->type - ) - ]), - PropertyNames::createEmpty() - ) + $propertyValue = $node->properties[$this->propertyName]; + if ($propertyValue === null) { + return; + } + if (!is_string($propertyValue)) { + throw new \Exception( + sprintf('StripTagsOnProperty can only be applied to properties of type string. Property "%s" is of type %s', $this->propertyName, get_debug_type($propertyValue)), + 1645391885 ); } + $newValue = strip_tags($propertyValue); + $this->contentRepository->handle( + SetNodeProperties::create( + $workspaceNameForWriting, + $node->aggregateId, + $node->originDimensionSpacePoint, + PropertyValuesToWrite::fromArray([ + $this->propertyName => $newValue, + ]), + ) + ); } }; } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationFactoryInterface.php b/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationFactoryInterface.php index df0c2193ed7..ecaa105d733 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationFactoryInterface.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationFactoryInterface.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\NodeMigration\Transformation; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; interface TransformationFactoryInterface { @@ -13,6 +14,7 @@ interface TransformationFactoryInterface */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface; } diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationsFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationsFactory.php index 915c05cc3fe..56d8d0eaa02 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationsFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/TransformationsFactory.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\NodeMigration\Transformation; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\NodeMigration\MigrationException; use Neos\ContentRepository\NodeMigration\NodeMigrationService; @@ -19,7 +20,8 @@ class TransformationsFactory private array $transformationFactories = []; public function __construct( - private readonly ContentRepository $contentRepository + private readonly ContentRepository $contentRepository, + private readonly PropertyConverter $propertyConverter, ) { } @@ -58,7 +60,7 @@ protected function buildTransformationObject( ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { $transformationFactory = $this->resolveTransformationFactory($transformationConfiguration['type']); - return $transformationFactory->build($transformationConfiguration['settings'] ?? [], $this->contentRepository); + return $transformationFactory->build($transformationConfiguration['settings'] ?? [], $this->contentRepository, $this->propertyConverter); } /** diff --git a/Neos.ContentRepository.NodeMigration/src/Transformation/UpdateRootNodeAggregateDimensionsTransformationFactory.php b/Neos.ContentRepository.NodeMigration/src/Transformation/UpdateRootNodeAggregateDimensionsTransformationFactory.php index 3b4b47867bb..4e735ae7ad3 100644 --- a/Neos.ContentRepository.NodeMigration/src/Transformation/UpdateRootNodeAggregateDimensionsTransformationFactory.php +++ b/Neos.ContentRepository.NodeMigration/src/Transformation/UpdateRootNodeAggregateDimensionsTransformationFactory.php @@ -6,6 +6,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; +use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\NodeMigration\MigrationException; @@ -17,7 +18,8 @@ class UpdateRootNodeAggregateDimensionsTransformationFactory implements Transfor */ public function build( array $settings, - ContentRepository $contentRepository + ContentRepository $contentRepository, + PropertyConverter $propertyConverter, ): GlobalTransformationInterface|NodeAggregateBasedTransformationInterface|NodeBasedTransformationInterface { if (!isset($settings['nodeType'])) { throw new MigrationException( diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php index eb160faa805..dee8d3a7d45 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; @@ -23,10 +24,9 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\TestSuite\Fakes\FakeAuthProvider; use Neos\ContentRepository\TestSuite\Fakes\FakeClock; -use Neos\ContentRepository\TestSuite\Fakes\FakeUserIdProvider; /** * The node creation trait for behavioral tests @@ -72,7 +72,7 @@ abstract protected function getContentRepository(ContentRepositoryId $id): Conte */ public function iAmUserIdentifiedBy(string $userId): void { - FakeUserIdProvider::setUserId(UserId::fromString($userId)); + FakeAuthProvider::setDefaultUserId(UserId::fromString($userId)); } /** @@ -110,13 +110,13 @@ public function iAmInWorkspaceAndDimensionSpacePoint(string $workspaceName, stri } /** - * @When /^VisibilityConstraints are set to "(withoutRestrictions|frontend)"$/ + * @When /^VisibilityConstraints are set to "(withoutRestrictions|default)"$/ */ public function visibilityConstraintsAreSetTo(string $restrictionType): void { $this->currentVisibilityConstraints = match ($restrictionType) { 'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(), - 'frontend' => VisibilityConstraints::frontend(), + 'default' => VisibilityConstraints::default(), default => throw new \InvalidArgumentException('Visibility constraint "' . $restrictionType . '" not supported.'), }; } @@ -140,9 +140,8 @@ public function iRememberNodeAggregateIdOfNodesChildAs(string $parentNodeAggrega )->aggregateId; } - protected function getCurrentNodeAggregateId(): NodeAggregateId + protected function getCurrentNodeAggregateId(): ?NodeAggregateId { - assert($this->currentNode instanceof Node); - return $this->currentNode->aggregateId; + return $this->currentNode?->aggregateId; } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index 18fddc56f68..a6cac6dc8b2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -35,18 +35,13 @@ use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\ContentStreamClosing; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeCopying; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeCreation; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeDisabling; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeModification; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeMove; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeReferencing; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeRemoval; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeRenaming; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeTypeChange; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeVariation; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\SubtreeTagging; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\WorkspaceCreation; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\WorkspaceDiscarding; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\WorkspacePublishing; use Neos\EventStore\EventStoreInterface; use PHPUnit\Framework\Assert; @@ -66,20 +61,14 @@ trait CRTestSuiteTrait use ContentStreamClosing; use NodeCreation; - use NodeCopying; - use NodeDisabling; use SubtreeTagging; use NodeModification; use NodeMove; use NodeReferencing; use NodeRemoval; use NodeRenaming; - use NodeTypeChange; - use NodeVariation; use WorkspaceCreation; - use WorkspaceDiscarding; - use WorkspacePublishing; /** * @BeforeScenario @@ -91,7 +80,7 @@ public function beforeEventSourcedScenarioDispatcher(BeforeScenarioScope $scope) $this->contentRepositories = []; } $this->currentContentRepository = null; - $this->currentVisibilityConstraints = VisibilityConstraints::frontend(); + $this->currentVisibilityConstraints = VisibilityConstraints::default(); $this->currentDimensionSpacePoint = null; $this->currentRootNodeAggregateId = null; $this->currentWorkspaceName = null; @@ -107,21 +96,7 @@ protected function readPayloadTable(TableNode $payloadTable): array { $eventPayload = []; foreach ($payloadTable->getHash() as $line) { - if (\str_starts_with($line['Value'], '$this->')) { - // Special case: Referencing stuff from the context here - $propertyOrMethodName = \mb_substr($line['Value'], \mb_strlen('$this->')); - $value = match ($propertyOrMethodName) { - 'currentNodeAggregateId' => $this->getCurrentNodeAggregateId()->value, - default => method_exists($this, $propertyOrMethodName) ? (string)$this->$propertyOrMethodName() : (string)$this->$propertyOrMethodName, - }; - } else { - // default case - $value = json_decode($line['Value'], true); - if ($value === null && json_last_error() !== JSON_ERROR_NONE) { - throw new \Exception(sprintf('The value "%s" is no valid JSON string', $line['Value']), 1546522626); - } - } - $eventPayload[$line['Key']] = $value; + $eventPayload[$line['Key']] = json_decode($line['Value'], true, 512, JSON_THROW_ON_ERROR); } return $eventPayload; 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 fc6f5eb0fd2..850a9bca3d8 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php @@ -22,7 +22,6 @@ use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; -use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; @@ -31,6 +30,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; +use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\GenericCommandExecutionAndEventPublication; use Neos\EventStore\Model\Event\StreamName; /** @@ -46,46 +46,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @When /^the command CreateRootNodeAggregateWithNode is executed with payload:$/ - * @param TableNode $payloadTable - * @throws ContentStreamDoesNotExistYet - * @throws \Exception - */ - public function theCommandCreateRootNodeAggregateWithNodeIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $nodeAggregateId = NodeAggregateId::fromString($commandArguments['nodeAggregateId']); - - $command = CreateRootNodeAggregateWithNode::create( - $workspaceName, - $nodeAggregateId, - NodeTypeName::fromString($commandArguments['nodeTypeName']), - ); - if (isset($commandArguments['tetheredDescendantNodeAggregateIds'])) { - $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromArray($commandArguments['tetheredDescendantNodeAggregateIds'])); - } - - $this->currentContentRepository->handle($command); - $this->currentRootNodeAggregateId = $nodeAggregateId; - } - - /** - * @When /^the command CreateRootNodeAggregateWithNode is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandCreateRootNodeAggregateWithNodeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandCreateRootNodeAggregateWithNodeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event RootNodeAggregateWithNodeWasCreated was published with payload:$/ * @param TableNode $payloadTable @@ -102,174 +62,6 @@ public function theEventRootNodeAggregateWithNodeWasCreatedWasPublishedToStreamW $this->currentRootNodeAggregateId = $nodeAggregateId; } - /** - * @When /^the command UpdateRootNodeAggregateDimensions is executed with payload:$/ - * @param TableNode $payloadTable - * @throws ContentStreamDoesNotExistYet - * @throws \Exception - */ - public function theCommandUpdateRootNodeAggregateDimensionsIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $nodeAggregateId = NodeAggregateId::fromString($commandArguments['nodeAggregateId']); - - $command = UpdateRootNodeAggregateDimensions::create( - $workspaceName, - $nodeAggregateId, - ); - - $this->currentContentRepository->handle($command); - $this->currentRootNodeAggregateId = $nodeAggregateId; - } - - /** - * @When /^the command CreateNodeAggregateWithNode is executed with payload:$/ - * @param TableNode $payloadTable - */ - public function theCommandCreateNodeAggregateWithNodeIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $originDimensionSpacePoint = isset($commandArguments['originDimensionSpacePoint']) - ? OriginDimensionSpacePoint::fromArray($commandArguments['originDimensionSpacePoint']) - : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); - - $command = CreateNodeAggregateWithNode::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - NodeTypeName::fromString($commandArguments['nodeTypeName']), - $originDimensionSpacePoint, - NodeAggregateId::fromString($commandArguments['parentNodeAggregateId']), - isset($commandArguments['succeedingSiblingNodeAggregateId']) - ? NodeAggregateId::fromString($commandArguments['succeedingSiblingNodeAggregateId']) - : null, - isset($commandArguments['initialPropertyValues']) - ? $this->deserializeProperties($commandArguments['initialPropertyValues']) - : null, - ); - if (isset($commandArguments['tetheredDescendantNodeAggregateIds'])) { - $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromArray($commandArguments['tetheredDescendantNodeAggregateIds'])); - } - if (isset($commandArguments['nodeName'])) { - $command = $command->withNodeName(NodeName::fromString($commandArguments['nodeName'])); - } - $this->currentContentRepository->handle($command); - } - - /** - * @When /^the command CreateNodeAggregateWithNode is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandCreateNodeAggregateWithNodeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandCreateNodeAggregateWithNodeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - - /** - * @When the following CreateNodeAggregateWithNode commands are executed: - */ - public function theFollowingCreateNodeAggregateWithNodeCommandsAreExecuted(TableNode $table): void - { - foreach ($table->getHash() as $row) { - $workspaceName = isset($row['workspaceName']) - ? WorkspaceName::fromString($row['workspaceName']) - : $this->currentWorkspaceName; - $originDimensionSpacePoint = isset($row['originDimensionSpacePoint']) - ? OriginDimensionSpacePoint::fromJsonString($row['originDimensionSpacePoint']) - : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); - $rawParentNodeAggregateId = $row['parentNodeAggregateId']; - $command = CreateNodeAggregateWithNode::create( - $workspaceName, - NodeAggregateId::fromString($row['nodeAggregateId']), - NodeTypeName::fromString($row['nodeTypeName']), - $originDimensionSpacePoint, - \str_starts_with($rawParentNodeAggregateId, '$') - ? $this->rememberedNodeAggregateIds[\mb_substr($rawParentNodeAggregateId, 1)] - : NodeAggregateId::fromString($rawParentNodeAggregateId), - !empty($row['succeedingSiblingNodeAggregateId']) - ? NodeAggregateId::fromString($row['succeedingSiblingNodeAggregateId']) - : null, - isset($row['initialPropertyValues']) - ? $this->parsePropertyValuesJsonString($row['initialPropertyValues']) - : null, - isset($row['references']) ? json_decode($row['references']) : null, - ); - if (!empty($row['tetheredDescendantNodeAggregateIds'])) { - $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromJsonString($row['tetheredDescendantNodeAggregateIds'])); - } - if (!empty($row['nodeName'])) { - $command = $command->withNodeName(NodeName::fromString($row['nodeName'])); - } - $this->currentContentRepository->handle($command); - } - } - - private function parsePropertyValuesJsonString(string $jsonString): PropertyValuesToWrite - { - $array = \json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR); - - return $this->deserializeProperties($array); - } - - /** - * @When /^the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateNodeAggregateWithNodeAndSerializedPropertiesIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $originDimensionSpacePoint = isset($commandArguments['originDimensionSpacePoint']) - ? OriginDimensionSpacePoint::fromArray($commandArguments['originDimensionSpacePoint']) - : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); - - $command = CreateNodeAggregateWithNodeAndSerializedProperties::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - NodeTypeName::fromString($commandArguments['nodeTypeName']), - $originDimensionSpacePoint, - NodeAggregateId::fromString($commandArguments['parentNodeAggregateId']), - isset($commandArguments['succeedingSiblingNodeAggregateId']) - ? NodeAggregateId::fromString($commandArguments['succeedingSiblingNodeAggregateId']) - : null, - isset($commandArguments['initialPropertyValues']) - ? SerializedPropertyValues::fromArray($commandArguments['initialPropertyValues']) - : null - ); - if (isset($commandArguments['tetheredDescendantNodeAggregateIds'])) { - $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromArray($commandArguments['tetheredDescendantNodeAggregateIds'])); - } - if (isset($commandArguments['nodeName'])) { - $command = $command->withNodeName(NodeName::fromString($commandArguments['nodeName'])); - } - $this->currentContentRepository->handle($command); - } - - /** - * @When /^the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandCreateNodeAggregateWithNodeAndSerializedPropertiesIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandCreateNodeAggregateWithNodeAndSerializedPropertiesIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event NodeAggregateWithNodeWasCreated was published with payload:$/ * @param TableNode $payloadTable diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeDisabling.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeDisabling.php deleted file mode 100644 index 62c6948d904..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeDisabling.php +++ /dev/null @@ -1,113 +0,0 @@ -readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $coveredDimensionSpacePoint = isset($commandArguments['coveredDimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['coveredDimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - - $command = DisableNodeAggregate::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $coveredDimensionSpacePoint, - NodeVariantSelectionStrategy::from($commandArguments['nodeVariantSelectionStrategy']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command DisableNodeAggregate is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandDisableNodeAggregateIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandDisableNodeAggregateIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - - /** - * @Given /^the command EnableNodeAggregate is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandEnableNodeAggregateIsExecutedWithPayload(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $coveredDimensionSpacePoint = isset($commandArguments['coveredDimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['coveredDimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - - $command = EnableNodeAggregate::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $coveredDimensionSpacePoint, - NodeVariantSelectionStrategy::from($commandArguments['nodeVariantSelectionStrategy']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command EnableNodeAggregate is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandEnableNodeAggregateIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandEnableNodeAggregateIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeModification.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeModification.php index db983fd4e59..4671d932858 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeModification.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeModification.php @@ -15,13 +15,9 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use Neos\EventStore\Model\Event\StreamName; use PHPUnit\Framework\Assert; @@ -37,46 +33,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @When /^the command SetNodeProperties is executed with payload:$/ - * @param TableNode $payloadTable - */ - public function theCommandSetPropertiesIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - if (!isset($commandArguments['workspaceName'])) { - $commandArguments['workspaceName'] = $this->currentWorkspaceName->value; - } - if (!isset($commandArguments['originDimensionSpacePoint'])) { - $commandArguments['originDimensionSpacePoint'] = $this->currentDimensionSpacePoint->jsonSerialize(); - } - - $rawNodeAggregateId = $commandArguments['nodeAggregateId']; - $command = SetNodeProperties::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - \str_starts_with($rawNodeAggregateId, '$') - ? $this->rememberedNodeAggregateIds[\mb_substr($rawNodeAggregateId, 1)] - : NodeAggregateId::fromString($rawNodeAggregateId), - OriginDimensionSpacePoint::fromArray($commandArguments['originDimensionSpacePoint']), - $this->deserializeProperties($commandArguments['propertyValues']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @When /^the command SetNodeProperties is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandSetPropertiesIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandSetPropertiesIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event NodePropertiesWereSet was published with payload:$/ * @param TableNode $payloadTable diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeMove.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeMove.php index 73edb77c65c..fa4a9bc28a5 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeMove.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeMove.php @@ -36,59 +36,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @Given /^the command MoveNodeAggregate is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandMoveNodeIsExecutedWithPayload(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $dimensionSpacePoint = isset($commandArguments['dimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['dimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - $newParentNodeAggregateId = isset($commandArguments['newParentNodeAggregateId']) - ? NodeAggregateId::fromString($commandArguments['newParentNodeAggregateId']) - : null; - $newPrecedingSiblingNodeAggregateId = isset($commandArguments['newPrecedingSiblingNodeAggregateId']) - ? NodeAggregateId::fromString($commandArguments['newPrecedingSiblingNodeAggregateId']) - : null; - $newSucceedingSiblingNodeAggregateId = isset($commandArguments['newSucceedingSiblingNodeAggregateId']) - ? NodeAggregateId::fromString($commandArguments['newSucceedingSiblingNodeAggregateId']) - : null; - $relationDistributionStrategy = RelationDistributionStrategy::fromString( - $commandArguments['relationDistributionStrategy'] ?? null - ); - - $command = MoveNodeAggregate::create( - $workspaceName, - $dimensionSpacePoint, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $relationDistributionStrategy, - $newParentNodeAggregateId, - $newPrecedingSiblingNodeAggregateId, - $newSucceedingSiblingNodeAggregateId, - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command MoveNodeAggregate is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandMoveNodeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandMoveNodeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event NodeAggregateWasMoved was published with payload:$/ * @param TableNode $payloadTable diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php index 43cdfc68c60..3d07a3cf3ec 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php @@ -15,17 +15,8 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesForName; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferenceToWrite; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use Neos\EventStore\Model\Event\StreamName; @@ -38,50 +29,8 @@ trait NodeReferencing abstract protected function readPayloadTable(TableNode $payloadTable): array; - abstract protected function deserializeProperties(array $properties): PropertyValuesToWrite; - abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @Given /^the command SetNodeReferences is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandSetNodeReferencesIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $sourceOriginDimensionSpacePoint = isset($commandArguments['sourceOriginDimensionSpacePoint']) - ? OriginDimensionSpacePoint::fromArray($commandArguments['sourceOriginDimensionSpacePoint']) - : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); - - $references = $this->mapRawNodeReferencesToNodeReferencesToWrite($commandArguments['references']); - $command = SetNodeReferences::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['sourceNodeAggregateId']), - $sourceOriginDimensionSpacePoint, - $references, - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command SetNodeReferences is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandSetNodeReferencesIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandSetNodeReferencesIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event NodeReferencesWereSet was published with payload:$/ * @param TableNode $payloadTable @@ -97,18 +46,4 @@ public function theEventNodeReferencesWereSetWasPublishedWithPayload(TableNode $ $this->publishEvent('NodeReferencesWereSet', $streamName->getEventStreamName(), $eventPayload); } - - protected function mapRawNodeReferencesToNodeReferencesToWrite(array $deserializedTableContent): NodeReferencesToWrite - { - $referencesForProperty = []; - foreach ($deserializedTableContent as $nodeReferencesForProperty) { - $references = []; - foreach ($nodeReferencesForProperty['references'] as $referenceData) { - $properties = isset($referenceData['properties']) ? $this->deserializeProperties($referenceData['properties']) : PropertyValuesToWrite::createEmpty(); - $references[] = NodeReferenceToWrite::fromTargetAndProperties(NodeAggregateId::fromString($referenceData['target']), $properties); - } - $referencesForProperty[] = NodeReferencesForName::fromReferences(ReferenceName::fromString($nodeReferencesForProperty['referenceName']), $references); - } - return NodeReferencesToWrite::fromArray($referencesForProperty); - } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRemoval.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRemoval.php index 9bd92a7caba..a7d8a85c050 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRemoval.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRemoval.php @@ -15,13 +15,8 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use Neos\EventStore\Model\Event\StreamName; @@ -36,48 +31,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @Given /^the command RemoveNodeAggregate is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandRemoveNodeAggregateIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $coveredDimensionSpacePoint = isset($commandArguments['coveredDimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['coveredDimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - - $command = RemoveNodeAggregate::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $coveredDimensionSpacePoint, - NodeVariantSelectionStrategy::from($commandArguments['nodeVariantSelectionStrategy']), - ); - if (isset($commandArguments['removalAttachmentPoint'])) { - $command = $command->withRemovalAttachmentPoint(NodeAggregateId::fromString($commandArguments['removalAttachmentPoint'])); - } - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command RemoveNodeAggregate is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandRemoveNodeAggregateIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandRemoveNodeAggregateIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Given /^the event NodeAggregateWasRemoved was published with payload:$/ * @param TableNode $payloadTable diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRenaming.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRenaming.php index ac383b6ba5b..456183e507a 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRenaming.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeRenaming.php @@ -14,11 +14,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; -use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use PHPUnit\Framework\Assert; @@ -29,41 +25,6 @@ trait NodeRenaming { use CRTestSuiteRuntimeVariables; - /** - * @Given /^the command ChangeNodeAggregateName is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandChangeNodeAggregateNameIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - - $command = ChangeNodeAggregateName::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - NodeName::fromString($commandArguments['newNodeName']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command ChangeNodeAggregateName is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandChangeNodeAggregateNameIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandChangeNodeAggregateNameIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - /** * @Then /^I expect the node "([^"]*)" to have the name "([^"]*)"$/ * @param string $nodeAggregateId diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeTypeChange.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeTypeChange.php deleted file mode 100644 index 1033e93f2f9..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeTypeChange.php +++ /dev/null @@ -1,72 +0,0 @@ -readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $command = ChangeNodeAggregateType::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - NodeTypeName::fromString($commandArguments['newNodeTypeName']), - NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy::from($commandArguments['strategy']), - ); - if (isset($commandArguments['tetheredDescendantNodeAggregateIds'])) { - $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromArray($commandArguments['tetheredDescendantNodeAggregateIds'])); - } - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command ChangeNodeAggregateType is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandChangeNodeAggregateTypeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandChangeNodeAggregateTypeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeVariation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeVariation.php deleted file mode 100644 index 3ead5a2de26..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeVariation.php +++ /dev/null @@ -1,70 +0,0 @@ -readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - - $command = CreateNodeVariant::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - OriginDimensionSpacePoint::fromArray($commandArguments['sourceOrigin']), - OriginDimensionSpacePoint::fromArray($commandArguments['targetOrigin']), - ); - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command CreateNodeVariant is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateNodeVariantIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandCreateNodeVariantIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/SubtreeTagging.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/SubtreeTagging.php index f249060dca5..f73e05d78c8 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/SubtreeTagging.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/SubtreeTagging.php @@ -15,15 +15,8 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use Neos\EventStore\Model\Event\StreamName; @@ -38,44 +31,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @Given /^the command TagSubtree is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandTagSubtreeIsExecutedWithPayload(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $coveredDimensionSpacePoint = isset($commandArguments['coveredDimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['coveredDimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - - $command = TagSubtree::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $coveredDimensionSpacePoint, - NodeVariantSelectionStrategy::from($commandArguments['nodeVariantSelectionStrategy']), - SubtreeTag::fromString($commandArguments['tag']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command TagSubtree is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandTagSubtreeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandTagSubtreeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } /** * @Given /^the event SubtreeWasTagged was published with payload:$/ @@ -107,44 +62,4 @@ public function theEventSubtreeWasUntaggedWasPublishedWithPayload(TableNode $pay $this->publishEvent('SubtreeWasUntagged', $streamName->getEventStreamName(), $eventPayload); } - - - /** - * @Given /^the command UntagSubtree is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandUntagSubtreeIsExecutedWithPayload(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - $coveredDimensionSpacePoint = isset($commandArguments['coveredDimensionSpacePoint']) - ? DimensionSpacePoint::fromArray($commandArguments['coveredDimensionSpacePoint']) - : $this->currentDimensionSpacePoint; - - $command = UntagSubtree::create( - $workspaceName, - NodeAggregateId::fromString($commandArguments['nodeAggregateId']), - $coveredDimensionSpacePoint, - NodeVariantSelectionStrategy::from($commandArguments['nodeVariantSelectionStrategy']), - SubtreeTag::fromString($commandArguments['tag']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command UntagSubtree is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - */ - public function theCommandUntagSubtreeIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandUntagSubtreeIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php index 91122fe5dba..54b239604c1 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php @@ -15,15 +15,8 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Command\CreateContentStream; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; use Neos\EventStore\Model\Event\StreamName; @@ -38,22 +31,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @When /^the command CreateRootWorkspace is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateRootWorkspaceIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - - $command = CreateRootWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - ContentStreamId::fromString($commandArguments['newContentStreamId']) - ); - - $this->currentContentRepository->handle($command); - } /** * @Given /^the event RootWorkspaceWasCreated was published with payload:$/ * @param TableNode $payloadTable @@ -66,69 +43,4 @@ public function theEventRootWorkspaceWasCreatedWasPublishedToStreamWithPayload(T $streamName = ContentStreamEventStreamName::fromContentStreamId($newContentStreamId); $this->publishEvent('RootWorkspaceWasCreated', $streamName->getEventStreamName(), $eventPayload); } - - /** - * @When /^the command CreateWorkspace is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateWorkspaceIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - - $command = CreateWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - WorkspaceName::fromString($commandArguments['baseWorkspaceName']), - ContentStreamId::fromString($commandArguments['newContentStreamId']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @When /^the command CreateWorkspace is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateWorkspaceIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandCreateWorkspaceIsExecutedWithPayload($payloadTable); - } catch (\Exception $e) { - $this->lastCommandException = $e; - } - } - - /** - * @When /^the command RebaseWorkspace is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandRebaseWorkspaceIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - $command = RebaseWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - ); - if (isset($commandArguments['rebasedContentStreamId'])) { - $command = $command->withRebasedContentStreamId(ContentStreamId::fromString($commandArguments['rebasedContentStreamId'])); - } - if (isset($commandArguments['rebaseErrorHandlingStrategy'])) { - $command = $command->withErrorHandlingStrategy(RebaseErrorHandlingStrategy::from($commandArguments['rebaseErrorHandlingStrategy'])); - } - - $this->currentContentRepository->handle($command); - } - - /** - * @When /^the command RebaseWorkspace is executed with payload and exceptions are caught:$/ - */ - public function theCommandRebaseWorkspaceIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) - { - try { - $this->theCommandRebaseWorkspaceIsExecutedWithPayload($payloadTable); - } catch (\Exception $e) { - $this->lastCommandException = $e; - } - } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceDiscarding.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceDiscarding.php deleted file mode 100644 index 28aaf305b73..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceDiscarding.php +++ /dev/null @@ -1,85 +0,0 @@ -readPayloadTable($payloadTable); - $command = DiscardWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - ); - if (isset($commandArguments['newContentStreamId'])) { - $command = $command->withNewContentStreamId(ContentStreamId::fromString($commandArguments['newContentStreamId'])); - } - - $this->currentContentRepository->handle($command); - } - - - /** - * @Given /^the command DiscardIndividualNodesFromWorkspace is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandDiscardIndividualNodesFromWorkspaceIsExecuted(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $nodesToDiscard = NodeIdsToPublishOrDiscard::fromArray($commandArguments['nodesToDiscard']); - $command = DiscardIndividualNodesFromWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - $nodesToDiscard, - ); - if (isset($commandArguments['newContentStreamId'])) { - $command = $command->withNewContentStreamId(ContentStreamId::fromString($commandArguments['newContentStreamId'])); - } - - $this->currentContentRepository->handle($command); - } - - - /** - * @Given /^the command DiscardIndividualNodesFromWorkspace is executed with payload and exceptions are caught:$/ - */ - public function theCommandDiscardIndividualNodesFromWorkspaceIsExecutedAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandDiscardIndividualNodesFromWorkspaceIsExecuted($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php deleted file mode 100644 index 6b656113bee..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php +++ /dev/null @@ -1,131 +0,0 @@ -readPayloadTable($payloadTable); - $nodesToPublish = NodeIdsToPublishOrDiscard::fromArray($commandArguments['nodesToPublish']); - - $command = PublishIndividualNodesFromWorkspace::create( - array_key_exists('workspaceName', $commandArguments) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName, - $nodesToPublish, - ); - if (isset($commandArguments['contentStreamIdForRemainingPart'])) { - $command = $command->withContentStreamIdForRemainingPart(ContentStreamId::fromString($commandArguments['contentStreamIdForRemainingPart'])); - } - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command PublishIndividualNodesFromWorkspace is executed with payload and exceptions are caught:$/ - */ - public function theCommandPublishIndividualNodesFromWorkspaceIsExecutedAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandPublishIndividualNodesFromWorkspaceIsExecuted($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - - /** - * @Given /^the command PublishWorkspace is executed with payload:$/ - * @throws \Exception - */ - public function theCommandPublishWorkspaceIsExecuted(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - - $command = PublishWorkspace::create( - WorkspaceName::fromString($commandArguments['workspaceName']), - ); - if (array_key_exists('newContentStreamId', $commandArguments)) { - $command = $command->withNewContentStreamId( - ContentStreamId::fromString($commandArguments['newContentStreamId']) - ); - } - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command PublishWorkspace is executed with payload and exceptions are caught:$/ - */ - public function theCommandPublishWorkspaceIsExecutedAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandPublishWorkspaceIsExecuted($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } - - /** - * @Given /^the command ChangeBaseWorkspace is executed with payload:$/ - * @throws \Exception - */ - public function theCommandChangeBaseWorkspaceIsExecuted(TableNode $payloadTable): void - { - $commandArguments = $this->readPayloadTable($payloadTable); - $command = ChangeBaseWorkspace::create( - array_key_exists('workspaceName', $commandArguments) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName, - WorkspaceName::fromString($commandArguments['baseWorkspaceName']), - ); - if (array_key_exists('newContentStreamId', $commandArguments)) { - $command = $command->withNewContentStreamId(ContentStreamId::fromString($commandArguments['newContentStreamId'])); - } - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command ChangeBaseWorkspace is executed with payload and exceptions are caught:$/ - */ - public function theCommandChangeBaseWorkspaceIsExecutedAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandChangeBaseWorkspaceIsExecuted($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index dd899ee4e24..0afbf60c150 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -14,27 +14,48 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap; +use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; +use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; 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\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; +use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesForName; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferenceToWrite; +use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; +use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; +use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; +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\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Model\Event; use Neos\EventStore\Model\Event\StreamName; @@ -57,66 +78,201 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function getEventStore(): EventStoreInterface; + abstract protected function deserializeProperties(array $properties): PropertyValuesToWrite; + /** - * @When /^the command "([^"]*)" is executed with payload:$/ - * @Given /^the command "([^"]*)" was executed with payload:$/ - * @param array|null $commandArguments + * @When the command :shortCommandName is executed with payload: * @throws \Exception */ - public function theCommandIsExecutedWithPayload(string $shortCommandName, TableNode $payloadTable = null, array $commandArguments = null): void + public function theCommandIsExecutedWithPayload(string $shortCommandName, TableNode $payloadTable): void { - $commandClassName = self::resolveShortCommandName($shortCommandName); - if ($commandArguments === null && $payloadTable !== null) { - $commandArguments = $this->readPayloadTable($payloadTable); + $commandArguments = $this->readPayloadTable($payloadTable); + $this->handleCommand($shortCommandName, $commandArguments); + } + + /** + * @When the command :shortCommandName is executed with payload and exceptions are caught: + */ + public function theCommandIsExecutedWithPayloadAndExceptionsAreCaught(string $shortCommandName, TableNode $payloadTable): void + { + $commandArguments = $this->readPayloadTable($payloadTable); + try { + $this->handleCommand($shortCommandName, $commandArguments); + } catch (\Exception $exception) { + $this->lastCommandException = $exception; } + } - if (isset($commandArguments['propertyValues.dateProperty'])) { - // special case to test Date type conversion - $commandArguments['propertyValues']['dateProperty'] = \DateTime::createFromFormat('Y-m-d\TH:i:sP', $commandArguments['propertyValues.dateProperty']); + /** + * @When the command :shortCommandName is executed with payload :payload + */ + public function theCommandIsExecutedWithJsonPayload(string $shortCommandName, string $payload): void + { + $commandArguments = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + $this->handleCommand($shortCommandName, $commandArguments); + } + + /** + * @When the command :shortCommandName is executed with payload :payload and exceptions are caught + */ + public function theCommandIsExecutedWithJsonPayloadAndExceptionsAreCaught(string $shortCommandName, string $payload): void + { + $commandArguments = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + try { + $this->handleCommand($shortCommandName, $commandArguments); + } catch (\Exception $exception) { + $this->lastCommandException = $exception; } + } - if (!method_exists($commandClassName, 'fromArray')) { - throw new \InvalidArgumentException(sprintf('Command "%s" does not implement a static "fromArray" constructor', $commandClassName), 1545564621); + /** + * @When the following :shortCommandName commands are executed: + */ + public function theFollowingCreateNodeAggregateWithNodeCommandsAreExecuted(string $shortCommandName, TableNode $table): void + { + foreach ($table->getHash() as $row) { + $this->handleCommand($shortCommandName, $row); } + } + private function handleCommand(string $shortCommandName, array $commandArguments): void + { + $commandClassName = self::resolveShortCommandName($shortCommandName); + $commandArguments = $this->addDefaultCommandArgumentValues($commandClassName, $commandArguments); $command = $commandClassName::fromArray($commandArguments); - + if ($command instanceof CreateRootNodeAggregateWithNode) { + $this->currentRootNodeAggregateId = $command->nodeAggregateId; + } $this->currentContentRepository->handle($command); } /** - * @When /^the command "([^"]*)" is executed with payload and exceptions are caught:$/ + * @param class-string $commandClassName */ - public function theCommandIsExecutedWithPayloadAndExceptionsAreCaught($shortCommandName, TableNode $payloadTable): void + protected function addDefaultCommandArgumentValues(string $commandClassName, array $commandArguments): array { - try { - $this->theCommandIsExecutedWithPayload($shortCommandName, $payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; + $commandArguments['workspaceName'] = $commandArguments['workspaceName'] ?? $this->currentWorkspaceName?->value; + $commandArguments['coveredDimensionSpacePoint'] = $commandArguments['coveredDimensionSpacePoint'] ?? $this->currentDimensionSpacePoint?->coordinates; + $commandArguments['dimensionSpacePoint'] = $commandArguments['dimensionSpacePoint'] ?? $this->currentDimensionSpacePoint?->coordinates; + if (is_string($commandArguments['nodeAggregateId'] ?? null) && str_starts_with($commandArguments['nodeAggregateId'], '$')) { + $commandArguments['nodeAggregateId'] = $this->rememberedNodeAggregateIds[substr($commandArguments['nodeAggregateId'], 1)]?->value; + } elseif (!isset($commandArguments['nodeAggregateId'])) { + $commandArguments['nodeAggregateId'] = $this->getCurrentNodeAggregateId()?->value; } + if ($commandClassName === CreateNodeAggregateWithNode::class) { + if (is_string($commandArguments['initialPropertyValues'] ?? null)) { + $commandArguments['initialPropertyValues'] = $this->deserializeProperties(json_decode($commandArguments['initialPropertyValues'], true, 512, JSON_THROW_ON_ERROR))->values; + } elseif (is_array($commandArguments['initialPropertyValues'] ?? null)) { + $commandArguments['initialPropertyValues'] = $this->deserializeProperties($commandArguments['initialPropertyValues'])->values; + } + if (isset($commandArguments['succeedingSiblingNodeAggregateId']) && $commandArguments['succeedingSiblingNodeAggregateId'] === '') { + unset($commandArguments['succeedingSiblingNodeAggregateId']); + } + if (is_string($commandArguments['parentNodeAggregateId'] ?? null) && str_starts_with($commandArguments['parentNodeAggregateId'], '$')) { + $commandArguments['parentNodeAggregateId'] = $this->rememberedNodeAggregateIds[substr($commandArguments['parentNodeAggregateId'], 1)]?->value; + } + if (empty($commandArguments['nodeName'])) { + unset($commandArguments['nodeName']); + } + } + if ($commandClassName === SetNodeProperties::class) { + if (is_string($commandArguments['propertyValues'] ?? null)) { + $commandArguments['propertyValues'] = $this->deserializeProperties(json_decode($commandArguments['propertyValues'], true, 512, JSON_THROW_ON_ERROR))->values; + } elseif (is_array($commandArguments['propertyValues'] ?? null)) { + $commandArguments['propertyValues'] = $this->deserializeProperties($commandArguments['propertyValues'])->values; + } + } + if ($commandClassName === CreateNodeAggregateWithNode::class || $commandClassName === SetNodeProperties::class) { + if (is_string($commandArguments['originDimensionSpacePoint'] ?? null) && !empty($commandArguments['originDimensionSpacePoint'])) { + $commandArguments['originDimensionSpacePoint'] = OriginDimensionSpacePoint::fromJsonString($commandArguments['originDimensionSpacePoint'])->coordinates; + } elseif (!isset($commandArguments['originDimensionSpacePoint'])) { + $commandArguments['originDimensionSpacePoint'] = $this->currentDimensionSpacePoint?->coordinates; + } + } + if ($commandClassName === CreateNodeAggregateWithNode::class || $commandClassName === SetNodeReferences::class) { + if (is_string($commandArguments['references'] ?? null)) { + $commandArguments['references'] = iterator_to_array($this->mapRawNodeReferencesToNodeReferencesToWrite(json_decode($commandArguments['references'], true, 512, JSON_THROW_ON_ERROR))); + } elseif (is_array($commandArguments['references'] ?? null)) { + $commandArguments['references'] = iterator_to_array($this->mapRawNodeReferencesToNodeReferencesToWrite($commandArguments['references'])); + } + } + if ($commandClassName === SetNodeReferences::class) { + if (is_string($commandArguments['sourceOriginDimensionSpacePoint'] ?? null) && !empty($commandArguments['sourceOriginDimensionSpacePoint'])) { + $commandArguments['sourceOriginDimensionSpacePoint'] = OriginDimensionSpacePoint::fromJsonString($commandArguments['sourceOriginDimensionSpacePoint'])->coordinates; + } elseif (!isset($commandArguments['sourceOriginDimensionSpacePoint'])) { + $commandArguments['sourceOriginDimensionSpacePoint'] = $this->currentDimensionSpacePoint?->coordinates; + } + if (is_string($commandArguments['sourceNodeAggregateId'] ?? null) && str_starts_with($commandArguments['sourceNodeAggregateId'], '$')) { + $commandArguments['sourceNodeAggregateId'] = $this->rememberedNodeAggregateIds[substr($commandArguments['sourceNodeAggregateId'], 1)]?->value; + } elseif (!isset($commandArguments['sourceNodeAggregateId'])) { + $commandArguments['sourceNodeAggregateId'] = $this->currentNodeAggregate?->nodeAggregateId->value; + } + } + if ($commandClassName === CreateNodeAggregateWithNode::class || $commandClassName === ChangeNodeAggregateType::class || $commandClassName === CreateRootNodeAggregateWithNode::class) { + if (is_string($commandArguments['tetheredDescendantNodeAggregateIds'] ?? null)) { + if ($commandArguments['tetheredDescendantNodeAggregateIds'] === '') { + unset($commandArguments['tetheredDescendantNodeAggregateIds']); + } else { + $commandArguments['tetheredDescendantNodeAggregateIds'] = json_decode($commandArguments['tetheredDescendantNodeAggregateIds'], true, 512, JSON_THROW_ON_ERROR); + } + } + } + return $commandArguments; + } + + protected function mapRawNodeReferencesToNodeReferencesToWrite(array $deserializedTableContent): NodeReferencesToWrite + { + $referencesForProperty = []; + foreach ($deserializedTableContent as $nodeReferencesForProperty) { + $references = []; + foreach ($nodeReferencesForProperty['references'] as $referenceData) { + $properties = isset($referenceData['properties']) ? $this->deserializeProperties($referenceData['properties']) : PropertyValuesToWrite::createEmpty(); + $references[] = NodeReferenceToWrite::fromTargetAndProperties(NodeAggregateId::fromString($referenceData['target']), $properties); + } + $referencesForProperty[] = NodeReferencesForName::fromReferences(ReferenceName::fromString($nodeReferencesForProperty['referenceName']), $references); + } + return NodeReferencesToWrite::fromArray($referencesForProperty); } + /** + * @return class-string + */ protected static function resolveShortCommandName(string $shortCommandName): string { - return match ($shortCommandName) { - 'CreateRootWorkspace' => CreateRootWorkspace::class, - 'CreateWorkspace' => CreateWorkspace::class, - 'PublishWorkspace' => PublishWorkspace::class, - 'PublishIndividualNodesFromWorkspace' => PublishIndividualNodesFromWorkspace::class, - 'RebaseWorkspace' => RebaseWorkspace::class, - 'CreateNodeAggregateWithNodeAndSerializedProperties' => CreateNodeAggregateWithNodeAndSerializedProperties::class, - 'ChangeNodeAggregateName' => ChangeNodeAggregateName::class, - 'SetSerializedNodeProperties' => SetSerializedNodeProperties::class, - 'DisableNodeAggregate' => DisableNodeAggregate::class, - 'EnableNodeAggregate' => EnableNodeAggregate::class, - 'TagSubtree' => TagSubtree::class, - 'UntagSubtree' => UntagSubtree::class, - 'MoveNodeAggregate' => MoveNodeAggregate::class, - 'SetNodeReferences' => SetNodeReferences::class, - default => throw new \Exception( - 'The short command name "' . $shortCommandName . '" is currently not supported by the tests.' - ), - }; + $commandClassNames = [ + AddDimensionShineThrough::class, + ChangeBaseWorkspace::class, + ChangeNodeAggregateName::class, + ChangeNodeAggregateType::class, + CreateNodeAggregateWithNode::class, + CreateNodeVariant::class, + CreateRootNodeAggregateWithNode::class, + CreateRootWorkspace::class, + CreateWorkspace::class, + DeleteWorkspace::class, + DisableNodeAggregate::class, + DiscardIndividualNodesFromWorkspace::class, + DiscardWorkspace::class, + EnableNodeAggregate::class, + MoveDimensionSpacePoint::class, + MoveNodeAggregate::class, + PublishIndividualNodesFromWorkspace::class, + PublishWorkspace::class, + RebasableToOtherWorkspaceInterface::class, + RebaseWorkspace::class, + RemoveNodeAggregate::class, + SetNodeProperties::class, + SetNodeReferences::class, + TagSubtree::class, + UntagSubtree::class, + UpdateRootNodeAggregateDimensions::class, + ]; + foreach ($commandClassNames as $commandClassName) { + if (substr(strrchr($commandClassName, '\\'), 1) === $shortCommandName) { + return $commandClassName; + } + } + throw new \RuntimeException('The short command name "' . $shortCommandName . '" is currently not supported by the tests.'); } /** @@ -147,14 +303,21 @@ protected function publishEvent(string $eventType, StreamName $streamName, array } /** - * @Then /^the last command should have thrown an exception of type "([^"]*)"(?: with code (\d*))?$/ + * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode and message: + * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode + * @Then the last command should have thrown an exception of type :shortExceptionName with message: + * @Then the last command should have thrown an exception of type :shortExceptionName */ - public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null): void + public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null, PyStringNode $expectedMessage = null): void { + if ($shortExceptionName === 'WorkspaceRebaseFailed') { + throw new \RuntimeException('Please use the assertion "the last command should have thrown the WorkspaceRebaseFailed exception with" instead.'); + } + Assert::assertNotNull($this->lastCommandException, 'Command did not throw exception'); $lastCommandExceptionShortName = (new \ReflectionClass($this->lastCommandException))->getShortName(); - Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_class($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage())); - if (!is_null($expectedCode)) { + Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage())); + if ($expectedCode !== null) { Assert::assertSame($expectedCode, $this->lastCommandException->getCode(), sprintf( 'Expected exception code %s, got exception code %s instead; Message: %s', $expectedCode, @@ -162,6 +325,10 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $this->lastCommandException->getMessage() )); } + if ($expectedMessage !== null) { + Assert::assertSame($expectedMessage->getRaw(), $this->lastCommandException->getMessage()); + } + $this->lastCommandException = null; } /** @@ -175,15 +342,32 @@ public function theLastCommandShouldHaveThrownTheWorkspaceRebaseFailedWith(Table Assert::assertInstanceOf(WorkspaceRebaseFailed::class, $exception, sprintf('Actual exception: %s (%s): %s', get_class($exception), $exception->getCode(), $exception->getMessage())); $actualComparableHash = []; - foreach ($exception->commandsThatFailedDuringRebase as $commandsThatFailed) { + foreach ($exception->conflictingEvents as $conflictingEvent) { $actualComparableHash[] = [ - 'SequenceNumber' => (string)$commandsThatFailed->getSequenceNumber()->value, - 'Command' => (new \ReflectionClass($commandsThatFailed->command))->getShortName(), - 'Exception' => (new \ReflectionClass($commandsThatFailed->exception))->getShortName(), + 'SequenceNumber' => (string)$conflictingEvent->getSequenceNumber()->value, + 'Event' => (new \ReflectionClass($conflictingEvent->getEvent()))->getShortName(), + 'Exception' => (new \ReflectionClass($conflictingEvent->getException()))->getShortName(), ]; } Assert::assertSame($payloadTable->getHash(), $actualComparableHash); + $this->lastCommandException = null; + } + + /** + * @AfterScenario + */ + public function ensureNoUnhandledCommandExceptions(\Behat\Behat\Hook\Scope\AfterScenarioScope $event): void + { + if ($this->lastCommandException !== null) { + Assert::fail(sprintf( + 'Last command did throw with exception which was not asserted: %s: "%s" in %s:%s', + $this->lastCommandException::class, + $this->lastCommandException->getMessage(), + $event->getFeature()->getFile(), + $event->getScenario()->getLine(), + )); + } } /** diff --git a/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProvider.php new file mode 100644 index 00000000000..f3413550065 --- /dev/null +++ b/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProvider.php @@ -0,0 +1,71 @@ +getAuthenticatedUserId(); + } + + return self::$userId ?? null; + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->getVisibilityConstraints($workspaceName); + } + + return VisibilityConstraints::withoutRestrictions(); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->canReadNodesFromWorkspace($workspaceName); + } + + return Privilege::granted(self::class . ' always grants privileges'); + } + + public function canExecuteCommand(CommandInterface $command): Privilege + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->canExecuteCommand($command); + } + + return Privilege::granted(self::class . ' always grants privileges'); + } +} diff --git a/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProviderFactory.php b/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProviderFactory.php new file mode 100644 index 00000000000..b2081f14b3c --- /dev/null +++ b/Neos.ContentRepository.TestSuite/Classes/Fakes/FakeAuthProviderFactory.php @@ -0,0 +1,18 @@ + $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new FakeUserIdProvider(); - } -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php index 459398dfeb0..38149fcbe74 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; -use Neos\ContentRepositoryRegistry\Service\ProjectionReplayServiceFactory; +use Neos\ContentRepositoryRegistry\Service\ProjectionServiceFactory; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\EventStore\StatusType; use Neos\Flow\Cli\CommandController; @@ -23,7 +23,7 @@ final class CrCommandController extends CommandController public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly ProjectionReplayServiceFactory $projectionServiceFactory, + private readonly ProjectionServiceFactory $projectionServiceFactory, ) { parent::__construct(); } diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php index 4825a4420b9..01cbc8ccf87 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php @@ -100,7 +100,7 @@ public function migratePayloadToWorkspaceNameCommand(string $contentRepository = * Needed for feature "Stabilize WorkspaceName value object": https://github.com/neos/neos-development-collection/pull/5193 * * Included in August 2024 - before final Neos 9.0 release - * + * * @param string $contentRepository Identifier of the Content Repository to migrate */ public function migratePayloadToValidWorkspaceNamesCommand(string $contentRepository = 'default'): void @@ -116,4 +116,56 @@ public function migrateSetReferencesToMultiNameFormatCommand(string $contentRepo $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); $eventMigrationService->migrateReferencesToMultiFormat($this->outputLine(...)); } + + /** + * Reorders all NodeAggregateWasMoved events to allow replaying in case orphaned nodes existed in previous betas + * + * Fixes these bugs to allow to migrate to Beta 15: + * + * - #5364 https://github.com/neos/neos-development-collection/issues/5364 + * - #5352 https://github.com/neos/neos-development-collection/issues/5352 + * + * Included in November 2024 - before final Neos 9.0 release + */ + public function reorderNodeAggregateWasRemovedCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->reorderNodeAggregateWasRemoved($this->outputLine(...)); + } + + /** + * Migrates "nodeAggregateClassification":"tethered" to "regular", in case for copied tethered nodes. + * + * Needed for #5350: https://github.com/neos/neos-development-collection/issues/5350 + * + * Included in November 2024 - before final Neos 9.0 release + * + * @param string $contentRepository Identifier of the Content Repository to migrate + */ + public function migrateCopyTetheredNodeCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->migrateCopyTetheredNode($this->outputLine(...)); + } + + /** + * Status information if content streams still contain legacy copy node events + * + * Needed for #5371: https://github.com/neos/neos-development-collection/pull/5371 + * + * Included in November 2024 - before final Neos 9.0 release + * + * NOTE: To reduce the number of matched content streams and to cleanup the event store run + * `./flow contentStream:removeDangling` and `./flow contentStream:pruneRemovedFromEventStream` + * + * @param string $contentRepository Identifier of the Content Repository to check + */ + public function copyNodesStatusCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->copyNodesStatus($this->outputLine(...)); + } } diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index 4b78f99f13d..86804d3cd93 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -6,28 +6,29 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; +use Neos\ContentRepository\Core\Factory\CommandHookFactoryInterface; +use Neos\ContentRepository\Core\Factory\CommandHooksFactory; use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\Factory\ProjectionsAndCatchUpHooksFactory; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; use Neos\ContentRepositoryRegistry\Exception\ContentRepositoryNotFoundException; use Neos\ContentRepositoryRegistry\Exception\InvalidConfigurationException; +use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\Clock\ClockFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\ContentDimensionSource\ContentDimensionSourceFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\EventStore\EventStoreFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\NodeTypeManager\NodeTypeManagerFactoryInterface; -use Neos\ContentRepositoryRegistry\Factory\UserIdProvider\UserIdProviderFactoryInterface; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\ContentSubgraphWithRuntimeCaches; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\SubgraphCachePool; use Neos\EventStore\EventStoreInterface; @@ -175,8 +176,9 @@ private function buildFactory(ContentRepositoryId $contentRepositoryId): Content $this->buildContentDimensionSource($contentRepositoryId, $contentRepositorySettings), $this->buildPropertySerializer($contentRepositoryId, $contentRepositorySettings), $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings), - $this->buildUserIdProvider($contentRepositoryId, $contentRepositorySettings), - $clock + $this->buildAuthProviderFactory($contentRepositoryId, $contentRepositorySettings), + $clock, + $this->buildCommandHooksFactory($contentRepositoryId, $contentRepositorySettings), ); } catch (\Exception $exception) { throw InvalidConfigurationException::fromException($contentRepositoryId, $exception); @@ -275,6 +277,28 @@ private function buildProjectionsFactory(ContentRepositoryId $contentRepositoryI return $projectionsAndCatchUpHooksFactory; } + /** @param array $contentRepositorySettings */ + private function buildCommandHooksFactory(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): CommandHooksFactory + { + $commandHooksSettings = $contentRepositorySettings['commandHooks'] ?? []; + if (!is_array($commandHooksSettings)) { + throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have the "commandHooks" configured properly. Expected array, got %s.', $contentRepositoryId->value, get_debug_type($commandHooksSettings)); + } + $commandHookFactories = []; + foreach ((new PositionalArraySorter($commandHooksSettings))->toArray() as $name => $commandHookSettings) { + // Allow to unset/disable command hooks + if ($commandHookSettings === null) { + continue; + } + $commandHookFactory = $this->objectManager->get($commandHookSettings['factoryObjectName']); + if (!$commandHookFactory instanceof CommandHookFactoryInterface) { + throw InvalidConfigurationException::fromMessage('Factory object name for command hook "%s" (content repository "%s") is not an instance of %s but %s.', $name, $contentRepositoryId->value, CommandHookFactoryInterface::class, get_debug_type($commandHookFactory)); + } + $commandHookFactories[] = $commandHookFactory; + } + return new CommandHooksFactory(...$commandHookFactories); + } + /** * @param ProjectionFactoryInterface> $projectionFactory */ @@ -293,14 +317,14 @@ private function registerCatchupHookForProjection(mixed $projectionOptions, Proj } /** @param array $contentRepositorySettings */ - private function buildUserIdProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): UserIdProviderInterface + private function buildAuthProviderFactory(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderFactoryInterface { - isset($contentRepositorySettings['userIdProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have userIdProvider.factoryObjectName configured.', $contentRepositoryId->value); - $userIdProviderFactory = $this->objectManager->get($contentRepositorySettings['userIdProvider']['factoryObjectName']); - if (!$userIdProviderFactory instanceof UserIdProviderFactoryInterface) { - throw InvalidConfigurationException::fromMessage('userIdProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, UserIdProviderFactoryInterface::class, get_debug_type($userIdProviderFactory)); + isset($contentRepositorySettings['authProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have authProvider.factoryObjectName configured.', $contentRepositoryId->value); + $authProviderFactory = $this->objectManager->get($contentRepositorySettings['authProvider']['factoryObjectName']); + if (!$authProviderFactory instanceof AuthProviderFactoryInterface) { + throw InvalidConfigurationException::fromMessage('authProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, AuthProviderFactoryInterface::class, get_debug_type($authProviderFactory)); } - return $userIdProviderFactory->build($contentRepositoryId, $contentRepositorySettings['userIdProvider']['options'] ?? []); + return $authProviderFactory; } /** @param array $contentRepositorySettings */ diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php new file mode 100644 index 00000000000..5b8593b7954 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php @@ -0,0 +1,17 @@ + $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new StaticUserIdProvider(UserId::forSystemUser()); - } -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php deleted file mode 100644 index a6145c7e8dc..00000000000 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface; -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php new file mode 100644 index 00000000000..69587bb4806 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php @@ -0,0 +1,25 @@ +projectionservice->catchupAllProjections(CatchUpOptions::create()); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php new file mode 100644 index 00000000000..7a5a1f9013f --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php @@ -0,0 +1,24 @@ +projectionService->resetAllProjections(); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 2a71cbd4333..1e7364dcee2 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -4,10 +4,12 @@ namespace Neos\ContentRepositoryRegistry\Service; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; @@ -15,23 +17,29 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; +use Neos\ContentRepository\Core\Feature\WorkspaceEventStreamName; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; +use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\Command\MigrateEventsCommandController; use Neos\ContentRepositoryRegistry\Factory\EventStore\DoctrineEventStoreFactory; use Neos\EventStore\EventStoreInterface; +use Neos\EventStore\Model\Event; +use Neos\EventStore\Model\Event\EventMetadata; use Neos\EventStore\Model\Event\EventType; use Neos\EventStore\Model\Event\EventTypes; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\EventEnvelope; +use Neos\EventStore\Model\Events; use Neos\EventStore\Model\EventStream\EventStreamFilter; +use Neos\EventStore\Model\EventStream\ExpectedVersion; use Neos\EventStore\Model\EventStream\VirtualStreamName; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -699,27 +707,32 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): } catch (UniqueConstraintViolationException) { $outputFn(' Metadata already exists'); } - $roleAssignment = []; + $roleAssignments = []; if ($workspaceName->isLive()) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:LivePublisher', 'role' => WorkspaceRole::COLLABORATOR->value, ]; + $roleAssignments[] = [ + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, + 'subject' => 'Neos.Neos:Everybody', + 'role' => WorkspaceRole::VIEWER->value, + ]; } elseif ($isInternalWorkspace) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:AbstractEditor', 'role' => WorkspaceRole::COLLABORATOR->value, ]; } elseif ($isPrivateWorkspace) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::USER->value, 'subject' => $workspaceOwner, 'role' => WorkspaceRole::COLLABORATOR->value, ]; } - if ($roleAssignment !== []) { + foreach ($roleAssignments as $roleAssignment) { try { $this->connection->insert('neos_neos_workspace_role', [ 'content_repository_id' => $this->contentRepositoryId->value, @@ -736,6 +749,155 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): $outputFn(sprintf('Added metadata & role assignments for %d workspaces.', $addedWorkspaceMetadata)); } + /** + * Reorders all NodeAggregateWasMoved events to allow replaying in case orphaned nodes existed in previous betas + */ + public function reorderNodeAggregateWasRemoved(\Closure $outputFn): void + { + $liveWorkspaceContentStreamId = null; + // hardcoded to LIVE + foreach ($this->eventStore->load(WorkspaceEventStreamName::fromWorkspaceName(WorkspaceName::forLive())->getEventStreamName(), EventStreamFilter::create(EventTypes::create(EventType::fromString('RootWorkspaceWasCreated')))) as $eventEnvelope) { + $rootWorkspaceWasCreated = self::decodeEventPayload($eventEnvelope); + $liveWorkspaceContentStreamId = ContentStreamId::fromString($rootWorkspaceWasCreated['newContentStreamId']); + break; + } + + if (!$liveWorkspaceContentStreamId) { + throw new \RuntimeException('Workspace live does not exist. No migration necessary.'); + } + + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId) . '_bkp_' . date('Y_m_d_H_i_s'); + $outputFn('Backup: copying events table to %s', [$backupEventTableName]); + $this->copyEventTable($backupEventTableName); + + $liveContentStreamName = ContentStreamEventStreamName::fromContentStreamId($liveWorkspaceContentStreamId)->getEventStreamName(); + // get all NodeAggregateWasRemoved from the live content stream + $eventsToReorder = iterator_to_array($this->eventStore->load($liveContentStreamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeAggregateWasRemoved')))), false); + + // remove all the NodeAggregateWasRemoved events at their sequenceNumbers + $eventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId); + $this->connection->beginTransaction(); + $this->connection->executeStatement( + 'DELETE FROM ' . $eventTableName . ' WHERE sequencenumber IN (:sequenceNumbers)', + [ + 'sequenceNumbers' => array_map(fn (EventEnvelope $eventEnvelope) => $eventEnvelope->sequenceNumber->value, $eventsToReorder) + ], + [ + 'sequenceNumbers' => ArrayParameterType::STRING + ] + ); + $this->connection->commit(); + + $mapper = function (EventEnvelope $eventEnvelope): Event { + $metadata = $event->eventMetadata?->value ?? []; + $metadata['reorderedByMigration'] = sprintf('Originally recorded at %s with sequence number %d', $eventEnvelope->recordedAt->format(\DateTimeInterface::ATOM), $eventEnvelope->sequenceNumber->value); + return new Event( + $eventEnvelope->event->id, + $eventEnvelope->event->type, + $eventEnvelope->event->data, + EventMetadata::fromArray($metadata), + $eventEnvelope->event->causationId, + $eventEnvelope->event->correlationId + ); + }; + + // reapply the NodeAggregateWasRemoved events + $this->eventStore->commit( + $liveContentStreamName, + Events::fromArray(array_map($mapper, $eventsToReorder)), + ExpectedVersion::ANY() + ); + + $outputFn(sprintf('Reordered %d removals. Please replay and rebase your other workspaces.', count($eventsToReorder))); + } + + public function migrateCopyTetheredNode(\Closure $outputFn): void + { + $this->eventsModified = []; + + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId) + . '_bkp_' . date('Y_m_d_H_i_s'); + $outputFn(sprintf('Backup: copying events table to %s', $backupEventTableName)); + + $this->copyEventTable($backupEventTableName); + + $streamName = VirtualStreamName::all(); + $eventStream = $this->eventStore->load($streamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeAggregateWithNodeWasCreated')))); + foreach ($eventStream as $eventEnvelope) { + $outputRewriteNotice = fn(string $message) => $outputFn(sprintf('%s@%s %s', $eventEnvelope->sequenceNumber->value, $eventEnvelope->event->type->value, $message)); + if ($eventEnvelope->event->type->value !== 'NodeAggregateWithNodeWasCreated') { + throw new \RuntimeException(sprintf('Unhandled event: %s', $eventEnvelope->event->type->value)); + } + + $eventMetaData = $eventEnvelope->event->metadata?->value; + // a copy is basically a NodeAggregateWithNodeWasCreated with CopyNodesRecursively command, so we skip others: + if (!$eventMetaData || ($eventMetaData['commandClass'] ?? null) !== CopyNodesRecursively::class) { + continue; + } + + $eventData = self::decodeEventPayload($eventEnvelope); + if ($eventData['nodeAggregateClassification'] !== NodeAggregateClassification::CLASSIFICATION_TETHERED->value) { + // this copy is okay + continue; + } + + $eventData['nodeAggregateClassification'] = NodeAggregateClassification::CLASSIFICATION_REGULAR->value; + $this->updateEventPayload($eventEnvelope->sequenceNumber, $eventData); + + $eventMetaData['commandPayload']['nodeTreeToInsert']['nodeAggregateClassification'] = NodeAggregateClassification::CLASSIFICATION_REGULAR->value; + + $this->updateEventMetaData($eventEnvelope->sequenceNumber, $eventMetaData); + $outputRewriteNotice(sprintf('Copied tethered node "%s" of type "%s" (name: %s) was migrated', $eventData['nodeAggregateId'], $eventData['nodeTypeName'], json_encode($eventData['nodeName']))); + } + + if (!count($this->eventsModified)) { + $outputFn('Migration was not necessary.'); + return; + } + + $outputFn(); + $outputFn(sprintf('Migration applied to %s events. Please replay the projections `./flow cr:projectionReplayAll`', count($this->eventsModified))); + } + + public function copyNodesStatus(\Closure $outputFn): void + { + $unpublishedCopyNodesInWorkspaces = []; + + $streamName = VirtualStreamName::all(); + $eventStream = $this->eventStore->load($streamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeAggregateWithNodeWasCreated')))); + foreach ($eventStream as $eventEnvelope) { + $eventMetaData = $eventEnvelope->event->metadata?->value; + // a copy is basically a NodeAggregateWithNodeWasCreated with CopyNodesRecursively command, so we skip others: + if (!$eventMetaData || ($eventMetaData['commandClass'] ?? null) !== CopyNodesRecursively::class) { + continue; + } + + $eventData = self::decodeEventPayload($eventEnvelope); + + if ($eventData['workspaceName'] !== 'live') { + $unpublishedCopyNodesInWorkspaces[$eventData['contentStreamId'] . ' (' . $eventData['workspaceName'] . ')'][] = sprintf( + '@%s copy node %s to %s', + $eventEnvelope->sequenceNumber->value, + $eventMetaData['commandPayload']['nodeTreeToInsert']['nodeAggregateId'], + $eventMetaData['commandPayload']['targetParentNodeAggregateId'] + ); + } + } + + if ($unpublishedCopyNodesInWorkspaces === []) { + $outputFn('Everything regarding copy nodes okay.'); + return; + } + $outputFn('WARNING: %d content streams contain unpublished legacy copy node events. They MUST be published before migrating to Neos 9 (stable) and will not be publishable afterward.', [count($unpublishedCopyNodesInWorkspaces)]); + foreach ($unpublishedCopyNodesInWorkspaces as $contentStream => $unpublishedCopyNodesInWorkspace) { + $outputFn('Content stream %s', [$contentStream]); + foreach ($unpublishedCopyNodesInWorkspace as $unpublishedCopyNode) { + $outputFn(' - %s', [$unpublishedCopyNode]); + } + } + $outputFn('NOTE: To reduce the number of matched content streams and to cleanup the event store run `./flow contentStream:removeDangling` and `./flow contentStream:pruneRemovedFromEventStream`'); + } + /** ------------------------ */ /** diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php similarity index 83% rename from Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php rename to Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php index 00a946be01a..06f11984d74 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php @@ -14,13 +14,12 @@ use Neos\EventStore\Model\EventStream\VirtualStreamName; /** - * Content Repository service to perform Projection replays + * Content Repository service to perform Projection operations * - * @internal this is currently only used by the {@see CrCommandController} + * @internal */ -final class ProjectionReplayService implements ContentRepositoryServiceInterface +final class ProjectionService implements ContentRepositoryServiceInterface { - public function __construct( private readonly Projections $projections, private readonly ContentRepository $contentRepository, @@ -53,6 +52,22 @@ public function resetAllProjections(): void } } + public function catchupProjection(string $projectionAliasOrClassName, CatchUpOptions $options): void + { + $projectionClassName = $this->resolveProjectionClassName($projectionAliasOrClassName); + $this->contentRepository->catchUpProjection($projectionClassName, $options); + } + + public function catchupAllProjections(CatchUpOptions $options, ?\Closure $progressCallback = null): void + { + foreach ($this->projectionClassNamesAndAliases() as $classNamesAndAlias) { + if ($progressCallback) { + $progressCallback($classNamesAndAlias['alias']); + } + $this->contentRepository->catchUpProjection($classNamesAndAlias['className'], $options); + } + } + public function highestSequenceNumber(): SequenceNumber { foreach ($this->eventStore->load(VirtualStreamName::all())->backwards()->limit(1) as $eventEnvelope) { diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php similarity index 70% rename from Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php rename to Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php index 0f4bc5f7a05..92114d47f1a 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php @@ -6,22 +6,20 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; -use Neos\ContentRepositoryRegistry\Command\CrCommandController; use Neos\Flow\Annotations as Flow; /** - * Factory for the {@see ProjectionReplayService} + * Factory for the {@see ProjectionService} * - * @implements ContentRepositoryServiceFactoryInterface - * @internal this is currently only used by the {@see CrCommandController} + * @implements ContentRepositoryServiceFactoryInterface + * @internal */ #[Flow\Scope("singleton")] -final class ProjectionReplayServiceFactory implements ContentRepositoryServiceFactoryInterface +final class ProjectionServiceFactory implements ContentRepositoryServiceFactoryInterface { - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface { - return new ProjectionReplayService( + return new ProjectionService( $serviceFactoryDependencies->projectionsAndCatchUpHooks->projections, $serviceFactoryDependencies->contentRepository, $serviceFactoryDependencies->eventStore, diff --git a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml index 83b207597eb..80574bfb60f 100644 --- a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml +++ b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml @@ -31,8 +31,8 @@ Neos: contentDimensionSource: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\ContentDimensionSource\ConfigurationBasedContentDimensionSourceFactory - userIdProvider: - factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory + authProvider: + factoryObjectName: Neos\ContentRepositoryRegistry\Factory\AuthProvider\StaticAuthProviderFactory clock: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory @@ -80,3 +80,9 @@ Neos: # factoryObjectName: My\Package\Projection\SomeProjectionFactory # options: {} # catchUpHooks: {} + + # Command Hooks + # + # commandHooks: + # 'My.Package:SomeCommandHook': # just a name + # factoryObjectName: My\Package\CommandHook\SomeCommandHookFactory diff --git a/Neos.Fusion/Classes/Core/FusionSourceCodeCollection.php b/Neos.Fusion/Classes/Core/FusionSourceCodeCollection.php index 0469f0f33ec..c1d4c809330 100644 --- a/Neos.Fusion/Classes/Core/FusionSourceCodeCollection.php +++ b/Neos.Fusion/Classes/Core/FusionSourceCodeCollection.php @@ -52,6 +52,9 @@ public static function tryFromFilePath(string $filePath): self return self::fromFilePath($filePath); } + /** + * @deprecated with Neos 9, remove me :) + */ public static function tryFromPackageRootFusion(string $packageKey): self { $fusionPathAndFilename = sprintf('resource://%s/Private/Fusion/Root.fusion', $packageKey); diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 4e75aa55aa1..d3634d48125 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -19,14 +19,16 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Service\AssetService; +use Neos\Neos\AssetUsage\Dto\AssetUsageReference; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Service\UserService; -use Neos\Neos\AssetUsage\Dto\AssetUsageReference; /** * Controller for asset usage handling @@ -65,6 +67,18 @@ class UsageController extends ActionController */ protected $workspaceService; + /** + * @Flow\Inject + * @var SecurityContext + */ + protected $securityContext; + + /** + * @Flow\Inject + * @var ContentRepositoryAuthorizationService + */ + protected $contentRepositoryAuthorizationService; + /** * Get Related Nodes for an asset * @@ -103,12 +117,7 @@ public function relatedNodesAction(AssetInterface $asset) ); $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null; - $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser( - $currentContentRepositoryId, - $usage->getWorkspaceName(), - $currentUser - ); - + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($currentContentRepositoryId, $usage->getWorkspaceName(), $this->securityContext->getRoles(), $this->userService->getBackendUser()?->getId()); $workspace = $contentRepository->findWorkspaceByName($usage->getWorkspaceName()); $inaccessibleRelation['nodeIdentifier'] = $usage->getNodeAggregateId()->value; diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index e52b55bb99c..59e2c733a99 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -118,17 +118,23 @@ private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $node $contentGraph = $this->contentGraphReadModel->getContentGraph($workspaceName); foreach ($dimensionSpacePoints as $dimensionSpacePoint) { + $this->assetUsageIndexingService->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( + $this->contentRepositoryId, + $workspaceName, + $nodeAggregateId, + $dimensionSpacePoint + ); + $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); - $node = $subgraph->findNodeById($nodeAggregateId); $descendants = $subgraph->findDescendantNodes($nodeAggregateId, FindDescendantNodesFilter::create()); - $nodes = array_merge([$node], iterator_to_array($descendants)); - - /** @var Node $node */ - foreach ($nodes as $node) { - $this->assetUsageIndexingService->removeIndexForNode( + /** @var Node $descendant */ + foreach ($descendants as $descendant) { + $this->assetUsageIndexingService->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( $this->contentRepositoryId, - $node + $descendant->workspaceName, + $descendant->aggregateId, + $descendant->dimensionSpacePoint ); } } diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php index a49c301a24d..f284ccf34b0 100644 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -171,18 +171,6 @@ public function removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint ); } - public function removeIndexForNode( - ContentRepositoryId $contentRepositoryId, - Node $node - ): void { - $this->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( - $contentRepositoryId, - $node->workspaceName, - $node->aggregateId, - $node->dimensionSpacePoint - ); - } - public function removeIndexForWorkspace( ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php deleted file mode 100644 index d94cbeac1c9..00000000000 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ /dev/null @@ -1,175 +0,0 @@ -contentRepositoryRegistry->get($contentRepositoryId); - - Files::createDirectoryRecursively($path); - $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); - - $liveWorkspace = $contentRepositoryInstance->findWorkspaceByName(WorkspaceName::forLive()); - if ($liveWorkspace === null) { - throw new \RuntimeException('Failed to find live workspace', 1716652280); - } - - $exportService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ExportServiceFactory( - $filesystem, - $liveWorkspace, - $this->assetRepository, - $this->assetUsageService, - ) - ); - assert($exportService instanceof ExportService); - $exportService->runAllProcessors($this->outputLine(...), $verbose); - $this->outputLine('Done'); - } - - /** - * Import the events from the path into the specified content repository - * - * @param string $path The path of the stored events like resource://Neos.Demo/Private/Content - * @param string $contentRepository The content repository identifier - * @param bool $verbose If set, all notices will be rendered - * @throws \Exception - */ - public function importCommand(string $path, string $contentRepository = 'default', bool $verbose = false): void - { - $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); - - $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $contentStreamIdentifier = ContentStreamId::create(); - - $importService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ImportServiceFactory( - $filesystem, - $contentStreamIdentifier, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - ) - ); - assert($importService instanceof ImportService); - try { - $importService->runAllProcessors($this->outputLine(...), $verbose); - } catch (\RuntimeException $exception) { - $this->outputLine('Error: ' . $exception->getMessage() . ''); - $this->outputLine('Import stopped.'); - return; - } - - $this->outputLine('Replaying projections'); - - $projectionService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->projectionReplayServiceFactory); - $projectionService->replayAllProjections(CatchUpOptions::create()); - - $this->outputLine('Assigning live workspace role'); - // set the live-workspace title to (implicitly) create the metadata record for this workspace - $this->workspaceService->setWorkspaceTitle($contentRepositoryId, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace')); - $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); - - $this->outputLine('Done'); - } - - /** - * This will completely prune the data of the specified content repository. - * - * @param string $contentRepository Name of the content repository where the data should be pruned from. - * @param bool $force Prune the cr without confirmation. This cannot be reverted! - * @return void - */ - public function pruneCommand(string $contentRepository = 'default', bool $force = false): void - { - if (!$force && !$this->output->askConfirmation(sprintf('> This will prune your content repository "%s". Are you sure to proceed? (y/n) ', $contentRepository), false)) { - $this->outputLine('Abort.'); - return; - } - - $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - - $contentStreamPruner = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ContentStreamPrunerFactory() - ); - - $workspaceMaintenanceService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new WorkspaceMaintenanceServiceFactory() - ); - - $projectionService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - $this->projectionServiceFactory - ); - - // remove the workspace metadata and role assignments for this cr - $this->workspaceService->pruneRoleAssignments($contentRepositoryId); - $this->workspaceService->pruneWorkspaceMetadata($contentRepositoryId); - - // reset the events table - $contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); - - // reset the projections state - $projectionService->resetAllProjections(); - - $this->outputLine('Done.'); - } -} diff --git a/Neos.Neos/Classes/Command/SiteCommandController.php b/Neos.Neos/Classes/Command/SiteCommandController.php index c3763cf7252..24037d9b95e 100644 --- a/Neos.Neos/Classes/Command/SiteCommandController.php +++ b/Neos.Neos/Classes/Command/SiteCommandController.php @@ -14,8 +14,11 @@ namespace Neos\Neos\Command; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\NodeNameIsAlreadyCovered; use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFound; +use Neos\ContentRepository\Export\Severity; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; @@ -24,9 +27,15 @@ use Neos\Neos\Domain\Exception\SiteNodeNameIsAlreadyInUseByAnotherSite; use Neos\Neos\Domain\Exception\SiteNodeTypeIsInvalid; use Neos\Neos\Domain\Model\Site; +use Neos\Neos\Domain\Repository\DomainRepository; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Neos\Domain\Service\SiteExportService; +use Neos\Neos\Domain\Service\SiteImportService; +use Neos\Neos\Domain\Service\SitePruningService; use Neos\Neos\Domain\Service\SiteService; +use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Utility\Files; /** * The Site Command Controller @@ -41,6 +50,12 @@ class SiteCommandController extends CommandController */ protected $siteRepository; + /** + * @Flow\Inject + * @var DomainRepository + */ + protected $domainRepository; + /** * @Flow\Inject * @var SiteService @@ -53,12 +68,42 @@ class SiteCommandController extends CommandController */ protected $packageManager; + /** + * @Flow\Inject + * @var ContentRepositoryRegistry + */ + protected $contentRepositoryRegistry; + /** * @Flow\Inject * @var PersistenceManagerInterface */ protected $persistenceManager; + /** + * @Flow\Inject + * @var SiteImportService + */ + protected $siteImportService; + + /** + * @Flow\Inject + * @var SiteExportService + */ + protected $siteExportService; + + /** + * @Flow\Inject + * @var SitePruningService + */ + protected $sitePruningService; + + /** + * @Flow\Inject + * @var WorkspaceService + */ + protected $workspaceService; + /** * Create a new site * @@ -111,31 +156,94 @@ public function createCommand($name, $packageKey, $nodeType, $nodeName = null, $ } /** - * Remove site with content and related data (with globbing) + * Import sites + * + * This command allows importing sites from the given path/package. The format must + * be identical to that produced by the exportAll command. * - * In the future we need some more sophisticated cleanup. + * If a path is specified, this command expects the corresponding directory to contain the exported files * - * @param string $siteNode Name for site root nodes to clear only content of this sites (globbing is supported) + * If a package key is specified, this command expects the export files to be located in the private resources + * directory of the given package (Resources/Private/Content). + * + * **Note that the live workspace has to be empty prior to importing.** + * + * @param string|null $packageKey Package key specifying the package containing the sites content + * @param string|null $path relative or absolute path and filename to the export files * @return void */ - public function pruneCommand($siteNode) + public function importAllCommand(string $packageKey = null, string $path = null, string $contentRepository = 'default', bool $verbose = false): void { - $sites = $this->findSitesByNodeNamePattern($siteNode); - if (empty($sites)) { - $this->outputLine('No Site found for pattern "%s".', [$siteNode]); - // Help the user a little about what he needs to provide as a parameter here - $this->outputLine('To find out which sites you have, use the site:list command.'); - $this->outputLine('The site:prune command expects the "Node name" from the site list as a parameter.'); - $this->outputLine('If you want to delete all sites, you can run site:prune \'*\'.'); - $this->quit(1); - } - foreach ($sites as $site) { - $this->siteService->pruneSite($site); - $this->outputLine( - 'Site with root "%s" matched pattern "%s" and has been removed.', - [$site->getNodeName(), $siteNode] - ); + // TODO check if this warning is still necessary with Neos 9 + // Since this command uses a lot of memory when large sites are imported, we warn the user to watch for + // the confirmation of a successful import. + $this->outputLine('This command can use a lot of memory when importing sites with many resources.'); + $this->outputLine('If the import is successful, you will see a message saying "Import finished".'); + $this->outputLine('If you do not see this message, the import failed, most likely due to insufficient memory.'); + $this->outputLine('Increase the memory_limit configuration parameter of your php CLI to attempt to fix this.'); + $this->outputLine('Starting import...'); + $this->outputLine('---'); + + $path = $this->determineTargetPath($packageKey, $path); + + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + + $this->siteImportService->importFromPath( + $contentRepositoryId, + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); + + $this->outputLine('Import finished.'); + } + + /** + * Export sites + * + * This command exports all sites of the content repository. + * + * If a path is specified, this command creates the directory if needed and exports into that. + * + * If a package key is specified, this command exports to the private resources + * directory of the given package (Resources/Private/Content). + * + * @param string|null $packageKey Package key specifying the package containing the sites content + * @param string|null $path relative or absolute path and filename to the export files + * @return void + */ + public function exportAllCommand(string $packageKey = null, string $path = null, string $contentRepository = 'default', bool $verbose = false): void + { + $path = $this->determineTargetPath($packageKey, $path); + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + Files::createDirectoryRecursively($path); + $this->siteExportService->exportToPath( + $contentRepositoryId, + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); + } + + /** + * This will completely prune the data of the specified content repository and remove all site-records. + * + * @param bool $force Prune the cr without confirmation. This cannot be reverted! + * @return void + */ + public function pruneAllCommand(string $contentRepository = 'default', bool $force = false, bool $verbose = false): void + { + if (!$force && !$this->output->askConfirmation(sprintf('> This will prune your content repository "%s" and all its attached sites. Are you sure to proceed? (y/n) ', $contentRepository), false)) { + $this->outputLine('Abort.'); + return; } + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + + $this->sitePruningService->pruneAll( + $contentRepositoryId, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); } /** @@ -220,4 +328,51 @@ function (Site $site) use ($siteNodePattern) { } ); } + + protected function determineTargetPath(?string $packageKey, ?string $path): string + { + $exceedingArguments = $this->request->getExceedingArguments(); + if (isset($exceedingArguments[0]) && $packageKey === null && $path === null) { + if (file_exists($exceedingArguments[0])) { + $path = $exceedingArguments[0]; + } elseif ($this->packageManager->isPackageAvailable($exceedingArguments[0])) { + $packageKey = $exceedingArguments[0]; + } + } + if ($packageKey === null && $path === null) { + $this->outputLine('You have to specify either --package-key or --path'); + $this->quit(1); + } + if ($path === null) { + $package = $this->packageManager->getPackage($packageKey); + $path = Files::concatenatePaths([$package->getPackagePath(), 'Resources/Private/Content']); + } + if (str_starts_with($path, 'resource://')) { + $this->outputLine('Resource paths are not allowed, please use --package-key instead or a real path.'); + $this->quit(1); + } + return $path; + } + + protected function createOnProcessorClosure(): \Closure + { + $onProcessor = function (string $processorLabel) { + $this->outputLine('%s...', [$processorLabel]); + }; + return $onProcessor; + } + + protected function createOnMessageClosure(bool $verbose): \Closure + { + return function (Severity $severity, string $message) use ($verbose) { + if (!$verbose && $severity === Severity::NOTICE) { + return; + } + $this->outputLine(match ($severity) { + Severity::NOTICE => $message, + Severity::WARNING => sprintf('Warning: %s', $message), + Severity::ERROR => sprintf('Error: %s', $message), + }); + }; + } } diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index ddd62e7a8b3..876e5b8d199 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -255,6 +255,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription, * * Without explicit workspace roles, only administrators can change the corresponding workspace. * With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: + * - viewer: Can read from the workspace * - collaborator: Can read from and write to the workspace * - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) * @@ -268,7 +269,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription, * * @param string $workspace Name of the workspace, for example "some-workspace" * @param string $subject The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user - * @param string $role Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + * @param string $role Role to assign, either 'viewer', 'collaborator' or 'manager' – a viewer can only read from the workspace, a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself * @param string $contentRepository Identifier of the content repository. (Default: 'default') * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user * @throws StopCommandException @@ -284,25 +285,16 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), }; $workspaceRole = match ($role) { + 'viewer' => WorkspaceRole::VIEWER, 'collaborator' => WorkspaceRole::COLLABORATOR, 'manager' => WorkspaceRole::MANAGER, - default => throw new \InvalidArgumentException(sprintf('role must be "collaborator" or "manager", given "%s"', $role), 1728398880), + default => throw new \InvalidArgumentException(sprintf('role must be "viewer", "collaborator" or "manager", given "%s"', $role), 1728398880), }; - if ($subjectType === WorkspaceRoleSubjectType::USER) { - $neosUser = $this->userService->getUser($subject); - if ($neosUser === null) { - $this->outputLine('The user "%s" specified as subject does not exist', [$subject]); - $this->quit(1); - } - $roleSubject = WorkspaceRoleSubject::fromString($neosUser->getId()->value); - } else { - $roleSubject = WorkspaceRoleSubject::fromString($subject); - } + $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject); $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::create( - $subjectType, $roleSubject, $workspaceRole ) @@ -331,11 +323,10 @@ public function unassignRoleCommand(string $workspace, string $subject, string $ 'user' => WorkspaceRoleSubjectType::USER, default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), }; - $roleSubject = WorkspaceRoleSubject::fromString($subject); + $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject); $this->workspaceService->unassignWorkspaceRole( $contentRepositoryId, $workspaceName, - $subjectType, $roleSubject, ); $this->outputLine('Removed role assignment from subject "%s" for workspace "%s"', [$roleSubject->value, $workspaceName->value]); @@ -517,7 +508,7 @@ public function showCommand(string $workspace, string $contentRepository = 'defa return; } $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [ - $assignment->subjectType->value, + $assignment->subject->type->value, $assignment->subject->value, $assignment->role->value, ], iterator_to_array($workspaceRoleAssignments)), [ @@ -526,4 +517,21 @@ public function showCommand(string $workspace, string $contentRepository = 'defa 'Role', ]); } + + // ----------------------- + + private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject + { + if ($subjectType === WorkspaceRoleSubjectType::USER) { + $neosUser = $this->userService->getUser($usernameOrRoleIdentifier); + if ($neosUser === null) { + $this->outputLine('The user "%s" specified as subject does not exist', [$usernameOrRoleIdentifier]); + $this->quit(1); + } + $roleSubject = WorkspaceRoleSubject::createForUser($neosUser->getId()); + } else { + $roleSubject = WorkspaceRoleSubject::createForGroup($usernameOrRoleIdentifier); + } + return $roleSubject; + } } diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index a426c24328f..19300dea675 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -14,6 +14,7 @@ namespace Neos\Neos\Controller\Frontend; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindSubtreeFilter; @@ -35,6 +36,7 @@ use Neos\Flow\Session\SessionInterface; use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\RenderingMode; +use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\RenderingModeService; use Neos\Neos\FrontendRouting\Exception\InvalidShortcutException; @@ -42,6 +44,7 @@ use Neos\Neos\FrontendRouting\NodeShortcutResolver; use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; use Neos\Neos\View\FusionView; @@ -110,6 +113,9 @@ class NodeController extends ActionController #[Flow\Inject] protected NodeUriBuilderFactory $nodeUriBuilderFactory; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * @param string $node * @throws NodeNotFoundException @@ -125,21 +131,12 @@ public function previewAction(string $node): void { // @todo add $renderingModeName as parameter and append it for successive links again as get parameter to node uris $renderingMode = $this->renderingModeService->findByCurrentUser(); - - $visibilityConstraints = VisibilityConstraints::frontend(); - if ($this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.GeneralAccess')) { - $visibilityConstraints = VisibilityConstraints::withoutRestrictions(); - } - $siteDetectionResult = SiteDetectionResult::fromRequest($this->request->getHttpRequest()); $contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId); $nodeAddress = NodeAddress::fromJsonString($node); - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $visibilityConstraints - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); @@ -197,17 +194,20 @@ public function previewAction(string $node): void public function showAction(string $node): void { $nodeAddress = NodeAddress::fromJsonString($node); - unset($node); if (!$nodeAddress->workspaceName->isLive()) { throw new NodeNotFoundException('The requested node isn\'t accessible to the current user', 1430218623); } $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - VisibilityConstraints::frontend() - ); + $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraints($contentRepository->id, $this->securityContext->getRoles()); + // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to + // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. + // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated, + // to ensure that disabled nodes are NEVER shown recursively. + $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled()); + $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints); + $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); diff --git a/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php b/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php new file mode 100644 index 00000000000..a75ae98ad96 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Exception/TetheredNodesCannotBePartiallyCopied.php @@ -0,0 +1,24 @@ +getSiteData(); + $context->files->write( + 'sites.json', + json_encode($sites, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + } + + /** + * @return SiteShape[] + */ + private function getSiteData(): array + { + $sites = $this->findSites($this->workspaceName); + $siteData = []; + foreach ($sites as $site) { + $siteData[] = [ + "name" => $site->getName(), + "nodeName" => $site->getNodeName()->value, + "siteResourcesPackageKey" => $site->getSiteResourcesPackageKey(), + "online" => $site->isOnline(), + "domains" => array_map( + fn(Domain $domain) => [ + 'hostname' => $domain->getHostname(), + 'scheme' => $domain->getScheme(), + 'port' => $domain->getPort(), + 'active' => $domain->getActive(), + 'primary' => $domain === $site->getPrimaryDomain(fallbackToActive: false), + ], + $site->getDomains()->toArray() + ) + ]; + } + + return $siteData; + } + + /** + * @param WorkspaceName $workspaceName + * @return Site[] + */ + private function findSites(WorkspaceName $workspaceName): array + { + $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + $sitesNodeAggregate = $contentGraph->findRootNodeAggregateByType(NodeTypeNameFactory::forSites()); + if ($sitesNodeAggregate === null) { + return []; + } + + $siteNodeAggregates = $contentGraph->findChildNodeAggregates($sitesNodeAggregate->nodeAggregateId); + $sites = []; + foreach ($siteNodeAggregates as $siteNodeAggregate) { + $siteNodeName = $siteNodeAggregate->nodeName?->value; + if ($siteNodeName === null) { + continue; + } + $site = $this->siteRepository->findOneByNodeName($siteNodeName); + if ($site === null) { + continue; + } + $sites[] = $site; + } + return $sites; + } +} diff --git a/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php new file mode 100644 index 00000000000..788dad93734 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php @@ -0,0 +1,50 @@ +dispatch(Severity::NOTICE, 'Creating live workspace'); + $liveWorkspace = $this->contentRepository->findWorkspaceByName(WorkspaceName::forLive()); + if ($liveWorkspace !== null) { + $context->dispatch(Severity::NOTICE, 'Workspace already exists, skipping'); + return; + } + $this->workspaceService->createRootWorkspace($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace'), WorkspaceDescription::fromString('')); + $this->workspaceService->assignWorkspaceRole($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + } +} diff --git a/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php b/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php new file mode 100644 index 00000000000..9c785a8a6d1 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php @@ -0,0 +1,135 @@ +files->has('sites.json')) { + $sitesJson = $context->files->read('sites.json'); + try { + $sites = json_decode($sitesJson, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode sites.json: {$e->getMessage()}", 1729506117, $e); + } + } else { + $context->dispatch(Severity::WARNING, 'Deprecated legacy handling: No "sites.json" in export found, attempting to extract neos sites from the events. Please update the export soonish.'); + $sites = self::extractSitesFromEventStream($context); + } + + /** @var SiteShape $site */ + foreach ($sites as $site) { + $context->dispatch(Severity::NOTICE, sprintf('Creating site "%s"', $site['name'])); + + $siteNodeName = NodeName::fromString($site['nodeName']); + if ($this->siteRepository->findOneByNodeName($siteNodeName->value)) { + $context->dispatch(Severity::NOTICE, sprintf('Site for node name "%s" already exists, skipping', $siteNodeName->value)); + continue; + } + $siteInstance = new Site($siteNodeName->value); + $siteInstance->setSiteResourcesPackageKey($site['siteResourcesPackageKey']); + $siteInstance->setState($site['online'] ? Site::STATE_ONLINE : Site::STATE_OFFLINE); + $siteInstance->setName($site['name']); + $this->siteRepository->add($siteInstance); + $this->persistenceManager->persistAll(); + foreach ($site['domains'] as $domain) { + $domainInstance = $this->domainRepository->findByHostname($domain['hostname'])->getFirst(); + if ($domainInstance instanceof Domain) { + $context->dispatch(Severity::NOTICE, sprintf('Domain "%s" already exists. Adding it to site "%s".', $domain['hostname'], $site['name'])); + } else { + $domainInstance = new Domain(); + $domainInstance->setSite($siteInstance); + $domainInstance->setHostname($domain['hostname']); + $domainInstance->setPort($domain['port'] ?? null); + $domainInstance->setScheme($domain['scheme'] ?? null); + $domainInstance->setActive($domain['active'] ?? false); + $this->domainRepository->add($domainInstance); + } + if ($domain['primary'] ?? false) { + $siteInstance->setPrimaryDomain($domainInstance); + $this->siteRepository->update($siteInstance); + } + $this->persistenceManager->persistAll(); + } + } + } + + /** + * @deprecated with Neos 9 Beta 15 please make sure that exports contain `sites.json` + * @return array + */ + private static function extractSitesFromEventStream(ProcessingContext $context): array + { + $eventFileResource = $context->files->readStream('events.jsonl'); + $siteRooNodeAggregateId = null; + $sites = []; + while (($line = fgets($eventFileResource)) !== false) { + $event = ExportedEvent::fromJson($line); + if ($event->type === 'RootNodeAggregateWithNodeWasCreated' && $event->payload['nodeTypeName'] === NodeTypeNameFactory::NAME_SITES) { + $siteRooNodeAggregateId = $event->payload['nodeAggregateId']; + continue; + } + if ($event->type === 'NodeAggregateWithNodeWasCreated' && $event->payload['parentNodeAggregateId'] === $siteRooNodeAggregateId) { + if (!isset($event->payload['nodeName'])) { + throw new \RuntimeException(sprintf('The nodeName of the site node "%s" must not be empty', $event->payload['nodeAggregateId']), 1731236316); + } + $sites[] = [ + 'siteResourcesPackageKey' => self::extractPackageKeyFromNodeTypeName($event->payload['nodeTypeName']), + 'name' => $event->payload['initialPropertyValues']['title']['value'] ?? $event->payload['nodeTypeName'], + 'nodeTypeName' => $event->payload['nodeTypeName'], + 'nodeName' => $event->payload['nodeName'], + 'domains' => [], + 'online' => true + ]; + } + }; + return $sites; + } + + private static function extractPackageKeyFromNodeTypeName(string $nodeTypeName): string + { + if (preg_match('/^([^:])+/', $nodeTypeName, $matches) !== 1) { + throw new \RuntimeException("Failed to extract package key from '$nodeTypeName'.", 1729505701); + } + return $matches[0]; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php new file mode 100644 index 00000000000..62201162bba --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -0,0 +1,62 @@ +reason; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/Site.php b/Neos.Neos/Classes/Domain/Model/Site.php index d6937a6655d..98d3cf8f7c5 100644 --- a/Neos.Neos/Classes/Domain/Model/Site.php +++ b/Neos.Neos/Classes/Domain/Model/Site.php @@ -63,7 +63,10 @@ class Site * Node name of this site in the content repository. * * The first level of nodes of a site can be reached via a path like - * "/Sites/MySite/" where "MySite" is the nodeName. + * "//my-site" where "my-site" is the nodeName. + * + * TODO use node aggregate identifier instead of node name + * see https://github.com/neos/neos-development-collection/issues/4470 * * @var string * @Flow\Identity @@ -328,11 +331,15 @@ public function setPrimaryDomain(Domain $domain = null) /** * Returns the primary domain, if one has been defined. * + * @param boolean $fallbackToActive if true falls back to the first active domain instead returning null if no primary domain was explicitly set * @return ?Domain The primary domain or NULL * @api */ - public function getPrimaryDomain(): ?Domain + public function getPrimaryDomain(bool $fallbackToActive = true): ?Domain { + if (!$fallbackToActive) { + return $this->primaryDomain; + } return $this->primaryDomain instanceof Domain && $this->primaryDomain->getActive() ? $this->primaryDomain : $this->getFirstActiveDomain(); diff --git a/Neos.Neos/Classes/Domain/Model/UserId.php b/Neos.Neos/Classes/Domain/Model/UserId.php index 2011ebb9a56..cf73375ddee 100644 --- a/Neos.Neos/Classes/Domain/Model/UserId.php +++ b/Neos.Neos/Classes/Domain/Model/UserId.php @@ -15,7 +15,7 @@ public function __construct( public string $value ) { if (!preg_match('/^([a-z0-9\-]{1,40})$/', $value)) { - throw new \InvalidArgumentException(sprintf('Invalid user id "%s" (a user id must only contain lowercase characters, numbers and the "-" sign).', 1718293224)); + throw new \InvalidArgumentException(sprintf('Invalid user id "%s" (a user id must only contain lowercase characters, numbers and the "-" sign).', $this->value), 1718293224); } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index faf543259cf..67f1f970110 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -5,15 +5,16 @@ namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** - * Calculated permissions a specific user has on a workspace + * Evaluated permissions a specific user has on a workspace, usually evaluated by the {@see ContentRepositoryAuthorizationService} * * - read: Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) * - * @api + * @api because it is returned by the {@see ContentRepositoryAuthorizationService} */ #[Flow\Proxy(false)] final readonly class WorkspacePermissions @@ -23,28 +24,49 @@ * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) */ - public static function create( - bool $read, - bool $write, - bool $manage, - ): self { - return new self($read, $write, $manage); + private function __construct( + public bool $read, + public bool $write, + public bool $manage, + private string $reason, + ) { } /** * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()} */ - private function __construct( - public bool $read, - public bool $write, - public bool $manage, - ) { + public static function create( + bool $read, + bool $write, + bool $manage, + string $reason, + ): self { + return new self($read, $write, $manage, $reason); } - public static function all(): self + public static function all(string $reason): self + { + return new self(true, true, true, $reason); + } + + public static function manage(string $reason): self + { + return new self(false, false, true, $reason); + } + + public static function none(string $reason): self + { + return new self(false, false, false, $reason); + } + + /** + * Human-readable explanation for why this permission was evaluated + */ + public function getReason(): string { - return new self(true, true, true); + return $this->reason; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index 898c961f5a0..0b487fb03f9 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -12,6 +12,11 @@ */ enum WorkspaceRole : string { + /** + * Can read from the workspace + */ + case VIEWER = 'VIEWER'; + /** * Can read from and write to the workspace */ @@ -27,11 +32,12 @@ public function isAtLeast(self $role): bool return $this->specificity() >= $role->specificity(); } - private function specificity(): int + public function specificity(): int { return match ($this) { - self::COLLABORATOR => 1, - self::MANAGER => 2, + self::VIEWER => 1, + self::COLLABORATOR => 2, + self::MANAGER => 3, }; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php index fd7d5a7896f..f8206c8d2ce 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -15,25 +15,22 @@ final readonly class WorkspaceRoleAssignment { private function __construct( - public WorkspaceRoleSubjectType $subjectType, public WorkspaceRoleSubject $subject, public WorkspaceRole $role, ) { } public static function create( - WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role, ): self { - return new self($subjectType, $subject, $role); + return new self($subject, $role); } public static function createForUser(UserId $userId, WorkspaceRole $role): self { return new self( - WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($userId->value), + WorkspaceRoleSubject::createForUser($userId), $role ); } @@ -41,8 +38,7 @@ public static function createForUser(UserId $userId, WorkspaceRole $role): self public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role): self { return new self( - WorkspaceRoleSubjectType::GROUP, - WorkspaceRoleSubject::fromString($flowRoleIdentifier), + WorkspaceRoleSubject::createForGroup($flowRoleIdentifier), $role ); } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php index 82dc1eb4a3f..a63eb23b899 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php @@ -5,7 +5,6 @@ namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; -use Traversable; /** * A set of {@see WorkspaceRoleAssignment} instances @@ -39,7 +38,7 @@ public function isEmpty(): bool return $this->assignments === []; } - public function getIterator(): Traversable + public function getIterator(): \Traversable { yield from $this->assignments; } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php index fb80329b09d..c53388bb23c 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -12,28 +12,42 @@ * @api */ #[Flow\Proxy(false)] -final readonly class WorkspaceRoleSubject implements \JsonSerializable +final readonly class WorkspaceRoleSubject { - public function __construct( - public string $value + private function __construct( + public WorkspaceRoleSubjectType $type, + public string $value, ) { if (preg_match('/^[\p{L}\p{P}\d .]{1,200}$/u', $this->value) !== 1) { throw new \InvalidArgumentException(sprintf('"%s" is not a valid workspace role subject.', $value), 1728384932); } } - public static function fromString(string $value): self + public static function createForUser(UserId $userId): self { - return new self($value); + return new self( + WorkspaceRoleSubjectType::USER, + $userId->value, + ); } - public function jsonSerialize(): string + public static function createForGroup(string $flowRoleIdentifier): self { - return $this->value; + return new self( + WorkspaceRoleSubjectType::GROUP, + $flowRoleIdentifier, + ); + } + + public static function create( + WorkspaceRoleSubjectType $type, + string $value, + ): self { + return new self($type, $value); } public function equals(self $other): bool { - return $this->value === $other->value; + return $this->type === $other->type && $this->value === $other->value; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php new file mode 100644 index 00000000000..5af2a3b2b36 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php @@ -0,0 +1,50 @@ + + * @api + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceRoleSubjects implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $subjects; + + private function __construct(WorkspaceRoleSubject ...$subjects) + { + $this->subjects = $subjects; + } + + /** + * @param array $subjects + */ + public static function fromArray(array $subjects): self + { + return new self(...$subjects); + } + + public function isEmpty(): bool + { + return $this->subjects === []; + } + + public function getIterator(): \Traversable + { + yield from $this->subjects; + } + + public function count(): int + { + return count($this->subjects); + } +} diff --git a/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php new file mode 100644 index 00000000000..0b94195c9d1 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php @@ -0,0 +1,35 @@ +contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); + } +} diff --git a/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php new file mode 100644 index 00000000000..c98174f0b6a --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php @@ -0,0 +1,38 @@ +workspaceMetadataAndRoleRepository->pruneRoleAssignments($this->contentRepositoryId); + $this->workspaceMetadataAndRoleRepository->pruneWorkspaceMetadata($this->contentRepositoryId); + } +} diff --git a/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php new file mode 100644 index 00000000000..d393d13f828 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php @@ -0,0 +1,93 @@ +contentRepository->getContentGraph($this->workspaceName); + } catch (WorkspaceDoesNotExist) { + $context->dispatch(Severity::NOTICE, sprintf('Could not find any matching sites, because the workspace "%s" does not exist.', $this->workspaceName->value)); + return; + } + $sites = $this->findAllSites($contentGraph); + foreach ($sites as $site) { + $domains = $site->getDomains(); + if ($site->getPrimaryDomain() !== null) { + $site->setPrimaryDomain(null); + $this->siteRepository->update($site); + } + foreach ($domains as $domain) { + $this->domainRepository->remove($domain); + } + $this->persistenceManager->persistAll(); + $this->siteRepository->remove($site); + $this->persistenceManager->persistAll(); + } + } + + /** + * @return Site[] + */ + protected function findAllSites(ContentGraphInterface $contentGraph): array + { + $sitesNodeAggregate = $contentGraph->findRootNodeAggregateByType(NodeTypeNameFactory::forSites()); + if ($sitesNodeAggregate === null) { + return []; + } + + $siteNodeAggregates = $contentGraph->findChildNodeAggregates($sitesNodeAggregate->nodeAggregateId); + $sites = []; + foreach ($siteNodeAggregates as $siteNodeAggregate) { + $siteNodeName = $siteNodeAggregate->nodeName?->value; + if ($siteNodeName === null) { + continue; + } + $site = $this->siteRepository->findOneByNodeName($siteNodeName); + if ($site === null) { + continue; + } + $sites[] = $site; + } + return $sites; + } +} diff --git a/Neos.Neos/Classes/Domain/Repository/DomainRepository.php b/Neos.Neos/Classes/Domain/Repository/DomainRepository.php index a86fe3eac93..6b1cb089ef2 100644 --- a/Neos.Neos/Classes/Domain/Repository/DomainRepository.php +++ b/Neos.Neos/Classes/Domain/Repository/DomainRepository.php @@ -84,6 +84,7 @@ public function findByHost($hostname, $onlyActive = false) public function findOneByHost($hostname, $onlyActive = false): ?Domain { $allMatchingDomains = $this->findByHost($hostname, $onlyActive); + // Fixme, requesting `onedimension.localhost` if domain `localhost` exists in the set would return the latter because of `getSortedMatches` return count($allMatchingDomains) > 0 ? $allMatchingDomains[0] : null; } diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php new file mode 100644 index 00000000000..ed6c928f6ca --- /dev/null +++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php @@ -0,0 +1,314 @@ +dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $assignment->subject->type->value, + 'subject' => $assignment->subject->value, + 'role' => $assignment->role->value, + ]); + } catch (UniqueConstraintViolationException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); + } + } + + /** + * The public and documented API is {@see WorkspaceService::unassignWorkspaceRole} + */ + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void + { + try { + $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subject->type->value, + 'subject' => $subject->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); + } + if ($affectedRows === 0) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); + } + } + + public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchAllAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); + } + return WorkspaceRoleAssignments::fromArray( + array_map(static fn (array $row) => WorkspaceRoleAssignment::create( + WorkspaceRoleSubject::create( + WorkspaceRoleSubjectType::from($row['subject_type']), + $row['subject'], + ), + WorkspaceRole::from($row['role']), + ), $rows) + ); + } + + public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole + { + $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; + $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases())); + $query = <<type === WorkspaceRoleSubjectType::GROUP) { + $groupSubjectValues[] = $subject->value; + } else { + $userSubjectValues[] = $subject->value; + } + } + try { + $role = $this->dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, + 'userSubjectValues' => $userSubjectValues, + 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, + 'groupSubjectValues' => $groupSubjectValues, + ], [ + 'userSubjectValues' => ArrayParameterType::STRING, + 'groupSubjectValues' => ArrayParameterType::STRING, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e); + } + if ($role === false) { + return null; + } + return WorkspaceRole::from($role); + } + + /** + * Removes all workspace metadata records for the specified content repository id + */ + public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to prune workspace metadata Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512100, $e); + } + } + + /** + * Removes all workspace role assignments for the specified content repository id + */ + public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to prune workspace roles for Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512142, $e); + } + } + + /** + * The public and documented API is {@see WorkspaceService::getWorkspaceMetadata()} + */ + public function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata + { + $table = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf( + 'Failed to fetch metadata for workspace "%s" (Content Repository "%s), please ensure the database schema is up to date. %s', + $workspaceName->value, + $contentRepositoryId->value, + $e->getMessage() + ), 1727782164, $e); + } + if (!is_array($metadataRow)) { + return null; + } + return new WorkspaceMetadata( + WorkspaceTitle::fromString($metadataRow['title']), + WorkspaceDescription::fromString($metadataRow['description']), + WorkspaceClassification::from($metadataRow['classification']), + $metadataRow['owner_user_id'] !== null ? UserId::fromString($metadataRow['owner_user_id']) : null, + ); + } + + /** + * The public and documented API is {@see WorkspaceService::setWorkspaceTitle()} and {@see WorkspaceService::setWorkspaceDescription()} + */ + public function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, Workspace $workspace, string|null $title, string|null $description): void + { + $data = array_filter([ + 'title' => $title, + 'description' => $description, + ], fn ($value) => $value !== null); + + try { + $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspace->workspaceName->value, + ]); + if ($affectedRows === 0) { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspace->workspaceName->value, + 'description' => '', + 'title' => $workspace->workspaceName->value, + 'classification' => $workspace->isRootWorkspace() ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, + ...$data, + ]); + } + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspace->workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); + } + } + + public function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void + { + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $title->value, + 'description' => $description->value, + 'classification' => $classification->value, + 'owner_user_id' => $ownerUserId?->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); + } + } + + public function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName + { + $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, + 'userId' => $userId->value, + ]); + return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/FusionAutoIncludeHandler.php b/Neos.Neos/Classes/Domain/Service/FusionAutoIncludeHandler.php new file mode 100644 index 00000000000..b167eff1bdf --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/FusionAutoIncludeHandler.php @@ -0,0 +1,15 @@ +fusionConfigurationCache->cacheFusionConfigurationBySite($site, function () use ($site) { $siteResourcesPackageKey = $site->getSiteResourcesPackageKey(); - return $this->fusionParser->parseFromSource( - $this->fusionSourceCodeFactory->createFromNodeTypeDefinitions($site->getConfiguration()->contentRepositoryId) - ->union( - $this->fusionSourceCodeFactory->createFromAutoIncludes() - ) - ->union( - FusionSourceCodeCollection::tryFromPackageRootFusion($siteResourcesPackageKey) - ) + $this->fusionAutoIncludeHandler->loadFusionFromPackage( + $siteResourcesPackageKey, + $this->fusionSourceCodeFactory->createFromNodeTypeDefinitions($site->getConfiguration()->contentRepositoryId) + ->union( + $this->fusionSourceCodeFactory->createFromAutoIncludes() + ) + ) ); }); } diff --git a/Neos.Neos/Classes/Domain/Service/FusionSourceCodeFactory.php b/Neos.Neos/Classes/Domain/Service/FusionSourceCodeFactory.php index 948994053cb..e8766ba3e5a 100644 --- a/Neos.Neos/Classes/Domain/Service/FusionSourceCodeFactory.php +++ b/Neos.Neos/Classes/Domain/Service/FusionSourceCodeFactory.php @@ -36,6 +36,9 @@ class FusionSourceCodeFactory #[Flow\InjectConfiguration("fusion.autoInclude")] protected array $autoIncludeConfiguration = []; + #[Flow\Inject] + protected FusionAutoIncludeHandler $fusionAutoIncludeHandler; + #[Flow\Inject] protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -50,14 +53,15 @@ public function createFromAutoIncludes(): FusionSourceCodeCollection $sourcecode = FusionSourceCodeCollection::empty(); foreach (array_keys($this->packageManager->getAvailablePackages()) as $packageKey) { if (isset($this->autoIncludeConfiguration[$packageKey]) && $this->autoIncludeConfiguration[$packageKey] === true) { - $sourcecode = $sourcecode->union( - FusionSourceCodeCollection::tryFromPackageRootFusion($packageKey) - ); + $sourcecode = $this->fusionAutoIncludeHandler->loadFusionFromPackage($packageKey, $sourcecode); } } return $sourcecode; } + /** + * @deprecated with Neos 9 - YAGNI from the start :) + */ public function createFromSite(Site $site): FusionSourceCodeCollection { return FusionSourceCodeCollection::tryFromPackageRootFusion($site->getSiteResourcesPackageKey()); diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeAggregateIdMapping.php b/Neos.Neos/Classes/Domain/Service/NodeDuplication/NodeAggregateIdMapping.php similarity index 72% rename from Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeAggregateIdMapping.php rename to Neos.Neos/Classes/Domain/Service/NodeDuplication/NodeAggregateIdMapping.php index 1c5be536e61..88cac7635a1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeAggregateIdMapping.php +++ b/Neos.Neos/Classes/Domain/Service/NodeDuplication/NodeAggregateIdMapping.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\Feature\NodeDuplication\Dto; +namespace Neos\Neos\Domain\Service\NodeDuplication; /* * This file is part of the Neos.ContentRepository package. @@ -14,16 +14,13 @@ * source code. */ +use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeSubtreeSnapshot; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; /** * An assignment of "old" to "new" NodeAggregateIds * - * Usable for predefining NodeAggregateIds if multiple nodes are copied. - * - * You'll never create this class yourself; but you use {@see CopyNodesRecursively::createFromSubgraphAndStartNode()} - * - * @internal implementation detail of {@see CopyNodesRecursively} command + * Usable for predefining NodeAggregateIds for deterministic testing, or fetching the newly inserted node. */ final class NodeAggregateIdMapping implements \JsonSerializable { @@ -34,12 +31,12 @@ final class NodeAggregateIdMapping implements \JsonSerializable * * @var array */ - protected array $nodeAggregateIds = []; + private array $nodeAggregateIds = []; /** * @param array $nodeAggregateIds */ - public function __construct(array $nodeAggregateIds) + private function __construct(array $nodeAggregateIds) { foreach ($nodeAggregateIds as $oldNodeAggregateId => $newNodeAggregateId) { $oldNodeAggregateId = NodeAggregateId::fromString($oldNodeAggregateId); @@ -54,12 +51,25 @@ public function __construct(array $nodeAggregateIds) } } + public static function createEmpty(): self + { + return new self([]); + } + + public function withNewNodeAggregateId(NodeAggregateId $oldNodeAggregateId, NodeAggregateId $newNodeAggregateId): self + { + $nodeAggregateIds = $this->nodeAggregateIds; + $nodeAggregateIds[$oldNodeAggregateId->value] = $newNodeAggregateId; + return new self($nodeAggregateIds); + } + /** * Create a new id mapping, *GENERATING* new ids. */ public static function generateForNodeSubtreeSnapshot(NodeSubtreeSnapshot $nodeSubtreeSnapshot): self { $nodeAggregateIdMapping = []; + /** @phpstan-ignore neos.cr.internal */ $nodeSubtreeSnapshot->walk( function (NodeSubtreeSnapshot $nodeSubtreeSnapshot) use (&$nodeAggregateIdMapping) { // here, we create new random NodeAggregateIds. @@ -71,13 +81,13 @@ function (NodeSubtreeSnapshot $nodeSubtreeSnapshot) use (&$nodeAggregateIdMappin } /** - * @param array $array + * @param array $array */ public static function fromArray(array $array): self { $nodeAggregateIds = []; foreach ($array as $oldNodeAggregateId => $newNodeAggregateId) { - $nodeAggregateIds[$oldNodeAggregateId] = NodeAggregateId::fromString($newNodeAggregateId); + $nodeAggregateIds[$oldNodeAggregateId] = $newNodeAggregateId instanceof NodeAggregateId ? $newNodeAggregateId : NodeAggregateId::fromString($newNodeAggregateId); } return new self($nodeAggregateIds); diff --git a/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php b/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php new file mode 100644 index 00000000000..3efcb5d94be --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/NodeDuplication/TransientNodeCopy.php @@ -0,0 +1,135 @@ +getNewNodeAggregateId($subtree->node->aggregateId) ?? NodeAggregateId::create(), + $targetWorkspaceName, + $targetOriginDimensionSpacePoint, + $nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $nodeAggregateIdMapping, + NodePath::createEmpty(), + NodeAggregateIdsByNodePaths::createEmpty() + ) + ); + } + + public function forTetheredChildNode(Subtree $subtree): self + { + $nodeName = $subtree->node->name; + if (!$subtree->node->classification->isTethered() || $nodeName === null) { + throw new \InvalidArgumentException(sprintf('Node "%s" must be tethered if given to "forTetheredChildNode".', $subtree->node->aggregateId->value)); + } + + $nodeAggregateId = $this->tetheredNodeAggregateIds->getNodeAggregateId(NodePath::fromNodeNames($nodeName)); + + if ($nodeAggregateId === null) { + throw new \InvalidArgumentException(sprintf('Name "%s" doesnt seem to be a point to a tethered node of "%s", could not determine deterministic node aggregate id.', $nodeName->value, $this->aggregateId->value)); + } + + return new self( + $nodeAggregateId, + $this->workspaceName, + $this->originDimensionSpacePoint, + $this->nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $this->nodeAggregateIdMapping, + NodePath::createEmpty(), + // we don't have to keep the relative $this->tetheredNodeAggregateIds for the current $nodName as we will just recalculate them from the subtree + NodeAggregateIdsByNodePaths::createEmpty() + ), + ); + } + + public function forRegularChildNode(Subtree $subtree): self + { + return new self( + $this->nodeAggregateIdMapping->getNewNodeAggregateId( + $subtree->node->aggregateId + ) ?? NodeAggregateId::create(), + $this->workspaceName, + $this->originDimensionSpacePoint, + $this->nodeAggregateIdMapping, + self::getTetheredDescendantNodeAggregateIds( + $subtree->children, + $this->nodeAggregateIdMapping, + NodePath::createEmpty(), + NodeAggregateIdsByNodePaths::createEmpty() + ), + ); + } + + private static function getTetheredDescendantNodeAggregateIds(Subtrees $subtreeChildren, NodeAggregateIdMapping $nodeAggregateIdMapping, NodePath $nodePath, NodeAggregateIdsByNodePaths $tetheredNodeAggregateIds): NodeAggregateIdsByNodePaths + { + foreach ($subtreeChildren as $childSubtree) { + if (!$childSubtree->node->classification->isTethered() || !$childSubtree->node->name) { + continue; + } + + $deterministicCopyAggregateId = $nodeAggregateIdMapping->getNewNodeAggregateId($childSubtree->node->aggregateId) ?? NodeAggregateId::create(); + + $childNodePath = $nodePath->appendPathSegment($childSubtree->node->name); + + $tetheredNodeAggregateIds = $tetheredNodeAggregateIds->add( + $childNodePath, + $deterministicCopyAggregateId + ); + + $tetheredNodeAggregateIds = self::getTetheredDescendantNodeAggregateIds($childSubtree->children, $nodeAggregateIdMapping, $childNodePath, $tetheredNodeAggregateIds); + } + + return $tetheredNodeAggregateIds; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php b/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php new file mode 100644 index 00000000000..dbd7d21460e --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/NodeDuplicationService.php @@ -0,0 +1,290 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph($sourceDimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); + + $targetParentNode = $subgraph->findNodeById($targetParentNodeAggregateId); + if ($targetParentNode === null) { + throw new NodeAggregateCurrentlyDoesNotExist(sprintf('The target parent node aggregate "%s" does not exist', $targetParentNodeAggregateId->value), 1732006769); + } + + $subtree = $subgraph->findSubtree($sourceNodeAggregateId, FindSubtreeFilter::create()); + if ($subtree === null) { + throw new NodeAggregateCurrentlyDoesNotExist(sprintf('The source node aggregate "%s" does not exist', $sourceNodeAggregateId->value), 1732006772); + } + + $commands = $this->calculateCopyNodesRecursively( + $subtree, + $subgraph, + $workspaceName, + $targetDimensionSpacePoint, + $targetParentNodeAggregateId, + $targetSucceedingSiblingNodeAggregateId, + $nodeAggregateIdMapping + ); + + foreach ($commands as $command) { + $contentRepository->handle($command); + } + } + + /** + * @internal implementation detail of {@see NodeDuplicationService::copyNodesRecursively}, exposed for EXPERIMENTAL use cases, the API can change any time! + */ + public function calculateCopyNodesRecursively( + Subtree $subtreeToCopy, + ContentSubgraphInterface $subgraph, + WorkspaceName $targetWorkspaceName, + OriginDimensionSpacePoint $targetDimensionSpacePoint, + NodeAggregateId $targetParentNodeAggregateId, + ?NodeAggregateId $targetSucceedingSiblingNodeAggregateId, + ?NodeAggregateIdMapping $nodeAggregateIdMapping = null + ): Commands { + $transientNodeCopy = TransientNodeCopy::forEntry( + $subtreeToCopy, + $targetWorkspaceName, + $targetDimensionSpacePoint, + $nodeAggregateIdMapping ?? NodeAggregateIdMapping::createEmpty() + ); + + $createCopyOfNodeCommand = CreateNodeAggregateWithNode::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $subtreeToCopy->node->nodeTypeName, + $targetDimensionSpacePoint, + $targetParentNodeAggregateId, + succeedingSiblingNodeAggregateId: $targetSucceedingSiblingNodeAggregateId, + // todo skip properties not in schema + initialPropertyValues: PropertyValuesToWrite::fromArray( + iterator_to_array($subtreeToCopy->node->properties) + ), + references: $this->serializeProjectedReferences( + $subgraph->findReferences($subtreeToCopy->node->aggregateId, FindReferencesFilter::create()) + ) + ); + + $createCopyOfNodeCommand = $createCopyOfNodeCommand->withTetheredDescendantNodeAggregateIds( + $transientNodeCopy->tetheredNodeAggregateIds + ); + + $commands = Commands::create($createCopyOfNodeCommand); + + foreach ($subtreeToCopy->node->tags->withoutInherited() as $explicitTag) { + $commands = $commands->append( + TagSubtree::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $transientNodeCopy->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS, + $explicitTag + ) + ); + } + + foreach ($subtreeToCopy->children as $childSubtree) { + if ($subtreeToCopy->node->classification->isTethered() && $childSubtree->node->classification->isTethered()) { + // TODO we assume here that the child node is tethered because the grandparent specifies that. + // this is not always fully correct and we could loosen the constraint by checking the node type schema + throw new TetheredNodesCannotBePartiallyCopied(sprintf('Cannot copy tethered node %s because child node %s is also tethered. Only standalone tethered nodes can be copied.', $subtreeToCopy->node->aggregateId->value, $childSubtree->node->aggregateId->value), 1731264887); + } + $commands = $this->commandsForSubtreeRecursively($transientNodeCopy, $childSubtree, $subgraph, $commands); + } + + return $commands; + } + + private function commandsForSubtreeRecursively(TransientNodeCopy $transientParentNode, Subtree $subtree, ContentSubgraphInterface $subgraph, Commands $commands): Commands + { + if ($subtree->node->classification->isTethered()) { + /** + * Case Node is tethered + */ + $transientNodeCopy = $transientParentNode->forTetheredChildNode( + $subtree + ); + + if ($subtree->node->properties->count() > 0) { + $setPropertiesOfTetheredNodeCommand = SetNodeProperties::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $transientParentNode->originDimensionSpacePoint, + // todo skip properties not in schema + PropertyValuesToWrite::fromArray( + iterator_to_array($subtree->node->properties) + ), + ); + + $commands = $commands->append($setPropertiesOfTetheredNodeCommand); + } + + $references = $subgraph->findReferences($subtree->node->aggregateId, FindReferencesFilter::create()); + if ($references->count() > 0) { + $setReferencesOfTetheredNodeCommand = SetNodeReferences::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $transientParentNode->originDimensionSpacePoint, + $this->serializeProjectedReferences( + $references + ), + ); + + $commands = $commands->append($setReferencesOfTetheredNodeCommand); + } + } else { + /** + * Case Node is a regular child + */ + $transientNodeCopy = $transientParentNode->forRegularChildNode( + $subtree + ); + + $createCopyOfNodeCommand = CreateNodeAggregateWithNode::create( + $transientParentNode->workspaceName, + $transientNodeCopy->aggregateId, + $subtree->node->nodeTypeName, + $transientParentNode->originDimensionSpacePoint, + $transientParentNode->aggregateId, + // todo succeedingSiblingNodeAggregateId + // todo skip properties not in schema + initialPropertyValues: PropertyValuesToWrite::fromArray( + iterator_to_array($subtree->node->properties) + ), + references: $this->serializeProjectedReferences( + $subgraph->findReferences($subtree->node->aggregateId, FindReferencesFilter::create()) + ) + ); + + $createCopyOfNodeCommand = $createCopyOfNodeCommand->withTetheredDescendantNodeAggregateIds( + $transientNodeCopy->tetheredNodeAggregateIds + ); + + $commands = $commands->append($createCopyOfNodeCommand); + } + + /** + * common logic + */ + + foreach ($subtree->node->tags->withoutInherited() as $explicitTag) { + $commands = $commands->append( + TagSubtree::create( + $transientNodeCopy->workspaceName, + $transientNodeCopy->aggregateId, + $transientNodeCopy->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeVariantSelectionStrategy::STRATEGY_ALL_VARIANTS, + $explicitTag + ) + ); + } + + foreach ($subtree->children as $childSubtree) { + $commands = $this->commandsForSubtreeRecursively($transientNodeCopy, $childSubtree, $subgraph, $commands); + } + + return $commands; + } + + private function serializeProjectedReferences(References $references): NodeReferencesToWrite + { + $serializedReferencesByName = []; + foreach ($references as $reference) { + if (!isset($serializedReferencesByName[$reference->name->value])) { + $serializedReferencesByName[$reference->name->value] = []; + } + $serializedReferencesByName[$reference->name->value][] = NodeReferenceToWrite::fromTargetAndProperties($reference->node->aggregateId, $reference->properties?->count() > 0 ? PropertyValuesToWrite::fromArray(iterator_to_array($reference->properties)) : PropertyValuesToWrite::createEmpty()); + } + + $serializedReferences = []; + foreach ($serializedReferencesByName as $name => $referenceObjects) { + $serializedReferences[] = NodeReferencesForName::fromReferences(ReferenceName::fromString($name), $referenceObjects); + } + + return NodeReferencesToWrite::fromArray($serializedReferences); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/ResourceFusionAutoIncludeHandler.php b/Neos.Neos/Classes/Domain/Service/ResourceFusionAutoIncludeHandler.php new file mode 100644 index 00000000000..38365f9ea68 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/ResourceFusionAutoIncludeHandler.php @@ -0,0 +1,22 @@ +union( + FusionSourceCodeCollection::tryFromFilePath(sprintf('resource://%s/Private/Fusion/Root.fusion', $packageKey)) + ); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteExportService.php b/Neos.Neos/Classes/Domain/Service/SiteExportService.php new file mode 100644 index 00000000000..ba305374607 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SiteExportService.php @@ -0,0 +1,88 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $liveWorkspace = $contentRepository->findWorkspaceByName(WorkspaceName::forLive()); + if ($liveWorkspace === null) { + throw new \RuntimeException('Failed to find live workspace', 1716652280); + } + + $processors = Processors::fromArray([ + 'Exporting events' => $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new EventExportProcessorFactory( + $liveWorkspace->currentContentStreamId + ) + ), + 'Exporting assets' => new AssetExportProcessor( + $contentRepositoryId, + $this->assetRepository, + $liveWorkspace, + $this->assetUsageService + ), + 'Export sites' => new SiteExportProcessor( + $contentRepository, + $liveWorkspace->workspaceName, + $this->siteRepository + ), + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteImportService.php b/Neos.Neos/Classes/Domain/Service/SiteImportService.php new file mode 100644 index 00000000000..741424a02e2 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SiteImportService.php @@ -0,0 +1,114 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $this->requireDataBaseSchemaToBeSetup(); + $this->requireContentRepositoryToBeSetup($contentRepository); + + $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); + $context = new ProcessingContext($filesystem, $onMessage); + + $processors = Processors::fromArray([ + 'Create Live workspace' => new LiveWorkspaceCreationProcessor($contentRepository, $this->workspaceService), + 'Create Neos sites' => new SiteCreationProcessor($this->siteRepository, $this->domainRepository, $this->persistenceManager), + 'Import events' => $this->contentRepositoryRegistry->buildService($contentRepositoryId, new EventStoreImportProcessorFactory(WorkspaceName::forLive(), keepEventIds: true)), + 'Import assets' => new AssetRepositoryImportProcessor($this->assetRepository, $this->resourceRepository, $this->resourceManager, $this->persistenceManager), + 'Catchup all projections' => new ProjectionCatchupProcessor($this->contentRepositoryRegistry->buildService($contentRepositoryId, new ProjectionServiceFactory())), + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } + + private function requireContentRepositoryToBeSetup(ContentRepository $contentRepository): void + { + $status = $contentRepository->status(); + if (!$status->isOk()) { + throw new \RuntimeException(sprintf('Content repository %s is not setup correctly, please run `./flow cr:setup`', $contentRepository->id->value)); + } + } + + private function requireDataBaseSchemaToBeSetup(): void + { + try { + [ + 'new' => $_newMigrationCount, + 'executed' => $executedMigrationCount, + 'available' => $availableMigrationCount + ] = $this->doctrineService->getMigrationStatus(); + } catch (DBALException | \PDOException) { + throw new \RuntimeException('Not database connected. Please check your database connection settings or run `./flow setup` for further information.', 1684075689386); + } + + if ($executedMigrationCount === 0 && $availableMigrationCount > 0) { + throw new \RuntimeException('No doctrine migrations have been executed. Please run `./flow doctrine:migrate`'); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php index 262d24e4461..bbcfbee80cd 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php +++ b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php @@ -17,19 +17,15 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Repository\SiteRepository; -use Neos\Neos\Utility\NodeTypeWithFallbackProvider; #[Flow\Scope('singleton')] final class SiteNodeUtility { - use NodeTypeWithFallbackProvider; - public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry ) { @@ -44,8 +40,7 @@ public function __construct( * $siteNode = $this->siteNodeUtility->findSiteNodeBySite( * $site, * WorkspaceName::forLive(), - * DimensionSpacePoint::createWithoutDimensions(), - * VisibilityConstraints::frontend() + * DimensionSpacePoint::createWithoutDimensions() * ); * ``` * @@ -54,26 +49,18 @@ public function __construct( public function findSiteNodeBySite( Site $site, WorkspaceName $workspaceName, - DimensionSpacePoint $dimensionSpacePoint, - VisibilityConstraints $visibilityConstraints + DimensionSpacePoint $dimensionSpacePoint ): Node { $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); - $contentGraph = $contentRepository->getContentGraph($workspaceName); - $subgraph = $contentGraph->getSubgraph( - $dimensionSpacePoint, - $visibilityConstraints, - ); + $subgraph = $contentRepository->getContentSubgraph($workspaceName, $dimensionSpacePoint); - $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType( - NodeTypeNameFactory::forSites() - ); - if (!$rootNodeAggregate) { + $rootNode = $subgraph->findRootNodeByType(NodeTypeNameFactory::forSites()); + + if (!$rootNode) { throw new \RuntimeException(sprintf('No sites root node found in content repository "%s", while fetching site node "%s"', $contentRepository->id->value, $site->getNodeName()), 1719046570); } - $rootNode = $rootNodeAggregate->getNodeByCoveredDimensionSpacePoint($dimensionSpacePoint); - $siteNode = $subgraph->findNodeByPath( $site->getNodeName()->toNodeName(), $rootNode->aggregateId @@ -83,7 +70,7 @@ public function findSiteNodeBySite( throw new \RuntimeException(sprintf('No site node found for site "%s"', $site->getNodeName()), 1697140379); } - if (!$this->getNodeType($siteNode)->isOfType(NodeTypeNameFactory::NAME_SITE)) { + if (!$contentRepository->getNodeTypeManager()->getNodeType($siteNode->nodeTypeName)?->isOfType(NodeTypeNameFactory::NAME_SITE)) { throw new \RuntimeException(sprintf( 'The site node "%s" (type: "%s") must be of type "%s"', $siteNode->aggregateId->value, diff --git a/Neos.Neos/Classes/Domain/Service/SitePruningService.php b/Neos.Neos/Classes/Domain/Service/SitePruningService.php new file mode 100644 index 00000000000..9a1c7c67537 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SitePruningService.php @@ -0,0 +1,89 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $processors = Processors::fromArray([ + 'Remove site nodes' => new SitePruningProcessor( + $contentRepository, + WorkspaceName::forLive(), + $this->siteRepository, + $this->domainRepository, + $this->persistenceManager + ), + 'Prune content repository' => new ContentRepositoryPruningProcessor( + $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new ContentStreamPrunerFactory() + ) + ), + 'Prune roles and metadata' => new RoleAndMetadataPruningProcessor($contentRepositoryId, $this->workspaceMetadataAndRoleRepository), + 'Reset all projections' => new ProjectionResetProcessor( + $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new ProjectionServiceFactory() + ) + ) + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteService.php b/Neos.Neos/Classes/Domain/Service/SiteService.php index 6041fceb202..42f7bea37e6 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteService.php +++ b/Neos.Neos/Classes/Domain/Service/SiteService.php @@ -167,13 +167,12 @@ public function createSite( ?string $nodeName = null, bool $inactive = false ): Site { - $siteNodeName = NodeName::fromString($nodeName ?: $siteName); + $siteNodeName = NodeName::transliterateFromString($nodeName ?: $siteName); if ($this->siteRepository->findOneByNodeName($siteNodeName->value)) { throw SiteNodeNameIsAlreadyInUseByAnotherSite::butWasAttemptedToBeClaimed($siteNodeName); } - // @todo use node aggregate identifier instead of node name $site = new Site($siteNodeName->value); $site->setSiteResourcesPackageKey($packageKey); $site->setState($inactive ? Site::STATE_OFFLINE : Site::STATE_ONLINE); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index c0c0e05aacf..b4021fae155 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,10 +14,7 @@ namespace Neos\Neos\Domain\Service; -use Doctrine\DBAL\ArrayParameterType; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception as DbalException; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -27,19 +24,19 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Exception\NoSuchRoleException; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceMetadata; -use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** * Central authority to interact with Content Repository Workspaces within Neos @@ -47,15 +44,14 @@ * @api */ #[Flow\Scope('singleton')] -final class WorkspaceService +final readonly class WorkspaceService { - private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata'; - private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role'; - public function __construct( - private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly UserService $userService, - private readonly Connection $dbal, + private ContentRepositoryRegistry $contentRepositoryRegistry, + private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository, + private UserService $userService, + private ContentRepositoryAuthorizationService $authorizationService, + private SecurityContext $securityContext, ) { } @@ -69,7 +65,7 @@ public function __construct( public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); - $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + $metadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); return $metadata ?? new WorkspaceMetadata( WorkspaceTitle::fromString($workspaceName->value), WorkspaceDescription::fromString(''), @@ -83,9 +79,9 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'title' => $newWorkspaceTitle->value, - ]); + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null); } /** @@ -93,9 +89,9 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'description' => $newWorkspaceDescription->value, - ]); + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value); } /** @@ -105,7 +101,7 @@ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId */ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace { - $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); + $workspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); if ($workspaceName === null) { throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); } @@ -126,7 +122,7 @@ public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, Wo ContentStreamId::create() ) ); - $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); } /** @@ -142,7 +138,7 @@ public function createLiveWorkspaceIfMissing(ContentRepositoryId $contentReposit return; } $this->createRootWorkspace($contentRepositoryId, $workspaceName, WorkspaceTitle::fromString('Public live workspace'), WorkspaceDescription::empty()); - $this->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); } /** @@ -167,7 +163,7 @@ public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, */ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void { - $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + $existingWorkspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); if ($existingWorkspaceName !== null) { $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); return; @@ -191,20 +187,9 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con */ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject_type' => $assignment->subjectType->value, - 'subject' => $assignment->subject->value, - 'role' => $assignment->role->value, - ]); - } catch (UniqueConstraintViolationException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); - } + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment); } /** @@ -212,82 +197,21 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo * * @see self::assignWorkspaceRole() */ - public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject): void + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject_type' => $subjectType->value, - 'subject' => $subject->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); - } - if ($affectedRows === 0) { - throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); - } + $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject); } /** * Get all role assignments for the specified workspace * - * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used! */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { - $table = self::TABLE_NAME_WORKSPACE_ROLE; - $query = <<dbal->fetchAllAssociative($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); - } - return WorkspaceRoleAssignments::fromArray( - array_map(static fn (array $row) => WorkspaceRoleAssignment::create( - WorkspaceRoleSubjectType::from($row['subject_type']), - WorkspaceRoleSubject::fromString($row['subject']), - WorkspaceRole::from($row['role']), - ), $rows) - ); - } - - /** - * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} - */ - public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions - { - try { - $userRoles = array_keys($this->userService->getAllRoles($user)); - } catch (NoSuchRoleException $e) { - throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e); - } - $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspaceMetadata !== null && $workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { - return WorkspacePermissions::all(); - } - $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), $userRoles); - $userIsAdministrator = in_array('Neos.Neos:Administrator', $userRoles, true); - if ($userWorkspaceRole === null) { - return WorkspacePermissions::create(false, false, $userIsAdministrator); - } - return WorkspacePermissions::create( - read: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - ); + return $this->metadataAndRoleRepository->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); } /** @@ -318,98 +242,8 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } - /** - * Removes all workspace metadata records for the specified content repository id - */ - public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void - { - try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to prune workspace metadata Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512100, $e); - } - } - - /** - * Removes all workspace role assignments for the specified content repository id - */ - public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void - { - try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to prune workspace roles for Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512142, $e); - } - } - // ------------------ - private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata - { - $table = self::TABLE_NAME_WORKSPACE_METADATA; - $query = <<dbal->fetchAssociative($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf( - 'Failed to fetch metadata for workspace "%s" (Content Repository "%s), please ensure the database schema is up to date. %s', - $workspaceName->value, - $contentRepositoryId->value, - $e->getMessage() - ), 1727782164, $e); - } - if (!is_array($metadataRow)) { - return null; - } - return new WorkspaceMetadata( - WorkspaceTitle::fromString($metadataRow['title']), - WorkspaceDescription::fromString($metadataRow['description']), - WorkspaceClassification::from($metadataRow['classification']), - $metadataRow['owner_user_id'] !== null ? UserId::fromString($metadataRow['owner_user_id']) : null, - ); - } - - /** - * @param array $data - */ - private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void - { - $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - ]); - if ($affectedRows === 0) { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'description' => '', - 'title' => $workspaceName->value, - 'classification' => $workspace->isRootWorkspace() ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, - ...$data, - ]); - } - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); - } - } - private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -420,87 +254,7 @@ private function createWorkspace(ContentRepositoryId $contentRepositoryId, Works ContentStreamId::create() ) ); - $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); - } - - private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void - { - try { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'title' => $title->value, - 'description' => $description->value, - 'classification' => $classification->value, - 'owner_user_id' => $ownerUserId?->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); - } - } - - private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName - { - $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; - $query = <<dbal->fetchOne($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, - 'userId' => $userId->value, - ]); - return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); - } - - /** - * @param array $userRoles - */ - private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): ?WorkspaceRole - { - $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; - $query = <<dbal->fetchOne($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, - 'userId' => $userId->value, - 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, - 'groupSubjects' => $userRoles, - ], [ - 'groupSubjects' => ArrayParameterType::STRING, - ]); - if ($role === false) { - return null; - } - return WorkspaceRole::from($role); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); } private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace @@ -513,4 +267,20 @@ private function requireWorkspace(ContentRepositoryId $contentRepositoryId, Work } return $workspace; } + + private function requireManagementWorkspacePermission(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return; + } + $workspacePermissions = $this->authorizationService->getWorkspacePermissions( + $contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId() + ); + if (!$workspacePermissions->manage) { + throw new AccessDenied(sprintf('Managing workspace "%s" in "%s" was denied: %s', $workspaceName->value, $contentRepositoryId->value, $workspacePermissions->getReason()), 1731654519); + } + } } diff --git a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php index ac47b1e3172..cf9165eb0fa 100644 --- a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php +++ b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php @@ -73,12 +73,7 @@ private function tryDeserializeNode(array $serializedNode): ?Node $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); try { - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $nodeAddress->workspaceName->isLive() - ? VisibilityConstraints::frontend() - : VisibilityConstraints::withoutRestrictions() - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); } catch (WorkspaceDoesNotExist $exception) { // in case the workspace was deleted the rendering should probably not come to this very point // still if it does we fail silently diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php new file mode 100644 index 00000000000..6de599cbd7c --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -0,0 +1,111 @@ + $roles The {@see Role} instances to check access for. Note: These have to be the expanded roles auf the authenticated tokens {@see Context::getRoles()} + * @param UserId|null $userId Optional ID of the authenticated Neos user. If set the workspace owner is evaluated since owners always have all permissions on their workspace + */ + public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions + { + $workspaceMetadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + if ($userId !== null && $workspaceMetadata?->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) { + return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value)); + } + $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), array_values($roles)); + $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers); + if ($userId !== null) { + $subjects[] = WorkspaceRoleSubject::createForUser($userId); + } + $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true); + $userWorkspaceRole = $this->metadataAndRoleRepository->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); + if ($userWorkspaceRole === null) { + if ($userIsAdministrator) { + return WorkspacePermissions::manage(sprintf('User is a Neos Administrator without explicit role for workspace "%s"', $workspaceName->value)); + } + return WorkspacePermissions::none(sprintf('User is no Neos Administrator and has no explicit role for workspace "%s"', $workspaceName->value)); + } + return WorkspacePermissions::create( + read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), + write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + reason: sprintf('User is %s Neos Administrator and has role "%s" for workspace "%s"', $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value), + ); + } + + /** + * Determines the {@see NodePermissions} a user with the specified {@see Role}s has on the given {@see Node} + * + * @param array $roles + */ + public function getNodePermissions(Node $node, array $roles): NodePermissions + { + $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId); + $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason); + $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason); + return NodePermissions::create( + read: $readGranted, + edit: $writeGranted, + reason: $readReason . "\n" . $writeReason, + ); + } + + /** + * Determines the default {@see VisibilityConstraints} for the specified {@see Role}s + * + * @param array $roles + */ + public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints + { + $restrictedSubtreeTags = SubtreeTags::createEmpty(); + /** @var ReadNodePrivilege $privilege */ + foreach ($this->policyService->getAllPrivilegesByType(ReadNodePrivilege::class) as $privilege) { + if (!$this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTags(), $contentRepositoryId))) { + $restrictedSubtreeTags = $restrictedSubtreeTags->merge($privilege->getSubtreeTags()); + } + } + return VisibilityConstraints::fromTagConstraints($restrictedSubtreeTags); + } +} diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php new file mode 100644 index 00000000000..227cb75d6aa --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -0,0 +1,78 @@ +subtreeTagsRuntimeCache */ + private function initialize(): void + { + if ($this->initialized) { + return; + } + $subtreeTag = $this->getParsedMatcher(); + if (str_contains($subtreeTag, ':')) { + [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); + $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId); + } + $this->subtreeTagsRuntimeCache = SubtreeTags::fromStrings($subtreeTag); + $this->initialized = true; + } + + /** + * Returns true, if this privilege covers the given subject + * + * @param PrivilegeSubjectInterface $subject + * @return boolean + * @throws InvalidPrivilegeTypeException if the given $subject is not supported by the privilege + */ + public function matchesSubject(PrivilegeSubjectInterface $subject): bool + { + if (!$subject instanceof SubtreeTagPrivilegeSubject) { + throw new InvalidPrivilegeTypeException(sprintf('Privileges of type "%s" only support subjects of type "%s" but we got a subject of type: "%s".', self::class, SubtreeTagPrivilegeSubject::class, get_class($subject)), 1729173985); + } + $contentRepositoryId = $this->getContentRepositoryId(); + if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) { + return false; + } + return !$this->getSubtreeTags()->intersection($subject->subTreeTags)->isEmpty(); + } + + public function getSubtreeTags(): SubtreeTags + { + $this->initialize(); + return $this->subtreeTagsRuntimeCache; + } + + public function getContentRepositoryId(): ?ContentRepositoryId + { + $this->initialize(); + return $this->contentRepositoryIdRuntimeCache; + } +} diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php new file mode 100644 index 00000000000..83c9f53b88a --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php @@ -0,0 +1,23 @@ +userService->getCurrentUser(); + if ($user === null) { + return null; + } + return UserId::fromString($user->getId()->value); + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return $this->authorizationService->getVisibilityConstraints($this->contentRepositoryId, $this->securityContext->getRoles()); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted('Authorization checks are disabled'); + } + $workspacePermissions = $this->authorizationService->getWorkspacePermissions( + $this->contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId(), + ); + return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason()); + } + + public function canExecuteCommand(CommandInterface $command): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted('Authorization checks are disabled'); + } + $nodeThatRequiresEditPrivilege = $this->nodeThatRequiresEditPrivilegeForCommand($command); + if ($nodeThatRequiresEditPrivilege !== null) { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName); + if (!$workspacePermissions->write) { + return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason())); + } + $node = $this->contentGraphReadModel + ->getContentGraph($nodeThatRequiresEditPrivilege->workspaceName) + ->getSubgraph($nodeThatRequiresEditPrivilege->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) + ->findNodeById($nodeThatRequiresEditPrivilege->aggregateId); + if ($node === null) { + return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value)); + } + $nodePermissions = $this->authorizationService->getNodePermissions($node, $this->securityContext->getRoles()); + if (!$nodePermissions->edit) { + return Privilege::denied(sprintf('No edit permissions for node "%s" in workspace "%s": %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); + } + return Privilege::granted(sprintf('Edit permissions for node "%s" in workspace "%s" granted: %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); + } + if ($command instanceof CreateRootWorkspace) { + return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks'); + } + if ($command instanceof ChangeBaseWorkspace) { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName); + if (!$workspacePermissions->manage) { + return Privilege::denied(sprintf('Missing "manage" permissions for workspace "%s": %s', $command->workspaceName->value, $workspacePermissions->getReason())); + } + $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName); + if (!$baseWorkspacePermissions->read) { + return Privilege::denied(sprintf('Missing "read" permissions for base workspace "%s": %s', $command->baseWorkspaceName->value, $baseWorkspacePermissions->getReason())); + } + return Privilege::granted(sprintf('User has "manage" permissions for workspace "%s" and "read" permissions for base workspace "%s"', $command->workspaceName->value, $command->baseWorkspaceName->value)); + } + return match ($command::class) { + AddDimensionShineThrough::class, + ChangeNodeAggregateName::class, + ChangeNodeAggregateType::class, + CreateRootNodeAggregateWithNode::class, + MoveDimensionSpacePoint::class, + UpdateRootNodeAggregateDimensions::class, + DiscardWorkspace::class, + DiscardIndividualNodesFromWorkspace::class, + PublishWorkspace::class, + PublishIndividualNodesFromWorkspace::class, + RebaseWorkspace::class => $this->requireWorkspaceWritePermission($command->workspaceName), + CreateWorkspace::class => $this->requireWorkspaceWritePermission($command->baseWorkspaceName), + DeleteWorkspace::class => $this->requireWorkspaceManagePermission($command->workspaceName), + default => Privilege::granted('Command not restricted'), + }; + } + + /** + * For a given command, determine the node (represented as {@see NodeAddress}) that needs {@see EditNodePrivilege} to be granted + */ + private function nodeThatRequiresEditPrivilegeForCommand(CommandInterface $command): ?NodeAddress + { + return match ($command::class) { + CreateNodeAggregateWithNode::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId), + CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId), + DisableNodeAggregate::class, + EnableNodeAggregate::class, + RemoveNodeAggregate::class, + TagSubtree::class, + UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId), + MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId), + SetNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId), + SetNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId), + default => null, + }; + } + + private function requireWorkspaceWritePermission(WorkspaceName $workspaceName): Privilege + { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); + if (!$workspacePermissions->write) { + return Privilege::denied("Missing 'write' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + } + return Privilege::granted("User has 'write' permissions for workspace '{$workspaceName->value}'"); + } + + private function requireWorkspaceManagePermission(WorkspaceName $workspaceName): Privilege + { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); + if (!$workspacePermissions->manage) { + return Privilege::denied("Missing 'manage' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + } + return Privilege::granted("User has 'manage' permissions for workspace '{$workspaceName->value}'"); + } + + private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions + { + return $this->authorizationService->getWorkspacePermissions( + $this->contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId(), + ); + } +} diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php new file mode 100644 index 00000000000..cc2ebb54dab --- /dev/null +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -0,0 +1,34 @@ +userService, $contentGraphReadModel, $this->contentRepositoryAuthorizationService, $this->securityContext); + } +} diff --git a/Neos.Neos/Classes/Service/Controller/DataSourceController.php b/Neos.Neos/Classes/Service/Controller/DataSourceController.php index db29b994b7b..265d151c766 100644 --- a/Neos.Neos/Classes/Service/Controller/DataSourceController.php +++ b/Neos.Neos/Classes/Service/Controller/DataSourceController.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Service\Controller; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; @@ -68,12 +67,12 @@ public function indexAction($dataSourceIdentifier, string $node = null): void unset($arguments['dataSourceIdentifier']); unset($arguments['node']); - $values = $dataSource->getData($this->deserializeNodeFromLegacyAddress($node), $arguments); + $values = $dataSource->getData($this->deserializeNodeFromNodeAddress($node), $arguments); $this->view->assign('value', $values); } - private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAddress): ?Node + private function deserializeNodeFromNodeAddress(?string $stringFormattedNodeAddress): ?Node { if (!$stringFormattedNodeAddress) { return null; @@ -82,10 +81,8 @@ private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAd $nodeAddress = NodeAddress::fromJsonString($stringFormattedNodeAddress); $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - return $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - )->findNodeById($nodeAddress->aggregateId); + return $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint) + ->findNodeById($nodeAddress->aggregateId); } /** diff --git a/Neos.Neos/Classes/Testing/TestingFusionAutoIncludeHandler.php b/Neos.Neos/Classes/Testing/TestingFusionAutoIncludeHandler.php new file mode 100644 index 00000000000..94e2dc76cb3 --- /dev/null +++ b/Neos.Neos/Classes/Testing/TestingFusionAutoIncludeHandler.php @@ -0,0 +1,60 @@ + + */ + private array $overriddenIncludes = []; + + public function setIncludeFusionPackage(string $packageKey): void + { + $this->overriddenIncludes[$packageKey] = true; + } + + public function setFusionForPackage(string $packageKey, FusionSourceCodeCollection $packageFusionSource): void + { + $this->overriddenIncludes[$packageKey] = $packageFusionSource; + } + + public function reset(): void + { + $this->overriddenIncludes = []; + } + + /** + * If no override is set via {@see setIncludeFusionPackage} or {@see setFusionForPackage} we load all the fusion via the default implementation + */ + public function loadFusionFromPackage(string $packageKey, FusionSourceCodeCollection $sourceCodeCollection): FusionSourceCodeCollection + { + if ($this->overriddenIncludes === []) { + return $this->defaultHandler->loadFusionFromPackage($packageKey, $sourceCodeCollection); + } + $override = $this->overriddenIncludes[$packageKey] ?? null; + if ($override === null) { + return $sourceCodeCollection; + } + if ($override === true) { + return $this->defaultHandler->loadFusionFromPackage($packageKey, $sourceCodeCollection); + } + return $sourceCodeCollection->union($override); + } +} diff --git a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php index c8744bb5eda..4157629043d 100644 --- a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php +++ b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php @@ -15,7 +15,6 @@ */ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; @@ -59,13 +58,7 @@ public function convertFrom( ) { $nodeAddress = NodeAddress::fromJsonString($source); $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName) - ->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $nodeAddress->workspaceName->isLive() - ? VisibilityConstraints::frontend() - : VisibilityConstraints::withoutRestrictions() - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); return $subgraph->findNodeById($nodeAddress->aggregateId); } diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php deleted file mode 100644 index b81c97e8c5a..00000000000 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php +++ /dev/null @@ -1,29 +0,0 @@ -userService->getCurrentUser(); - if ($user === null) { - return UserId::forSystemUser(); - } - return UserId::fromString($user->getId()->value); - } -} diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php b/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php deleted file mode 100644 index 388dc6f19f3..00000000000 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new UserIdProvider($this->userService); - } -} diff --git a/Neos.Neos/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php index fa517e69154..abdd8fd7d95 100644 --- a/Neos.Neos/Classes/View/FusionExceptionView.php +++ b/Neos.Neos/Classes/View/FusionExceptionView.php @@ -122,8 +122,7 @@ public function render(): ResponseInterface|StreamInterface $currentSiteNode = $this->siteNodeUtility->findSiteNodeBySite( $site, WorkspaceName::forLive(), - $arbitraryRootDimensionSpacePoint, - VisibilityConstraints::frontend() + $arbitraryRootDimensionSpacePoint ); } catch (WorkspaceDoesNotExist | \RuntimeException) { return $this->renderErrorWelcomeScreen(); diff --git a/Neos.Neos/Configuration/Objects.yaml b/Neos.Neos/Configuration/Objects.yaml index be5cd60dd91..20da5dd98aa 100644 --- a/Neos.Neos/Configuration/Objects.yaml +++ b/Neos.Neos/Configuration/Objects.yaml @@ -20,6 +20,9 @@ Neos\Neos\Domain\Service\FusionConfigurationCache: 2: setting: "Neos.Neos.fusion.enableObjectTreeCache" +Neos\Neos\Domain\Service\FusionAutoIncludeHandler: + className: Neos\Neos\Domain\Service\ResourceFusionAutoIncludeHandler + Neos\Fusion\Core\Cache\RuntimeContentCache: properties: serializer: diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index 8ccf44e7ce0..c441d522255 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -142,6 +142,14 @@ privilegeTargets: label: General access to the dimensions module matcher: 'administration/dimensions' + + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + + 'Neos.Neos:ContentRepository.ReadDisabledNodes': + # !!! matcher payload in this case is a ContentRepository SubtreeTag, + # i.e. nodes with ths specified tag are only read if the user has the corresponding privilegeTarget assigned. + matcher: 'disabled' + roles: 'Neos.Flow:Everybody': @@ -229,6 +237,11 @@ roles: privilegeTarget: 'Neos.Neos:Backend.Module.Management' permission: GRANT + - + privilegeTarget: 'Neos.Neos:ContentRepository.ReadDisabledNodes' + permission: GRANT + + 'Neos.Neos:RestrictedEditor': label: Restricted Editor diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml index d7d0bc7c720..ae5349f5f50 100644 --- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml @@ -3,8 +3,8 @@ Neos: presets: 'default': - userIdProvider: - factoryObjectName: Neos\Neos\UserIdProvider\UserIdProviderFactory + authProvider: + factoryObjectName: Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory contentGraphProjection: catchUpHooks: diff --git a/Neos.Neos/Configuration/Testing/Objects.yaml b/Neos.Neos/Configuration/Testing/Objects.yaml new file mode 100644 index 00000000000..faa9e83523a --- /dev/null +++ b/Neos.Neos/Configuration/Testing/Objects.yaml @@ -0,0 +1,2 @@ +Neos\Neos\Domain\Service\FusionAutoIncludeHandler: + className: Neos\Neos\Testing\TestingFusionAutoIncludeHandler diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index ee2cc993df6..c4f45f3200b 100644 --- a/Neos.Neos/Documentation/References/CommandReference.rst +++ b/Neos.Neos/Documentation/References/CommandReference.rst @@ -19,7 +19,7 @@ commands that may be available, use:: ./flow help -The following reference was automatically generated from code on 2024-11-08 +The following reference was automatically generated from code on 2024-11-19 .. _`Neos Command Reference: NEOS.FLOW`: @@ -2154,87 +2154,6 @@ Package *NEOS.NEOS* ------------------- -.. _`Neos Command Reference: NEOS.NEOS neos.neos:cr:export`: - -``neos.neos:cr:export`` -*********************** - -**Export the events from the specified content repository** - - - -Arguments -^^^^^^^^^ - -``--path`` - The path for storing the result - - - -Options -^^^^^^^ - -``--content-repository`` - The content repository identifier -``--verbose`` - If set, all notices will be rendered - - - - - -.. _`Neos Command Reference: NEOS.NEOS neos.neos:cr:import`: - -``neos.neos:cr:import`` -*********************** - -**Import the events from the path into the specified content repository** - - - -Arguments -^^^^^^^^^ - -``--path`` - The path of the stored events like resource://Neos.Demo/Private/Content - - - -Options -^^^^^^^ - -``--content-repository`` - The content repository identifier -``--verbose`` - If set, all notices will be rendered - - - - - -.. _`Neos Command Reference: NEOS.NEOS neos.neos:cr:prune`: - -``neos.neos:cr:prune`` -********************** - -**This will completely prune the data of the specified content repository.** - - - - - -Options -^^^^^^^ - -``--content-repository`` - Name of the content repository where the data should be pruned from. -``--force`` - Prune the cr without confirmation. This cannot be reverted! - - - - - .. _`Neos Command Reference: NEOS.NEOS neos.neos:domain:activate`: ``neos.neos:domain:activate`` @@ -2434,6 +2353,73 @@ Arguments +.. _`Neos Command Reference: NEOS.NEOS neos.neos:site:exportall`: + +``neos.neos:site:exportall`` +**************************** + +**Export sites** + +This command exports all sites of the content repository. + +If a path is specified, this command creates the directory if needed and exports into that. + +If a package key is specified, this command exports to the private resources +directory of the given package (Resources/Private/Content). + + + +Options +^^^^^^^ + +``--package-key`` + Package key specifying the package containing the sites content +``--path`` + relative or absolute path and filename to the export files +``--content-repository`` + contentRepository +``--verbose`` + verbose + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:site:importall`: + +``neos.neos:site:importall`` +**************************** + +**Import sites** + +This command allows importing sites from the given path/package. The format must +be identical to that produced by the exportAll command. + +If a path is specified, this command expects the corresponding directory to contain the exported files + +If a package key is specified, this command expects the export files to be located in the private resources +directory of the given package (Resources/Private/Content). + +**Note that the live workspace has to be empty prior to importing.** + + + +Options +^^^^^^^ + +``--package-key`` + Package key specifying the package containing the sites content +``--path`` + relative or absolute path and filename to the export files +``--content-repository`` + contentRepository +``--verbose`` + verbose + + + + + .. _`Neos Command Reference: NEOS.NEOS neos.neos:site:list`: ``neos.neos:site:list`` @@ -2449,22 +2435,26 @@ Arguments -.. _`Neos Command Reference: NEOS.NEOS neos.neos:site:prune`: +.. _`Neos Command Reference: NEOS.NEOS neos.neos:site:pruneall`: -``neos.neos:site:prune`` -************************ +``neos.neos:site:pruneall`` +*************************** -**Remove site with content and related data (with globbing)** +**This will completely prune the data of the specified content repository and remove all site-records.** -In the future we need some more sophisticated cleanup. -Arguments -^^^^^^^^^ -``--site-node`` - Name for site root nodes to clear only content of this sites (globbing is supported) +Options +^^^^^^^ + +``--content-repository`` + Prune the cr without confirmation. This cannot be reverted! +``--force`` + force +``--verbose`` + verbose @@ -2771,6 +2761,7 @@ Options Without explicit workspace roles, only administrators can change the corresponding workspace. With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: +- viewer: Can read from the workspace - collaborator: Can read from and write to the workspace - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) @@ -2790,7 +2781,7 @@ Arguments ``--subject`` The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user ``--role`` - Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + Role to assign, either 'viewer', 'collaborator' or 'manager' – a viewer can only read from the workspace, a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index d6642d59d89..d47388c29ee 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst @@ -3,7 +3,7 @@ FluidAdaptor ViewHelper Reference ################################# -This reference was automatically generated from code on 2024-11-08 +This reference was automatically generated from code on 2024-11-19 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index da276092fcc..50df5726140 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst @@ -3,7 +3,7 @@ Form ViewHelper Reference ######################### -This reference was automatically generated from code on 2024-11-08 +This reference was automatically generated from code on 2024-11-19 .. _`Form ViewHelper Reference: neos.form:form`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst index 7bd4022063b..de2f66c7208 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst @@ -3,7 +3,7 @@ Media ViewHelper Reference ########################## -This reference was automatically generated from code on 2024-11-08 +This reference was automatically generated from code on 2024-11-19 .. _`Media ViewHelper Reference: neos.media:fileTypeIcon`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst index b982875a8d2..c9999dbcc68 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst @@ -3,7 +3,7 @@ Neos ViewHelper Reference ######################### -This reference was automatically generated from code on 2024-11-08 +This reference was automatically generated from code on 2024-11-19 .. _`Neos ViewHelper Reference: neos:backend.authenticationProviderLabel`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst index 98a91c21902..e1e7b3cb2d4 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst @@ -3,7 +3,7 @@ TYPO3 Fluid ViewHelper Reference ################################ -This reference was automatically generated from code on 2024-11-08 +This reference was automatically generated from code on 2024-11-19 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php new file mode 100644 index 00000000000..8d22c21adfc --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -0,0 +1,135 @@ + $className + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @BeforeScenario + */ + public function resetContentRepositorySecurity(): void + { + FakeAuthProvider::resetAuthProvider(); + $this->crSecurity_contentRepositorySecurityEnabled = false; + } + + private function enableContentRepositorySecurity(): void + { + if ($this->crSecurity_contentRepositorySecurityEnabled === true) { + return; + } + $contentRepositoryAuthProviderFactory = $this->getObject(ContentRepositoryAuthProviderFactory::class); + $contentGraphProjection = $this->getContentRepositoryService(new class implements ContentRepositoryServiceFactoryInterface { + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface + { + $contentGraphProjection = $serviceFactoryDependencies->projectionsAndCatchUpHooks->contentGraphProjection; + return new class ($contentGraphProjection) implements ContentRepositoryServiceInterface { + public function __construct( + public ContentGraphProjectionInterface $contentGraphProjection, + ) { + } + }; + } + })->contentGraphProjection; + $contentRepositoryAuthProvider = $contentRepositoryAuthProviderFactory->build($this->currentContentRepository->id, $contentGraphProjection->getState()); + + FakeAuthProvider::replaceAuthProvider($contentRepositoryAuthProvider); + $this->crSecurity_contentRepositorySecurityEnabled = true; + } + + /** + * @Given content repository security is enabled + */ + public function contentRepositorySecurityIsEnabled(): void + { + $this->enableFlowSecurity(); + $this->enableContentRepositorySecurity(); + } + + /** + * @When I am authenticated as :username + */ + public function iAmAuthenticatedAs(string $username): void + { + $user = $this->getObject(UserService::class)->getUser($username); + $this->authenticateAccount($user->getAccounts()->first()); + } + + /** + * @When I access the content graph for workspace :workspaceName + */ + public function iAccessesTheContentGraphForWorkspace(string $workspaceName): void + { + $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName))); + } + + /** + * @Then I should not be able to read node :nodeAggregateId + */ + public function iShouldNotBeAbleToReadNode(string $nodeAggregateId): void + { + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node !== null) { + Assert::fail(sprintf('Expected node "%s" to be inaccessible but it was loaded', $nodeAggregateId)); + } + } + + /** + * @Then I should be able to read node :nodeAggregateId + */ + public function iShouldBeAbleToReadNode(string $nodeAggregateId): void + { + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node === null) { + Assert::fail(sprintf('Expected node "%s" to be accessible but it could not be loaded', $nodeAggregateId)); + } + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php index fabfcd12608..b2257a5c4d2 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -12,6 +12,7 @@ * source code. */ +use Behat\Gherkin\Node\PyStringNode; use PHPUnit\Framework\Assert; /** @@ -37,12 +38,30 @@ private function tryCatchingExceptions(\Closure $callback): mixed } /** - * @Then an exception :exceptionMessage should be thrown + * @Then an exception of type :expectedShortExceptionName should be thrown with code :code + * @Then an exception of type :expectedShortExceptionName should be thrown with message: + * @Then an exception of type :expectedShortExceptionName should be thrown */ - public function anExceptionShouldBeThrown(string $exceptionMessage): void + public function anExceptionShouldBeThrown(string $expectedShortExceptionName, ?int $code = null, PyStringNode $expectedExceptionMessage = null): void { Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown'); - Assert::assertSame($exceptionMessage, $this->lastCaughtException->getMessage()); + $lastCaughtExceptionShortName = (new \ReflectionClass($this->lastCaughtException))->getShortName(); + Assert::assertSame($expectedShortExceptionName, $lastCaughtExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCaughtException), $this->lastCaughtException->getCode(), $this->lastCaughtException->getMessage())); + if ($expectedExceptionMessage !== null) { + Assert::assertSame($expectedExceptionMessage->getRaw(), $this->lastCaughtException->getMessage()); + } + if ($code !== null) { + Assert::assertSame($code, $this->lastCaughtException->getCode()); + } + $this->lastCaughtException = null; + } + + /** + * @Then no exception should be thrown + */ + public function noExceptionShouldBeThrown(): void + { + Assert::assertNull($this->lastCaughtException, 'Expected no exception but one was thrown'); $this->lastCaughtException = null; } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index e8636b4aac2..8ec72284b4d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -39,6 +39,7 @@ class FeatureContext implements BehatContext use CRBehavioralTestsSubjectProvider; use RoutingTrait; use MigrationsTrait; + use FrontendNodeControllerTrait; use FusionTrait; use ContentCacheTrait; @@ -46,8 +47,11 @@ class FeatureContext implements BehatContext use AssetTrait; use WorkspaceServiceTrait; + use ContentRepositorySecurityTrait; use UserServiceTrait; + use NodeDuplicationTrait; + protected Environment $environment; protected ContentRepositoryRegistry $contentRepositoryRegistry; diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php new file mode 100644 index 00000000000..5f4915dfcf1 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php @@ -0,0 +1,135 @@ + $className + * @return T + */ + abstract protected function getObject(string $className): object; + + /** + * @BeforeScenario + */ + final public function resetFlowSecurity(): void + { + $this->flowSecurity_securityEnabled = false; + + $policyService = $this->getObject(PolicyService::class); + // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager) + $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class)); + + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + // todo add setter! Also used in FunctionalTestCase https://github.com/neos/flow-development-collection/commit/b9c89e3e08649cbb5366cb769b2f79b0f13bd68e + ObjectAccess::setProperty($securityContext, 'authorizationChecksDisabled', true, true); + $this->getObject(PrivilegeManagerInterface::class)->reset(); + } + + final protected function enableFlowSecurity(): void + { + if ($this->flowSecurity_securityEnabled === true) { + return; + } + + $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class); + + $this->flowSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; + + $securityContext = $this->getObject(SecurityContext::class); + $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); + $this->flowSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); + $securityContext->setRequest($this->flowSecurity_mockActionRequest); + $this->flowSecurity_securityEnabled = true; + } + + final protected function authenticateAccount(Account $account): void + { + $this->enableFlowSecurity(); + $this->flowSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->flowSecurity_testingProvider->setAccount($account); + + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + $securityContext->setRequest($this->flowSecurity_mockActionRequest); + $this->getObject(AuthenticationProviderManager::class)->authenticate(); + } + + /** + * @Given The following additional policies are configured: + */ + final public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void + { + $policyService = $this->getObject(PolicyService::class); + + $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule( + $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY), + Yaml::parse($policies->getRaw()) + ); + + // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used) + // we can change the roles and privileges at runtime :D + $policyService->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager + { + public function __construct( + private array $mergedPolicyConfiguration + ) { + } + + public function getConfiguration(string $configurationType, string $configurationPath = null) + { + Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType); + Assert::assertSame(null, $configurationPath); + return $this->mergedPolicyConfiguration; + } + }); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FrontendNodeControllerTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FrontendNodeControllerTrait.php new file mode 100644 index 00000000000..982262f17d2 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FrontendNodeControllerTrait.php @@ -0,0 +1,92 @@ + $className + * + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @BeforeScenario + */ + public function setupFrontendNodeControllerTrait(): void + { + $this->getObject(ContentCache::class)->flush(); + $this->getObject(\Neos\Neos\Testing\TestingFusionAutoIncludeHandler::class)->reset(); + $this->frontendNodeControllerResponse = null; + } + + /** + * @When the Fusion code for package :package is: + */ + public function iHaveTheFollowingFusionCodeForTheSite(PyStringNode $fusionCode, string $package) + { + $testingFusionHandler = $this->getObject(\Neos\Neos\Testing\TestingFusionAutoIncludeHandler::class); + $testingFusionHandler->setFusionForPackage($package, \Neos\Fusion\Core\FusionSourceCodeCollection::fromString($fusionCode->getRaw())); + } + + /** + * @When I dispatch the following request :requestUri + */ + public function iDispatchTheFollowingRequest(string $requestUri) + { + $testingFusionHandler = $this->getObject(\Neos\Neos\Testing\TestingFusionAutoIncludeHandler::class); + $testingFusionHandler->setIncludeFusionPackage('Neos.Fusion'); + $testingFusionHandler->setIncludeFusionPackage('Neos.Neos'); + + $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', $requestUri); + + $this->frontendNodeControllerResponse = $this->getObject(\Neos\Flow\Http\Middleware\MiddlewaresChain::class)->handle( + $httpRequest + ); + } + + /** + * @Then I expect the following response header: + */ + public function iExpectTheFollowingResponseHeader(PyStringNode $expectedResult): void + { + Assert::assertNotNull($this->frontendNodeControllerResponse); + Assert::assertSame($expectedResult->getRaw(), $this->frontendNodeControllerResponse->getBody()->getContents()); + } + + /** + * @Then I expect the following response: + */ + public function iExpectTheFollowingResponse(PyStringNode $expectedResult): void + { + Assert::assertNotNull($this->frontendNodeControllerResponse); + Assert::assertEquals($expectedResult->getRaw(), str_replace("\r\n", "\n", Message::toString($this->frontendNodeControllerResponse))); + } +} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php similarity index 53% rename from Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php rename to Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php index 5b020a33873..2b3c00eb496 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCopying.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/NodeDuplicationTrait.php @@ -12,61 +12,70 @@ declare(strict_types=1); -namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; -use Neos\ContentRepository\Core\Feature\NodeDuplication\Dto\NodeAggregateIdMapping; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; +use Neos\Neos\Domain\Service\NodeDuplication\NodeAggregateIdMapping; +use Neos\Neos\Domain\Service\NodeDuplicationService; /** * The node copying trait for behavioral tests */ -trait NodeCopying +trait NodeDuplicationTrait { use CRTestSuiteRuntimeVariables; + use ExceptionsTrait; + + /** + * @template T of object + * @param class-string $className + * + * @return T + */ + abstract private function getObject(string $className): object; abstract protected function readPayloadTable(TableNode $payloadTable): array; /** - * @When /^the command CopyNodesRecursively is executed, copying the current node aggregate with payload:$/ + * @When /^copy nodes recursively is executed with payload:$/ */ - public function theCommandCopyNodesRecursivelyIsExecutedCopyingTheCurrentNodeAggregateWithPayload(TableNode $payloadTable): void + public function copyNodesRecursivelyIsExecutedWithPayload(TableNode $payloadTable): void { $commandArguments = $this->readPayloadTable($payloadTable); - $subgraph = $this->currentContentRepository->getContentGraph($this->currentWorkspaceName)->getSubgraph( - $this->currentDimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); + + $workspaceName = isset($commandArguments['workspaceName']) + ? WorkspaceName::fromString($commandArguments['workspaceName']) + : $this->currentWorkspaceName; + + $sourceNodeAggregateId = NodeAggregateId::fromString($commandArguments['sourceNodeAggregateId']); + $sourceDimensionSpacePoint = isset($commandArguments['sourceDimensionSpacePoint']) + ? DimensionSpacePoint::fromArray($commandArguments['sourceDimensionSpacePoint']) + : $this->currentDimensionSpacePoint; + $targetDimensionSpacePoint = isset($commandArguments['targetDimensionSpacePoint']) ? OriginDimensionSpacePoint::fromArray($commandArguments['targetDimensionSpacePoint']) : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); + $targetSucceedingSiblingNodeAggregateId = isset($commandArguments['targetSucceedingSiblingNodeAggregateId']) ? NodeAggregateId::fromString($commandArguments['targetSucceedingSiblingNodeAggregateId']) : null; - $workspaceName = isset($commandArguments['workspaceName']) - ? WorkspaceName::fromString($commandArguments['workspaceName']) - : $this->currentWorkspaceName; - - $command = CopyNodesRecursively::createFromSubgraphAndStartNode( - $subgraph, - $workspaceName, - $this->currentNode, - $targetDimensionSpacePoint, - NodeAggregateId::fromString($commandArguments['targetParentNodeAggregateId']), - $targetSucceedingSiblingNodeAggregateId + $this->tryCatchingExceptions( + fn () => $this->getObject(NodeDuplicationService::class)->copyNodesRecursively( + $this->currentContentRepository->id, + $workspaceName, + $sourceDimensionSpacePoint, + $sourceNodeAggregateId, + $targetDimensionSpacePoint, + NodeAggregateId::fromString($commandArguments['targetParentNodeAggregateId']), + $targetSucceedingSiblingNodeAggregateId, + NodeAggregateIdMapping::fromArray($commandArguments['nodeAggregateIdMapping'] ?? []) + ) ); - if (isset($commandArguments['targetNodeName'])) { - $command = $command->withTargetNodeName(NodeName::fromString($commandArguments['targetNodeName'])); - } - $command = $command->withNodeAggregateIdMapping(NodeAggregateIdMapping::fromArray($commandArguments['nodeAggregateIdMapping'])); - - $this->currentContentRepository->handle($command); } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/RoutingTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/RoutingTrait.php index 3eb2dc1a93a..f5232d55a10 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/RoutingTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/RoutingTrait.php @@ -82,14 +82,15 @@ abstract private function getObject(string $className): object; /** * @Given A site exists for node name :nodeName * @Given A site exists for node name :nodeName and domain :domain + * @Given A site exists for node name :nodeName and domain :domain and package :package */ - public function theSiteExists(string $nodeName, string $domain = null): void + public function theSiteExists(string $nodeName, string $domain = null, string $package = null): void { $siteRepository = $this->getObject(SiteRepository::class); $persistenceManager = $this->getObject(PersistenceManagerInterface::class); $site = new Site($nodeName); - $site->setSiteResourcesPackageKey('Neos.Neos'); + $site->setSiteResourcesPackageKey($package ?: 'Neos.Neos'); $site->setState(Site::STATE_ONLINE); $siteRepository->add($site); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 4d8d153f64e..da251d624fb 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -16,8 +16,10 @@ use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Security\AccountFactory; use Neos\Flow\Security\Cryptography\HashService; +use Neos\Flow\Security\Policy\PolicyService; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Service\UserService; +use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Party\Domain\Model\PersonName; use Neos\Utility\ObjectAccess; @@ -63,7 +65,7 @@ public function theFollowingNeosUsersExist(TableNode $usersTable): void username: $userData['Username'], firstName: $userData['First name'] ?? null, lastName: $userData['Last name'] ?? null, - roleIdentifiers: isset($userData['Roles']) ? explode(',', $userData['Roles']) : null, + roleIdentifiers: !empty($userData['Roles']) ? explode(',', $userData['Roles']) : null, id: $userData['Id'] ?? null, ); } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 06916378f00..af046a39443 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -18,15 +18,16 @@ use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use PHPUnit\Framework\Assert; /** @@ -62,17 +63,18 @@ public function theRootWorkspaceIsCreated(string $workspaceName, string $title = } /** - * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :ownerUserId + * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :username */ - public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $ownerUserId): void + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $username): void { + $ownerUserId = $this->userIdForUsername($username); $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createPersonalWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), - UserId::fromString($ownerUserId), + $ownerUserId, )); } @@ -169,12 +171,16 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table */ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void { + if ($groupName !== null) { + $subject = WorkspaceRoleSubject::createForGroup($groupName); + } else { + $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); + } $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceRoleAssignment::create( - $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $subject, WorkspaceRole::from($role) ) )); @@ -186,11 +192,15 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string */ public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void { + if ($groupName !== null) { + $subject = WorkspaceRoleSubject::createForGroup($groupName); + } else { + $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); + } $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $subject, )); } @@ -201,7 +211,7 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName { $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [ - 'Subject type' => $assignment->subjectType->value, + 'Subject type' => $assignment->subject->type->value, 'Subject' => $assignment->subject->value, 'Role' => $assignment->role->value, ], iterator_to_array($workspaceAssignments)); @@ -213,13 +223,17 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName */ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void { - $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser( + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + Assert::assertNotNull($user); + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $roles, + $user->getId(), ); - Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter((array)$permissions)))); + Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions))))); } /** @@ -227,14 +241,25 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username */ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void { - $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser( + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + Assert::assertNotNull($user); + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $roles, + $user->getId(), ); Assert::assertFalse($permissions->read); Assert::assertFalse($permissions->write); Assert::assertFalse($permissions->manage); } + + private function userIdForUsername(string $username): UserId + { + $user = $this->getObject(UserService::class)->getUser($username); + Assert::assertNotNull($user, sprintf('The user "%s" does not exist', $username)); + return $user->getId(); + } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature new file mode 100644 index 00000000000..edfdda081ca --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_NoDimensions.feature @@ -0,0 +1,55 @@ +@contentrepository +Feature: Copy nodes constraint checks + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Document': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | nody-mc-nodeface | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | sir-nodeward-nodington-iii | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + + Scenario: Coping fails if the source node does not exist + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "not-existing" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + + Then an exception of type NodeAggregateCurrentlyDoesNotExist should be thrown with code 1732006772 + + Scenario: Coping fails if the target node does not exist + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nody-mc-nodeface" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "not-existing" | + + Then an exception of type NodeAggregateCurrentlyDoesNotExist should be thrown with code 1732006769 + + Scenario: Coping fails if the source node is a root + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "lady-eleonode-rootford" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + + Then an exception of type NodeTypeIsOfTypeRoot should be thrown with code 1541765806 diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature new file mode 100644 index 00000000000..4bb4ea277c7 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_ConstraintChecks_TetheredNodes.feature @@ -0,0 +1,54 @@ +@contentrepository +Feature: Copy nodes constraint checks + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': [] + 'Neos.ContentRepository.Testing:TetheredDocument': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + 'Neos.ContentRepository.Testing:Document': + childNodes: + tethered-document: + type: 'Neos.ContentRepository.Testing:TetheredDocument' + 'Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren': [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | node-mc-nodeface | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | {} | + | node-wan-kenody | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | {"tethered-document": "nodewyn-tetherton", "tethered-document/tethered": "nodimer-tetherton"} | + | sir-david-nodenburg | lady-eleonode-rootford | Neos.ContentRepository.Testing:TetheredDocument | {"tethered": "davids-tether"} | + + Scenario: Coping fails if the leaf of a nested tethered node is attempted to be copied + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + And I expect the node aggregate "nodimer-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "node-mc-nodeface" | + + Then an exception of type TetheredNodesCannotBePartiallyCopied should be thrown with message: + """ + Cannot copy tethered node nodewyn-tetherton because child node nodimer-tetherton is also tethered. Only standalone tethered nodes can be copied. + """ diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature new file mode 100644 index 00000000000..f061b789a00 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_NoDimensions.feature @@ -0,0 +1,221 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Copy nodes (without dimensions) + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Document': + properties: + title: + type: string + array: + type: array + uri: + type: GuzzleHttp\Psr7\Uri + date: + type: DateTimeImmutable + references: + ref: [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | sir-david-nodenborough | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | nody-mc-nodeface | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | + | node-wan-kenodi | lady-eleonode-rootford | Neos.ContentRepository.Testing:Document | + | sir-nodeward-nodington-iii | node-wan-kenodi | Neos.ContentRepository.Testing:Document | + + Scenario: Simple singular node aggregate is copied + When I am in workspace "live" and dimension space point {} + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + Then I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Document" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["nody-mc-nodeface"] + + Scenario: Singular node aggregate is copied with (complex) properties + When I am in workspace "live" and dimension space point {} + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | propertyValues | {"title": "Original Text", "array": {"givenName":"Nody", "familyName":"McNodeface"}, "uri": {"__type": "GuzzleHttp\\\\Psr7\\\\Uri", "value": "https://neos.de"}, "date": {"__type": "DateTimeImmutable", "value": "2001-09-22T12:00:00+00:00"}} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to have the following serialized properties: + | Key | Type | Value | + | title | string | "Original Text" | + | array | array | {"givenName":"Nody","familyName":"McNodeface"} | + | date | DateTimeImmutable | "2001-09-22T12:00:00+00:00" | + | uri | GuzzleHttp\Psr7\Uri | "https://neos.de" | + + Scenario: Singular node aggregate is copied with references + When I am in workspace "live" and dimension space point {} + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Singular node aggregate is copied with subtree tags (and disabled state) + When I am in workspace "live" and dimension space point {} + + Given the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "node-wan-kenodi" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "parent-tag" | + + And VisibilityConstraints are set to "withoutRestrictions" + + # we inherit the tag here but DONT copy it! + Then I expect the node with aggregate identifier "sir-nodeward-nodington-iii" to inherit the tag "parent-tag" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to disable dimension space points [{}] + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to be exactly explicitly tagged "disabled,tag1" + + Scenario: Node aggregate is copied children recursively + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | {} | + | child-a1 | child-a | Neos.ContentRepository.Testing:Document | {"title": "I am Node A1"} | + | child-a2 | child-a | Neos.ContentRepository.Testing:Document | {} | + | child-b | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | {} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy", "child-b": "child-b-copy", "child-a1": "child-a1-copy", "child-a2": "child-a2-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-iii-copy" to exist + And I expect this node aggregate to have the child node aggregates ["child-a-copy","child-b-copy"] + + And I expect the node aggregate "child-a-copy" to exist + And I expect this node aggregate to have the child node aggregates ["child-a1-copy","child-a2-copy"] + + And I expect the node aggregate "child-b-copy" to exist + And I expect this node aggregate to have no child node aggregates + + And I expect node aggregate identifier "child-a1-copy" to lead to node cs-identifier;child-a1-copy;{} + And I expect this node to have the following serialized properties: + | Key | Type | Value | + | title | string | "I am Node A1" | + + Scenario: References are copied for child nodes + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | references | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy"} | + + And I expect node aggregate identifier "child-a-copy" to lead to node cs-identifier;child-a-copy;{} + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Subtree tags are copied for child nodes + When I am in workspace "live" and dimension space point {} + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | + | child-a | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:Document | + + Given the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "child-a" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy", "child-a": "child-a-copy"} | + + And I expect node aggregate identifier "child-a-copy" to lead to node cs-identifier;child-a-copy;{} + And I expect this node to be exactly explicitly tagged "tag1" diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature new file mode 100644 index 00000000000..90359de22c4 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/NodeCopying/CopyNode_TetheredNodes.feature @@ -0,0 +1,213 @@ +Feature: Copy nodes with tethered nodes + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': + properties: + title: + type: string + references: + ref: [] + 'Neos.ContentRepository.Testing:DocumentWithTethered': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + 'Neos.ContentRepository.Testing:RootDocument': + childNodes: + tethered-document: + type: 'Neos.ContentRepository.Testing:DocumentWithTethered' + 'Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren': [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + When I am in workspace "live" and dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | sir-david-nodenborough | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | {} | + | nody-mc-nodeface | sir-david-nodenborough | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | | + | nodimus-primus | lady-eleonode-rootford | Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren | | + | sir-nodeward-nodington-i | nodimus-primus | Neos.ContentRepository.Testing:DocumentWithTethered | {"tethered": "nodewyn-tetherton"} | + | node-wan-kenodi | sir-david-nodenborough | Neos.ContentRepository.Testing:RootDocument | {"tethered-document": "tethered-document", "tethered-document/tethered": "tethered-document-child"} | + + Scenario: Coping a tethered node turns it into a regular node + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + # must not be tethered! + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + Scenario: Coping a node with tethered node keeps the child node tethered + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-i" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-i": "sir-nodeward-nodington-ii", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "sir-nodeward-nodington-ii" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["nodewyn-tetherton-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-nodeward-nodington-ii"] + + Scenario: Coping a node with nested tethered nodes keeps the child nodes tethered + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "node-wan-kenodi" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"node-wan-kenodi": "node-wan-kenodi-copy", "tethered-document": "tethered-document-copy", "tethered-document-child": "tethered-document-child-copy"} | + + And I expect the node aggregate "node-wan-kenodi-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:RootDocument" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["tethered-document-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "tethered-document-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered-document" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["tethered-document-child-copy"] + + And I expect the node aggregate "tethered-document-child-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + + Scenario: Coping a regular node with a child node that has a tethered child node + And I expect the node aggregate "nodewyn-tetherton" to exist + And I expect this node aggregate to be classified as "tethered" + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "nodimus-primus" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"nodimus-primus": "nodimus-primus-copy", "sir-nodeward-nodington-i": "sir-nodeward-nodington-i-copy", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect the node aggregate "nodimus-primus-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithoutTetheredChildren" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["sir-nodeward-nodington-i-copy"] + And I expect this node aggregate to have the parent node aggregates ["sir-david-nodenborough"] + + And I expect the node aggregate "sir-nodeward-nodington-i-copy" to exist + And I expect this node aggregate to be classified as "regular" + And I expect this node aggregate to be unnamed + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:DocumentWithTethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have the child node aggregates ["nodewyn-tetherton-copy"] + And I expect this node aggregate to have the parent node aggregates ["nodimus-primus-copy"] + + And I expect the node aggregate "nodewyn-tetherton-copy" to exist + And I expect this node aggregate to be classified as "tethered" + And I expect this node aggregate to be named "tethered" + And I expect this node aggregate to be of type "Neos.ContentRepository.Testing:Tethered" + And I expect this node aggregate to occupy dimension space points [[]] + And I expect this node aggregate to disable dimension space points [] + And I expect this node aggregate to have no child node aggregates + And I expect this node aggregate to have the parent node aggregates ["sir-nodeward-nodington-i-copy"] + + Scenario: Properties and references are copied for tethered child nodes + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "nodewyn-tetherton" | + | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nodewyn-tetherton" | + | propertyValues | {"title": "Original Text"} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-nodeward-nodington-i" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "sir-david-nodenborough" | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-i": "sir-nodeward-nodington-ii", "nodewyn-tetherton": "nodewyn-tetherton-copy"} | + + And I expect node aggregate identifier "nodewyn-tetherton-copy" to lead to node cs-identifier;nodewyn-tetherton-copy;{} + And I expect this node to have the following properties: + | Key | Value | + | title | "Original Text" | + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | + + Scenario: Properties are copied for deeply nested tethered nodes + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "tethered-document-child" | + | propertyValues | {"title": "Original Text"} | + + When copy nodes recursively is executed with payload: + | Key | Value | + | sourceDimensionSpacePoint | {} | + | sourceNodeAggregateId | "sir-david-nodenborough" | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nodimus-primus" | + | nodeAggregateIdMapping | {"sir-david-nodenborough": "sir-david-nodenborough-copy", "node-wan-kenodi": "node-wan-kenodi-copy", "tethered-document": "tethered-document-copy", "tethered-document-child": "tethered-document-child-copy"} | + + And I expect node aggregate identifier "tethered-document-child-copy" to lead to node cs-identifier;tethered-document-child-copy;{} + And I expect this node to have the following properties: + | Key | Value | + | title | "Original Text" | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature new file mode 100644 index 00000000000..d279d035287 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -0,0 +1,106 @@ +@flowEntities +Feature: EditNodePrivilege related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege': + 'Neos.Neos:EditSubtreeA': + matcher: 'subtree_a' + roles: + 'Neos.Neos:RoleWithPrivilegeToEditSubtree': + privileges: + - + privilegeTarget: 'Neos.Neos:EditSubtreeA' + permission: GRANT + """ + And using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Username | First name | Last name | Roles | + | admin | Armin | Admin | Neos.Neos:Administrator | + | restricted_editor | Rich | Restricted | Neos.Neos:RestrictedEditor | + | editor | Edward | Editor | Neos.Neos:Editor | + | editor_with_privilege | Pete | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToEditSubtree | + And I am in workspace "live" + And I am in dimension space point {"language":"de"} + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "subtree_a" | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a1a1a" | + | nodeVariantSelectionStrategy | "allVariants" | + And the role COLLABORATOR is assigned to workspace "live" for group "Neos.Neos:Editor" + When a personal workspace for user "editor" is created + And content repository security is enabled + + Scenario Outline: Handling all relevant EditNodePrivilege related commands with different users + Given I am authenticated as "editor" + When the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "restricted_editor" + When the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "admin" + When the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "editor_with_privilege" + And the command is executed with payload '' + + When I am in workspace "edward-editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | command | command payload | + | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"} | + | CreateNodeVariant | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"en"}} | + | DisableNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | EnableNodeAggregate | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"} | + | RemoveNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | TagSubtree | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"} | + | UntagSubtree | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"} | + | MoveNodeAggregate | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"} | + | SetNodeProperties | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}} | + | SetNodeReferences | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature new file mode 100644 index 00000000000..2545f9c7bac --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -0,0 +1,87 @@ +@flowEntities +Feature: ReadNodePrivilege related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + 'Neos.Neos:ReadSubtreeA': + matcher: 'subtree_a' + roles: + 'Neos.Neos:RoleWithPrivilegeToReadSubtree': + privileges: + - + privilegeTarget: 'Neos.Neos:ReadSubtreeA' + permission: GRANT + """ + And using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Username | First name | Last name | Roles | + | admin | Armin | Admin | Neos.Neos:Administrator | + | restricted_editor | Rich | Restricted | Neos.Neos:RestrictedEditor | + | editor | Edward | Editor | Neos.Neos:Editor | + | editor_with_privilege | Pete | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToReadSubtree | + And I am in workspace "live" + And I am in dimension space point {"language":"de"} + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "subtree_a" | + And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" + When a personal workspace for user "editor" is created + And content repository security is enabled + + Scenario Outline: Read tagged node as user without corresponding ReadNodePrivilege + And I am authenticated as "" + Then I should not be able to read node "a1" + + Examples: + | user | + | admin | + | restricted_editor | + | editor | + + Scenario Outline: Read tagged node as user with corresponding ReadNodePrivilege + And I am authenticated as "" + Then I should be able to read node "a1" + + Examples: + | user | + | editor_with_privilege | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature new file mode 100644 index 00000000000..7b02cca647b --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature @@ -0,0 +1,216 @@ +@flowEntities +Feature: Workspace permission related features + + Background: + When using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] + 'Neos.Neos:Document2': {} + 'Neos.Neos:CustomRoot': + superTypes: + 'Neos.ContentRepository:Root': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Username | Roles | + | admin | Neos.Neos:Administrator | + | editor | Neos.Neos:Editor | + | restricted_editor | Neos.Neos:RestrictedEditor | + | owner | Neos.Neos:Editor | + | manager | Neos.Neos:Editor | + | collaborator | Neos.Neos:Editor | + | uninvolved | Neos.Neos:Editor | + And I am in workspace "live" + And I am in dimension space point {"language":"de"} + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "subtree_a" | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a1a1a" | + | nodeVariantSelectionStrategy | "allVariants" | + And the personal workspace "workspace" is created with the target workspace "live" for user "owner" + And I am in workspace "workspace" + And the role MANAGER is assigned to workspace "workspace" for user "manager" + And the role COLLABORATOR is assigned to workspace "workspace" for user "collaborator" + # The following step was added in order to make the `AddDimensionShineThrough` command viable + And I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | mul, de, ch | ch->de->mul | + And content repository security is enabled + + Scenario Outline: Creating a root workspace + Given I am authenticated as + When the command CreateRootWorkspace is executed with payload '{"workspaceName":"new-ws","newContentStreamId":"new-cs"}' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | admin | + | editor | + | restricted_editor | + | owner | + | collaborator | + | uninvolved | + + Scenario Outline: Creating a base workspace without WRITE permissions + Given I am authenticated as + And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" + Then an exception of type "AccessDenied" should be thrown with code 1729086686 + + And the personal workspace "some-other-personal-workspace" is created with the target workspace "workspace" for user + Then an exception of type "AccessDenied" should be thrown with code 1729086686 + + Examples: + | user | + | admin | + | editor | + | restricted_editor | + | uninvolved | + + Scenario Outline: Creating a base workspace with WRITE permissions + Given I am authenticated as + And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" + + And the personal workspace "some-other-personal-workspace" is created with the target workspace "workspace" for user + + Examples: + | user | + | collaborator | + | owner | + + Scenario Outline: Deleting a workspace without MANAGE permissions + Given I am authenticated as + When the command DeleteWorkspace is executed with payload '{"workspaceName":"workspace"}' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | collaborator | + | uninvolved | + + Scenario Outline: Deleting a workspace with MANAGE permissions + Given I am authenticated as + When the command DeleteWorkspace is executed with payload '{"workspaceName":"workspace"}' + + Examples: + | user | + | admin | + | manager | + | owner | + + Scenario Outline: Managing metadata and roles of a workspace without MANAGE permissions + Given I am authenticated as + And the title of workspace "workspace" is set to "Some new workspace title" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + And the description of workspace "workspace" is set to "Some new workspace description" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + When the role COLLABORATOR is assigned to workspace "workspace" for group "Neos.Neos:AbstractEditor" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "workspace" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + Examples: + | user | + | collaborator | + | uninvolved | + + Scenario Outline: Managing metadata and roles of a workspace with MANAGE permissions + Given I am authenticated as + And the title of workspace "workspace" is set to "Some new workspace title" + And the description of workspace "workspace" is set to "Some new workspace description" + When the role COLLABORATOR is assigned to workspace "workspace" for group "Neos.Neos:AbstractEditor" + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "workspace" + + Examples: + | user | + | admin | + | manager | + | owner | + + Scenario Outline: Handling commands that require WRITE permissions on the workspace + When I am authenticated as "uninvolved" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "restricted_editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "admin" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "owner" + And the command is executed with payload '' + + # todo test also collaborator, but cannot commands twice here: + # When I am authenticated as "collaborator" + # And the command is executed with payload '' and exceptions are caught + + Examples: + | command | command payload | + | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"} | + | CreateNodeVariant | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"mul"}} | + | DisableNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | EnableNodeAggregate | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"} | + | RemoveNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | TagSubtree | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"} | + | UntagSubtree | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"} | + | MoveNodeAggregate | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"} | + | SetNodeProperties | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}} | + | SetNodeReferences | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} | + + | AddDimensionShineThrough | {"nodeAggregateId":"a1","source":{"language":"de"},"target":{"language":"ch"}} | + | ChangeNodeAggregateName | {"nodeAggregateId":"a1","newNodeName":"changed"} | + | ChangeNodeAggregateType | {"nodeAggregateId":"a1","newNodeTypeName":"Neos.Neos:Document2","strategy":"happypath"} | + | CreateRootNodeAggregateWithNode | {"nodeAggregateId":"c","nodeTypeName":"Neos.Neos:CustomRoot"} | + | MoveDimensionSpacePoint | {"source":{"language":"de"},"target":{"language":"ch"}} | + | UpdateRootNodeAggregateDimensions | {"nodeAggregateId":"root"} | + | DiscardWorkspace | {} | + | DiscardIndividualNodesFromWorkspace | {"nodesToDiscard":[{"nodeAggregateId":"a1"}]} | + | PublishWorkspace | {} | + | PublishIndividualNodesFromWorkspace | {"nodesToPublish":[{"nodeAggregateId":"a1"}]} | + | RebaseWorkspace | {} | + | CreateWorkspace | {"workspaceName":"new-workspace","baseWorkspaceName":"workspace","newContentStreamId":"any"} | + diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index ea0a90995a2..22b3ff17e04 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -29,12 +29,15 @@ Feature: Neos WorkspaceService related features Scenario: Create root workspace with a name that exceeds the workspace name max length When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created - Then an exception 'Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters' should be thrown + Then an exception of type "InvalidArgumentException" should be thrown with message: + """ + Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters + """ Scenario: Create root workspace with a name that is already used Given the root workspace "some-root-workspace" is created When the root workspace "some-root-workspace" is created - Then an exception "The workspace some-root-workspace already exists" should be thrown + Then an exception of type "WorkspaceAlreadyExists" should be thrown Scenario: Get metadata of non-existing root workspace When a root workspace "some-root-workspace" exists without metadata @@ -73,10 +76,10 @@ Feature: Neos WorkspaceService related features Scenario: Create a single personal workspace When the root workspace "some-root-workspace" is created - And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id" + And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" Then the workspace "some-user-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | - | some-user-workspace | | PERSONAL | some-user-id | + | some-user-workspace | | PERSONAL | janedoe | Scenario: Create a single shared workspace When the root workspace "some-root-workspace" is created @@ -94,7 +97,10 @@ Feature: Neos WorkspaceService related features Scenario: Assign role to non-existing workspace When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor" - Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to find workspace with name "some-workspace" for content repository "default" + """ Scenario: Assign group role to root workspace Given the root workspace "some-root-workspace" is created @@ -107,42 +113,54 @@ Feature: Neos WorkspaceService related features Given the root workspace "some-root-workspace" is created When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" - Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first + """ Scenario: Assign user role to root workspace Given the root workspace "some-root-workspace" is created - When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + When the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe" Then the workspace "some-root-workspace" should have the following role assignments: - | Subject type | Subject | Role | - | USER | some-user-id | MANAGER | + | Subject type | Subject | Role | + | USER | janedoe | MANAGER | Scenario: Assign a role to the same user twice Given the root workspace "some-root-workspace" is created - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id" - And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" - Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe" + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to assign role for workspace "some-root-workspace" to subject "johndoe" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first + """ Scenario: Unassign role from non-existing workspace When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" - Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to find workspace with name "some-workspace" for content repository "default" + """ Scenario: Unassign role from workspace that has not been assigned before Given the root workspace "some-root-workspace" is created When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" - Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group + """ Scenario: Assign two roles, then unassign one Given the root workspace "some-root-workspace" is created - And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe" And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" Then the workspace "some-root-workspace" should have the following role assignments: | Subject type | Subject | Role | | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | - | USER | some-user-id | MANAGER | + | USER | janedoe | MANAGER | When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" Then the workspace "some-root-workspace" should have the following role assignments: - | Subject type | Subject | Role | - | USER | some-user-id | MANAGER | + | Subject type | Subject | Role | + | USER | janedoe | MANAGER | Scenario: Workspace permissions for personal workspace for admin user Given the root workspace "live" is created @@ -186,14 +204,14 @@ Feature: Neos WorkspaceService related features Scenario: Workspace permissions for collaborator by user When the root workspace "some-root-workspace" is created - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "johndoe" + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe" Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have the permissions "read,write" for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" Scenario: Workspace permissions for manager by user When the root workspace "some-root-workspace" is created - When the role MANAGER is assigned to workspace "some-root-workspace" for user "johndoe" + When the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe" Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" @@ -212,7 +230,7 @@ Feature: Neos WorkspaceService related features Scenario: Permissions for workspace without metadata Given a root workspace "some-root-workspace" exists without metadata - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "janedoe" + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe" Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendNodeController/DefaultFusionRendering.feature b/Neos.Neos/Tests/Behavior/Features/FrontendNodeController/DefaultFusionRendering.feature new file mode 100644 index 00000000000..8e2d8dfcd12 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/FrontendNodeController/DefaultFusionRendering.feature @@ -0,0 +1,113 @@ +@flowEntities +Feature: Test the default Fusion rendering for a request + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': {} + 'Neos.Neos:ContentCollection': {} + 'Neos.Neos:Content': {} + 'Neos.Neos:Sites': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.Neos:Document': + properties: + title: + type: string + uriPathSegment: + type: string + 'Neos.Neos:Site': + superTypes: + 'Neos.Neos:Document': true + childNodes: + main: + type: 'Neos.Neos:ContentCollection' + 'Neos.Neos:Test.DocumentType': + superTypes: + 'Neos.Neos:Document': true + childNodes: + main: + type: 'Neos.Neos:ContentCollection' + 'Neos.Neos:Test.ContentType': + superTypes: + 'Neos.Neos:Content': true + properties: + text: + type: string + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + When the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.Neos:Sites" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | parentNodeAggregateId | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | nodeName | + | a | root | Neos.Neos:Site | {"title": "Node a"} | {} | a | + | a1 | a | Neos.Neos:Test.DocumentType | {"uriPathSegment": "a1", "title": "Node a1"} | {"main": "a-tetherton" } | | + | a1a1 | a-tetherton | Neos.Neos:Test.ContentType | {"text": "my first text"} | {} | | + | a1a2 | a-tetherton | Neos.Neos:Test.ContentType | {"text": "my second text"} | {} | | + And A site exists for node name "a" and domain "http://localhost" and package "Vendor.Site" + And the sites configuration is: + """yaml + Neos: + Neos: + sites: + 'a': + preset: default + uriPathSuffix: '' + contentDimensions: + resolver: + factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory + """ + + Scenario: Default output + And the Fusion code for package "Vendor.Site" is: + """fusion + prototype(Neos.Neos:Test.DocumentType) < prototype(Neos.Neos:Page) { + body { + content = Neos.Fusion:Component { + renderer = afx` + {String.chr(10)}title: {node.properties.title} + {String.chr(10)}children: + {String.chr(10)} + ` + } + } + } + prototype(Neos.Neos:Test.ContentType) < prototype(Neos.Neos:ContentComponent) { + text = Neos.Neos:Editable { + property = 'text' + } + + renderer = afx` + [{props.text}] + ` + } + """ + + When I dispatch the following request "/a1" + Then I expect the following response: + """ + HTTP/1.1 200 OK + Content-Type: text/html + X-Flow-Powered: Flow/dev Neos/dev + Content-Length: 486 + + + + Node a1 + title: Node a1 + children:
[my first text][my second text]
+ + """ diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature index b1edf071af4..f5c77c72d9a 100644 --- a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature +++ b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature @@ -109,12 +109,11 @@ Feature: Tests for the "Neos.Neos:ContentCollection" Fusion prototype """ Scenario: - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "a1" | | nodeTypeName | "Neos.Neos:Test.DocumentType" | | parentNodeAggregateId | "a" | - | initialPropertyValues | {} | | tetheredDescendantNodeAggregateIds | { "main": "a1-main"} | When the following CreateNodeAggregateWithNode commands are executed: | nodeAggregateId | parentNodeAggregateId | nodeTypeName | diff --git a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature b/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature deleted file mode 100644 index 2fa92e93581..00000000000 --- a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature +++ /dev/null @@ -1,148 +0,0 @@ -# TODO rewrite test after https://github.com/neos/neos-development-collection/issues/3732 - -Feature: Privilege to restrict nodes shown in the node tree - - Background: - Given I have the following policies: - """ - privilegeTargets: - - 'Neos\Neos\Security\Authorization\Privilege\NodeTreePrivilege': - 'Neos.ContentRepository:CompanySubtree': - matcher: 'isDescendantNodeOf("/sites/content-repository/company")' - 'Neos.ContentRepository:ServiceSubtree': - matcher: 'isDescendantNodeOf("/sites/content-repository/service")' - - 'Neos.ContentRepository:NeosSite': - matcher: 'isDescendantNodeOf("/sites/neos")' - 'Neos.ContentRepository:NeosTeams': - matcher: 'isAncestorOrDescendantNodeOf("/sites/neos/community/teams")' - - 'Neos\ContentRepository\Security\Authorization\Privilege\Node\EditNodePrivilege': - 'Neos.ContentRepository:EditNeosTeamsPath': - matcher: 'isAncestorNodeOf("/sites/neos/community/teams")' - - roles: - 'Neos.Flow:Everybody': - privileges: [] - - 'Neos.Flow:Anonymous': - privileges: [] - - 'Neos.Flow:AuthenticatedUser': - privileges: [] - - 'Neos.Neos:Editor': - privileges: - - - privilegeTarget: 'Neos.ContentRepository:CompanySubtree' - permission: GRANT - - 'Neos.Neos:Administrator': - parentRoles: ['Neos.Neos:Editor'] - privileges: - - - privilegeTarget: 'Neos.ContentRepository:ServiceSubtree' - permission: GRANT - - - privilegeTarget: 'Neos.ContentRepository:NeosTeams' - permission: GRANT - - - privilegeTarget: 'Neos.ContentRepository:EditNeosTeamsPath' - permission: DENY - - """ - - And I have the following nodes: - | Identifier | Path | Node Type | Properties | Workspace | - | ecf40ad1-3119-0a43-d02e-55f8b5aa3c70 | /sites | unstructured | | live | - | fd5ba6e1-4313-b145-1004-dad2f1173a35 | /sites/content-repository | Neos.ContentRepository.Testing:Document | {"title": "Home"} | live | - | 68ca0dcd-2afb-ef0e-1106-a5301e65b8a0 | /sites/content-repository/company | Neos.ContentRepository.Testing:Document | {"title": "Company"} | live | - | 52540602-b417-11e3-9358-14109fd7a2dd | /sites/content-repository/service | Neos.ContentRepository.Testing:Document | {"title": "Service"} | live | - | 3223481d-e11c-4db7-95de-b371411a2431 | /sites/content-repository/service/newsletter | Neos.ContentRepository.Testing:Document | {"title": "Newsletter"} | live | - | 544e14a3-b21d-429a-9fdd-cbeccc8d2b0f | /sites/content-repository/about-us | Neos.ContentRepository.Testing:Document | {"title": "About us"} | live | - | 56217c92-07e9-4554-ac35-03f86d278870 | /sites/neos | Neos.ContentRepository.Testing:Document | {"title": "Neos"} | live | - | 4be072fe-0738-4892-8a27-342a6ac96075 | /sites/neos/community | Neos.ContentRepository.Testing:Document | {"title": "Community"} | live | - | c56d66e7-9c55-4eef-a2b1-c263b3261996 | /sites/neos/community/teams | Neos.ContentRepository.Testing:Document | {"title": "Teams"} | live | - | 07902b2e-61d9-4ce4-9b90-1cf338830d2f | /sites/neos/community/teams/member| Neos.ContentRepository.Testing:Document | {"title": "Johannes"} | live | - - @Isolated @fixtures - Scenario: Editors are granted to set properties on company node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/company" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "The company" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on service node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/service" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "Our services" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on service sub node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/service/newsletter" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "Our newsletter" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on company node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/company" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "The company" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on service node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/service" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Our services" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on service sub node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/service/newsletter" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Our newsletter" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on a neos sub node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/neos/community/teams" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "The Teams" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on a neos sub node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/neos/community/teams/member" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Basti" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are not granted to set properties on an ancestor node of teams - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/neos/community" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "The Community" - And I should get false when asking the node authorization service if editing this node is granted diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index dc1c0aa5bb0..e8f02988acb 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -44,6 +44,7 @@ use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; use Neos\Flow\Security\Context; +use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Neos\Controller\Module\AbstractModuleController; @@ -63,6 +64,7 @@ use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\PendingChangesProjection\ChangeFinder; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; use Neos\Workspace\Ui\ViewModel\PendingChanges; use Neos\Workspace\Ui\ViewModel\WorkspaceListItem; @@ -104,6 +106,9 @@ class WorkspaceController extends AbstractModuleController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * Display a list of unpublished content */ @@ -111,7 +116,7 @@ public function indexAction(): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1718308216); + throw new AccessDeniedException('No user authenticated', 1718308216); } $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); @@ -139,7 +144,7 @@ public function indexAction(): void continue; } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); if (!$permissions->read) { continue; } @@ -161,7 +166,7 @@ public function showAction(WorkspaceName $workspace): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1720371024); + throw new AccessDeniedException('No user authenticated', 1720371024); } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -178,7 +183,7 @@ public function showAction(WorkspaceName $workspace): void $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName); assert($baseWorkspace !== null); $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); - $baseWorkspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, @@ -207,7 +212,7 @@ public function createAction( ): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1718303756); + throw new AccessDeniedException('No user authenticated', 1718303756); } $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value); try { @@ -288,6 +293,15 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new AccessDeniedException('No user is authenticated', 1729620262); + } + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); + if (!$workspacePermissions->manage) { + throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297); + } + if ($title->value === '') { $title = WorkspaceTitle::fromString($workspaceName->value); } @@ -998,7 +1012,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { - $user = $this->userService->getCurrentUser(); + $currentUser = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); foreach ($workspaces as $workspace) { @@ -1014,10 +1028,7 @@ protected function prepareBaseWorkspaceOptions( if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - if ($user === null) { - continue; - } - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId()); if (!$permissions->manage) { continue; } diff --git a/README.md b/README.md index b7cb5af08a2..4183dd4275c 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,16 @@ You can chose from one of the following options: #### Migrating an existing (Neos < 9.0) Site ``` bash -# WORKAROUND: for now, you still need to create a site (which must match the root node name) -# !! in the future, you would want to import *INTO* a given site (and replace its root node) -./flow site:create neosdemo Neos.Demo Neos.Demo:Document.Homepage - -# the following config points to a Neos 8.0 database (adjust to your needs), created by -# the legacy "./flow site:import Neos.Demo" command. -./flow cr:migrateLegacyData --config '{"dbal": {"dbname": "neos80"}, "resourcesPath": "/path/to/neos-8.0/Data/Persistent/Resources"}' +# the following config points to a Neos 8.0 database (adjust to your needs) +./flow site:exportLegacyData --path ./migratedContent --config '{"dbal": {"dbname": "neos80"}, "resourcesPath": "/path/to/neos-8.0/Data/Persistent/Resources"}' +# import the migrated data +./flow site:importAll --path ./migratedContent ``` #### Importing an existing (Neos >= 9.0) Site from an Export ``` bash -# import the event stream from the Neos.Demo package -./flow cr:import Packages/Sites/Neos.Demo/Resources/Private/Content +./flow site:importAll --package-key Neos.Demo ``` ### Running Neos diff --git a/composer.json b/composer.json index 9a568f75d0b..668fb6e7918 100644 --- a/composer.json +++ b/composer.json @@ -96,7 +96,7 @@ "scripts": { "lint:phpcs": "../../bin/phpcs --colors", "lint:phpcs:fix": "../../bin/phpcbf --colors", - "lint:phpstan": "../../bin/phpstan analyse", + "lint:phpstan": "../../bin/phpstan analyse -v", "lint:phpstan-generate-baseline": "../../bin/phpstan analyse --generate-baseline", "lint:distributionintegrity": "[ -d 'Neos.ContentRepository' ] && { echo 'Package Neos.ContentRepository should not exist.' 1>&2; exit 1; } || exit 0;", "lint": [ @@ -108,8 +108,9 @@ "../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepository.Core/Tests/Unit", "../../bin/phpunit --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/UnitTests.xml Neos.ContentRepositoryRegistry/Tests/Unit" ], + "test:paratest-cli": "../../bin/paratest --debug -v --functional --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml", "test:parallel": [ - "FLOW_CONTEXT=Testing/Behat ../../bin/paratest --debug -v --functional --group parallel --processes 2 --colors --stop-on-failure -c ../../Build/BuildEssentials/PhpUnit/FunctionalTests.xml Neos.ContentRepository.BehavioralTests/Tests/Parallel/WorkspaceWritingDuringRebase/WorkspaceWritingDuringRebase.php" + "for f in Neos.ContentRepository.BehavioralTests/Tests/Parallel/**/*Test.php; do composer test:paratest-cli $f; done" ], "test:behat-cli": "../../bin/behat -f progress --strict --no-interaction", "test:behavioral": [ @@ -291,10 +292,19 @@ }, "require-dev": { "roave/security-advisories": "dev-latest", - "phpstan/phpstan": "^1.8", + "phpstan/phpstan": "^1.11", "squizlabs/php_codesniffer": "^3.6", "phpunit/phpunit": "^9.0", "neos/behat": "*", "league/flysystem-memory": "^3" + }, + + "config": { + "_comment": "We need to insert a vendor dir (even though composer install MUST NOT be run here) but so autoloading works for composer scripts", + "vendor-dir": "../Libraries", + "allow-plugins": { + "neos/composer-plugin": false, + "cweagans/composer-patches": false + } } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ebbd2ba29a9..12d96178fbb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3,7 +3,7 @@ parameters: - message: "#^The internal method \"Neos\\\\ContentRepository\\\\Core\\\\Projection\\\\Projections\\:\\:getClassNames\" is called\\.$#" count: 1 - path: Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php + path: Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php - message: "#^Method Neos\\\\Neos\\\\Controller\\\\Backend\\\\MenuHelper\\:\\:buildModuleList\\(\\) return type has no value type specified in iterable type array\\.$#"