diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ProjectionContentGraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ProjectionContentGraph.php index b8ff909b5cc..b1effb68e62 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ProjectionContentGraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ProjectionContentGraph.php @@ -427,7 +427,7 @@ public function findIngoingHierarchyRelationsForNode( } /** - * @return array indexed by the dimension space point hash: ['' => HierarchyRelation, ...] + * @return array */ public function findOutgoingHierarchyRelationsForNode( NodeRelationAnchorPoint $parentAnchorPoint, @@ -461,7 +461,7 @@ public function findOutgoingHierarchyRelationsForNode( } $relations = []; foreach ($rows as $row) { - $relations[(string)$row['dimensionspacepointhash']] = $this->mapRawDataToHierarchyRelation($row); + $relations[] = $this->mapRawDataToHierarchyRelation($row); } return $relations; } diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/02-RemoveNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/02-RemoveNodeAggregate_WithoutDimensions.feature index a3bda47101c..c8651f43cbf 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/02-RemoveNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/02-RemoveNodeAggregate_WithoutDimensions.feature @@ -122,3 +122,17 @@ Feature: Remove NodeAggregate And I expect node aggregate identifier "nodingers-cat" and node path "pet" to lead to node cs-identifier;nodingers-cat;{} And I expect this node to have no references And I expect node aggregate identifier "nodingers-kitten" and node path "pet/kitten" to lead to no node + + Scenario: Remove a node aggregate with descendants and expect all of them to be gone + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | + | nody-mc-nodeface | Neos.ContentRepository.Testing:Document | sir-david-nodenborough | child | + | younger-mc-nodeface | Neos.ContentRepository.Testing:Document | sir-david-nodenborough | younger-child | + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | nodeVariantSelectionStrategy | "allVariants" | + + Then I expect node aggregate identifier "sir-david-nodenborough" and node path "document" to lead to no node + And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child" to lead to no node + And I expect node aggregate identifier "younger-mc-nodeface" and node path "document/younger-child" to lead to no node diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/01-ChangeNodeAggregateType_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/01-ChangeNodeAggregateType_ConstraintChecks.feature new file mode 100644 index 00000000000..55148afde37 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/01-ChangeNodeAggregateType_ConstraintChecks.feature @@ -0,0 +1,207 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Change node aggregate type - basic error cases + + As a user of the CR I want to change the type of a node aggregate. + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de, gsw | gsw->de | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:AnotherRoot': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.ContentRepository.Testing:Tethered': [] + 'Neos.ContentRepository.Testing:Simple': [] + 'Neos.ContentRepository.Testing:AbstractNode': + abstract: true + 'Neos.ContentRepository.Testing:ParentNodeType': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeB': false + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeB': false + 'Neos.ContentRepository.Testing:GrandParentNodeType': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeType' + 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': [] + 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': [] + 'Neos.ContentRepository.Testing:NodeTypeA': + childNodes: + childOfTypeA: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' + properties: + text: + type: string + defaultValue: 'text' + 'Neos.ContentRepository.Testing:NodeTypeB': + childNodes: + childOfTypeB: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' + properties: + otherText: + type: string + defaultValue: 'otherText' + """ + 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 {"language":"de"} + 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 | nodeName | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | sir-david-nodenborough | parent | lady-eleonode-rootford | Neos.ContentRepository.Testing:ParentNodeType | {"tethered": "nodewyn-tetherton"} | + | nody-mc-nodeface | null | sir-david-nodenborough | Neos.ContentRepository.Testing:Simple | {} | + | nodimus-prime | null | nodewyn-tetherton | Neos.ContentRepository.Testing:Simple | {} | + + Scenario: Try to change the node aggregate type in a workspace that currently does not exist + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "non-existing" | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeA" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" + + Scenario: Try to change the node aggregate type in a workspace whose content stream is closed + When the command CloseContentStream is executed with payload: + | Key | Value | + | contentStreamId | "cs-identifier" | + And the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeA" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "ContentStreamIsClosed" + + Scenario: Try to change the type on a non-existing node aggregate + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "non-existing" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeA" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" + + Scenario: Try to change the type of a root node aggregate: + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | newNodeTypeName | "Neos.ContentRepository.Testing:AnotherRoot" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeAggregateIsRoot" + + Scenario: Try to change the type of a tethered node aggregate: + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nodewyn-tetherton" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeAggregateIsTethered" + + Scenario: Try to change a node aggregate to a non existing type + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:Undefined" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeTypeNotFound" + + Scenario: Try to change node aggregate to a root type: + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:AnotherRoot" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeTypeIsOfTypeRoot" + + Scenario: Try to change a node aggregate to an abstract type + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:AbstractNode" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeTypeIsAbstract" + + Scenario: Try to change to a node type disallowed by the parent node + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type disallowed by the parent node in a variant + When the command MoveNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | dimensionSpacePoint | {"language": "gsw"} | + | newParentNodeAggregateId | "lady-eleonode-rootford" | + | newSucceedingSiblingNodeAggregateId | null | + | relationDistributionStrategy | "scatter" | + And the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type that is not allowed by the grand parent aggregate inside a tethered parent aggregate + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nodimus-prime" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type that is not allowed by the grand parent aggregate inside a tethered parent aggregate in a variant + When the command MoveNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "nodimus-prime" | + | dimensionSpacePoint | {"language": "gsw"} | + | newParentNodeAggregateId | "lady-eleonode-rootford" | + | newSucceedingSiblingNodeAggregateId | null | + | relationDistributionStrategy | "scatter" | + And the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nodimus-prime" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change a node to a type with a tethered node declaration, whose name is already occupied by a non-tethered node + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | + | oddnode-tetherton | tethered | nody-mc-nodeface | Neos.ContentRepository.Testing:Simple | + And the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeAggregateIsUntethered" + + Scenario: Try to change a node to a type with a descendant tethered node declaration, whose name is already occupied by a non-tethered node (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | + | oddnode-tetherton | tethered | nodewyn-tetherton | Neos.ContentRepository.Testing:Simple | + And the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeType" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeAggregateIsUntethered" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/02-ChangeNodeAggregateType_DeleteStrategy.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/02-ChangeNodeAggregateType_DeleteStrategy.feature new file mode 100644 index 00000000000..f73a3a112b7 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/02-ChangeNodeAggregateType_DeleteStrategy.feature @@ -0,0 +1,437 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Change node aggregate type - behavior of DELETE strategy + + As a user of the CR I want to change the type of a node aggregate. + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de, gsw | gsw->de | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': [] + 'Neos.ContentRepository.Testing:NodeTypeCCollection': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + 'Neos.ContentRepository.Testing:NodeTypeB': FALSE + 'Neos.ContentRepository.Testing:ParentNodeType': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeB': FALSE + 'Neos.ContentRepository.Testing:ParentNodeTypeB': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + 'Neos.ContentRepository.Testing:ParentNodeTypeC': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:NodeTypeCCollection' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + properties: + 'parentCText': + defaultValue: 'parentCTextDefault' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeA': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeType' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeB': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeTypeB' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeC': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeTypeC' + 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': [] + 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': [] + 'Neos.ContentRepository.Testing:NodeTypeA': + childNodes: + child-of-type-a: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' + properties: + text: + type: string + defaultValue: 'text' + 'Neos.ContentRepository.Testing:NodeTypeB': + childNodes: + # !! NodeTypeB has BOTH childOfTypeA AND childOfTypeB as tethered child nodes... + child-of-type-b: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' + child-of-type-a: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' + properties: + otherText: + type: string + defaultValue: 'otherText' + constraints: + nodeTypes: + # both of these types are forbidden. + 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': false + 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': false + """ + 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 {"language":"de"} + 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 | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | tetheredDescendantNodeAggregateIds | + | sir-david-nodenborough | {"language":"de"} | lady-eleonode-rootford | parent | Neos.ContentRepository.Testing:ParentNodeType | {"tethered": "tethered-nodenborough"} | + + Scenario: Change to a node type that disallows already present children with the delete conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | + | nody-mc-nodeface | {"language":"de"} | sir-david-nodenborough | parent | Neos.ContentRepository.Testing:NodeTypeA | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | + | strategy | "delete" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + + # the child nodes have been removed + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + Scenario: Change to a node type that disallows already present grandchildren with the delete conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | tetheredDescendantNodeAggregateIds | + | parent2-na | {"language":"de"} | lady-eleonode-rootford | parent2 | Neos.ContentRepository.Testing:ParentNodeType | {"tethered": "tethered-child"} | + | nody-mc-nodeface | {"language":"de"} | tethered-child | null | Neos.ContentRepository.Testing:NodeTypeA | {} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "parent2-na" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | + | strategy | "delete" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "parent2-na" to lead to node cs-identifier;parent2-na;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + + # the child nodes still exist + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "tethered-child" to lead to node cs-identifier;tethered-child;{"language":"de"} + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "tethered-child" to lead to node cs-identifier;tethered-child;{"language":"de"} + + # the grandchild nodes have been removed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + Scenario: Change to a node type with a differently typed tethered child that disallows already present (grand)children with the DELETE conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | parent2 | Neos.ContentRepository.Testing:ParentNodeType | {} | {"tethered": "nodewyn-tetherton"} | + | nodimus-prime | {"language":"de"} | nodewyn-tetherton | null | Neos.ContentRepository.Testing:NodeTypeA | {} | {} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeC" | + | strategy | "delete" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + + # the tethered child nodes still exist and are now properly typed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" + + # the now disallowed grandchild nodes have been removed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodimus-prime" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodimus-prime" to lead to no node + + Scenario: Change to a node type whose tethered child that is also type-changed disallows already present children with the DELETE conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeA | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodewyn-tetherton | Neos.ContentRepository.Testing:NodeTypeA | {} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeB" | + | strategy | "delete" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeB" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeB" + + # the tethered child nodes still exist and are now properly typed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + + # the now disallowed grandchild nodes have been removed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + + Scenario: Change to a node type whose tethered child that is also type-changed disallows already present (grand)children with the DELETE conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeA | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodimer-tetherton | Neos.ContentRepository.Testing:NodeTypeA | {"child-of-type-a": "a-tetherton"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeB" | + | strategy | "delete" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeB" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeB" + + # the tethered child nodes still exist and are now properly typed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" + + # the now disallowed grandchild nodes and their descendants have been removed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "a-tetherton" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "a-tetherton" to lead to no node + + Scenario: Change to a node type whose tethered child that is also type-changed has a differently typed tethered child that disallows already present grandchildren with the DELETE conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeB | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodimer-tetherton | Neos.ContentRepository.Testing:NodeTypeB | {"child-of-type-a": "nodingers-tethered-a", "child-of-type-b": "nodingers-tethered-b"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeC" | + | strategy | "delete" | + | tetheredDescendantNodeAggregateIds | {"tethered/tethered/tethered": "nodimus-tetherton"} | + + Then I expect exactly 16 events to be published on stream "ContentStream:cs-identifier" + And event at index 10 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeC" | + And event at index 11 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodewyn-tetherton" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeC" | + And event at index 12 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodewyn-tetherton" | + | originDimensionSpacePoint | {"language":"de"} | + | affectedDimensionSpacePoints | [{"language":"de"},{"language":"gsw"}] | + | propertyValues | {"parentCText":{"value":"parentCTextDefault","type":"string"}} | + | propertyValues | {"parentCText":{"value":"parentCTextDefault","type":"string"}} | + | propertiesToUnset | [] | + And event at index 13 is of type "NodeAggregateWasRemoved" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodingers-cat" | + | affectedOccupiedDimensionSpacePoints | [{"language":"de"},{"language":"gsw"}] | + | affectedCoveredDimensionSpacePoints | [{"language":"de"},{"language":"gsw"}] | + | removalAttachmentPoint | null | + And event at index 14 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimer-tetherton" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeCCollection" | + And event at index 15 is of type "NodeAggregateWithNodeWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimus-tetherton" | + | nodeTypeName | "Neos.ContentRepository.Testing:Tethered" | + | originDimensionSpacePoint | {"language":"de"} | + | succeedingSiblingsForCoverage | [{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null}] | + | parentNodeAggregateId | "nodimer-tetherton" | + | nodeName | "tethered" | + | initialPropertyValues | [] | + | nodeAggregateClassification | "tethered" | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeC" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeC" + + # the tethered child nodes still exist and are now properly typed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + + # the tethered grandchild nodes still exist and are now properly typed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" + + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodimus-tetherton" to lead to node cs-identifier;nodimus-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:Tethered" + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodimus-tetherton" to lead to node cs-identifier;nodimus-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:Tethered" + + # the now disallowed grandchild nodes and their descendants have been removed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodingers-cat" to lead to no node + + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodingers-tethered-a" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodingers-tethered-a" to lead to no node + + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodingers-tethered-b" to lead to no node + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodingers-tethered-b" to lead to no node + + + Scenario: Change node type successfully + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nodea-identifier-de | {"language":"de"} | lady-eleonode-rootford | null | Neos.ContentRepository.Testing:NodeTypeA | { "child-of-type-a": "child-of-type-a-id"} | + + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nodea-identifier-de" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nodea-identifier-de" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "delete" | + | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "child-of-type-b-id"} | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"gsw"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | child-of-type-a | cs-identifier;child-of-type-a-id;{"language":"gsw"} | + | child-of-type-b | cs-identifier;child-of-type-b-id;{"language":"gsw"} | + + Scenario: When changing node type, a non-allowed tethered node should stay (Tethered nodes are not taken into account when checking constraints) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nodea-identifier-de | {"language":"de"} | lady-eleonode-rootford | null | Neos.ContentRepository.Testing:NodeTypeA | { "child-of-type-a": "child-of-type-a-id"} | + + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nodea-identifier-de" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nodea-identifier-de" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "delete" | + | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "child-of-type-b-id"} | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" + + # BOTH tethered child nodes still need to exist + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | child-of-type-a | cs-identifier;child-of-type-a-id;{"language":"de"} | + | child-of-type-b | cs-identifier;child-of-type-b-id;{"language":"de"} | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/03-ChangeNodeAggregateType_HappyPathStrategy.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/03-ChangeNodeAggregateType_HappyPathStrategy.feature new file mode 100644 index 00000000000..fcb23133abc --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/11-NodeTypeChange/03-ChangeNodeAggregateType_HappyPathStrategy.feature @@ -0,0 +1,432 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Change node aggregate type - behavior of HAPPYPATH strategy + + As a user of the CR I want to change the type of a node aggregate. + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de, gsw | gsw->de | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Tethered': [] + 'Neos.ContentRepository.Testing:NodeTypeCCollection': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + 'Neos.ContentRepository.Testing:NodeTypeB': FALSE + 'Neos.ContentRepository.Testing:ParentNodeType': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeB': FALSE + 'Neos.ContentRepository.Testing:ParentNodeTypeB': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:Tethered' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + 'Neos.ContentRepository.Testing:ParentNodeTypeC': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:NodeTypeCCollection' + constraints: + nodeTypes: + '*': TRUE + 'Neos.ContentRepository.Testing:NodeTypeA': FALSE + properties: + 'parentCText': + defaultValue: 'parentCTextDefault' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeA': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeType' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeB': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeTypeB' + 'Neos.ContentRepository.Testing:GrandParentNodeTypeC': + childNodes: + tethered: + type: 'Neos.ContentRepository.Testing:ParentNodeTypeC' + 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': + properties: + defaultTextA: + type: string + defaultValue: 'defaultTextA' + commonDefaultText: + type: string + defaultValue: 'commonDefaultTextA' + 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': + properties: + defaultTextB: + type: string + defaultValue: 'defaultTextB' + commonDefaultText: + type: string + defaultValue: 'commonDefaultTextB' + 'Neos.ContentRepository.Testing:NodeTypeA': + childNodes: + child-of-type-a: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' + properties: + defaultTextA: + type: string + defaultValue: 'defaultTextA' + commonDefaultText: + type: string + defaultValue: 'commonDefaultTextA' + 'Neos.ContentRepository.Testing:NodeTypeB': + childNodes: + child-of-type-b: + type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' + properties: + defaultTextB: + type: string + defaultValue: 'defaultTextB' + commonDefaultText: + type: string + defaultValue: 'commonDefaultTextB' + """ + 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 {"language":"de"} + 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 | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | + | sir-david-nodenborough | {"language":"de"} | lady-eleonode-rootford | parent | Neos.ContentRepository.Testing:ParentNodeType | + + Scenario: Try to change to a node type that disallows already present children with the HAPPYPATH conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | + | nody-mc-nodeface | {"language":"de"} | sir-david-nodenborough | null | Neos.ContentRepository.Testing:NodeTypeA | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type that disallows already present grandchildren with the HAPPYPATH conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | parent2-na | {"language":"de"} | lady-eleonode-rootford | parent2 | Neos.ContentRepository.Testing:ParentNodeType | {} | {"tethered": "nodewyn-tetherton"} | + | nody-mc-nodeface | {"language":"de"} | nodewyn-tetherton | null | Neos.ContentRepository.Testing:NodeTypeA | {} | {} | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "parent2-na" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type with a differently typed tethered child that disallows already present (grand)children with the HAPPYPATH conflict resolution strategy + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeName | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | parent2 | Neos.ContentRepository.Testing:ParentNodeType | {} | {"tethered": "nodewyn-tetherton"} | + | nodimus-prime | {"language":"de"} | nodewyn-tetherton | null | Neos.ContentRepository.Testing:NodeTypeA | {} | {} | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeC" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type whose tethered child that is also type-changed disallows already present children with the HAPPYPATH conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeA | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodewyn-tetherton | Neos.ContentRepository.Testing:NodeTypeA | {} | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type whose tethered child that is also type-changed disallows already present (grand)children with the HAPPYPATH conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeA | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodimer-tetherton | Neos.ContentRepository.Testing:NodeTypeA | {} | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeB" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + Scenario: Try to change to a node type whose tethered child that is also type-changed has a differently typed tethered child that disallows already present grandchildren with the HAPPYPATH conflict resolution strategy (recursive case) + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:GrandParentNodeTypeB | {"tethered": "nodewyn-tetherton", "tethered/tethered": "nodimer-tetherton"} | + | nodingers-cat | {"language": "de"} | nodimer-tetherton | Neos.ContentRepository.Testing:NodeTypeB | {} | + + When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeC" | + | strategy | "happypath" | + Then the last command should have thrown an exception of type "NodeConstraintException" + + + Scenario: Change node type with tethered children + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeTypeA | {} | { "child-of-type-a": "nodewyn-tetherton"} | + + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + | strategy | "happypath" | + | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "nodimer-tetherton"} | + + Then I expect exactly 13 events to be published on stream "ContentStream:cs-identifier" + And event at index 8 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | + And event at index 9 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"gsw"} | + | affectedDimensionSpacePoints | [{"language":"gsw"}] | + | propertyValues | {"defaultTextB":{"value":"defaultTextB","type":"string"}} | + | propertiesToUnset | ["defaultTextA"] | + And event at index 10 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"de"} | + | affectedDimensionSpacePoints | [{"language":"de"}] | + | propertyValues | {"defaultTextB":{"value":"defaultTextB","type":"string"}} | + | propertiesToUnset | ["defaultTextA"] | + And event at index 11 is of type "NodeAggregateWithNodeWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimer-tetherton" | + | nodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeB" | + | originDimensionSpacePoint | {"language":"gsw"} | + | succeedingSiblingsForCoverage | [{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null}] | + | parentNodeAggregateId | "nody-mc-nodeface" | + | nodeName | "child-of-type-b" | + | initialPropertyValues | {"defaultTextB":{"value":"defaultTextB","type":"string"},"commonDefaultText":{"value":"commonDefaultTextB","type":"string"}} | + | nodeAggregateClassification | "tethered" | + And event at index 12 is of type "NodeGeneralizationVariantWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimer-tetherton" | + | sourceOrigin | {"language":"gsw"} | + | generalizationOrigin | {"language":"de"} | + | variantSucceedingSiblings | [{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null}] | + + # the type has changed + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" + And I expect this node to have the following properties: + | Key | Value | + # Not modified because it was already present + | commonDefaultText | "commonDefaultTextA" | + | defaultTextB | "defaultTextB" | + # defaultTextA missing because it does not exist in NodeTypeB + + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + # the tethered child of the old node type has not been removed with this strategy. + | child-of-type-a | cs-identifier;nodewyn-tetherton;{"language":"de"} | + | child-of-type-b | cs-identifier;nodimer-tetherton;{"language":"de"} | + + And I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to have the following properties: + | Key | Value | + | commonDefaultText | "commonDefaultTextA" | + | defaultTextA | "defaultTextA" | + + And I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"de"} + And I expect this node to have the following properties: + | Key | Value | + | commonDefaultText | "commonDefaultTextB" | + | defaultTextB | "defaultTextB" | + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"gsw"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" + + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + # the tethered child of the old node type has not been removed with this strategy. + | child-of-type-a | cs-identifier;nodewyn-tetherton;{"language":"gsw"} | + | child-of-type-b | cs-identifier;nodimer-tetherton;{"language":"gsw"} | + + And I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"gsw"} + And I expect this node to have the following properties: + | Key | Value | + | commonDefaultText | "commonDefaultTextA" | + | defaultTextA | "defaultTextA" | + + And I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"gsw"} + And I expect this node to have the following properties: + | Key | Value | + | commonDefaultText | "commonDefaultTextB" | + | defaultTextB | "defaultTextB" | + + Scenario: Change node type, recursively also changing the types of tethered descendants + When the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | originDimensionSpacePoint | parentNodeAggregateId | nodeTypeName | initialPropertyValues | tetheredDescendantNodeAggregateIds | + | nody-mc-nodeface | {"language":"de"} | lady-eleonode-rootford | Neos.ContentRepository.Testing:ParentNodeType | {} | {"tethered": "nodewyn-tetherton"} | + + When the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + When the command ChangeNodeAggregateType is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeC" | + | strategy | "happypath" | + | tetheredDescendantNodeAggregateIds | {"tethered/tethered": "nodimer-tetherton", "tethered/tethered/tethered": "nodimus-tetherton"} | + + Then I expect exactly 16 events to be published on stream "ContentStream:cs-identifier" + And event at index 8 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nody-mc-nodeface" | + | newNodeTypeName | "Neos.ContentRepository.Testing:GrandParentNodeTypeC" | + And event at index 9 is of type "NodeAggregateTypeWasChanged" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodewyn-tetherton" | + | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeC" | + And event at index 10 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodewyn-tetherton" | + | originDimensionSpacePoint | {"language":"de"} | + | affectedDimensionSpacePoints | [{"language":"de"}] | + | propertyValues | {"parentCText":{"value":"parentCTextDefault","type":"string"}} | + | propertiesToUnset | [] | + And event at index 11 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodewyn-tetherton" | + | originDimensionSpacePoint | {"language":"gsw"} | + | affectedDimensionSpacePoints | [{"language":"gsw"}] | + | propertyValues | {"parentCText":{"value":"parentCTextDefault","type":"string"}} | + | propertiesToUnset | [] | + And event at index 12 is of type "NodeAggregateWithNodeWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimer-tetherton" | + | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeCCollection" | + | originDimensionSpacePoint | {"language":"de"} | + | succeedingSiblingsForCoverage | [{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null}] | + | parentNodeAggregateId | "nodewyn-tetherton" | + | nodeName | "tethered" | + | initialPropertyValues | [] | + | nodeAggregateClassification | "tethered" | + And event at index 13 is of type "NodeSpecializationVariantWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimer-tetherton" | + | sourceOrigin | {"language":"de"} | + | specializationOrigin | {"language":"gsw"} | + | specializationSiblings | [{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null}] | + And event at index 14 is of type "NodeAggregateWithNodeWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimus-tetherton" | + | nodeTypeName | "Neos.ContentRepository.Testing:Tethered" | + | originDimensionSpacePoint | {"language":"de"} | + | succeedingSiblingsForCoverage | [{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null}] | + | parentNodeAggregateId | "nodimer-tetherton" | + | nodeName | "tethered" | + | initialPropertyValues | [] | + | nodeAggregateClassification | "tethered" | + And event at index 15 is of type "NodeSpecializationVariantWasCreated" with payload: + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "nodimus-tetherton" | + | sourceOrigin | {"language":"de"} | + | specializationOrigin | {"language":"gsw"} | + | specializationSiblings | [{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null}] | + + When I am in workspace "live" and dimension space point {"language":"de"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeC" + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | tethered | cs-identifier;nodewyn-tetherton;{"language":"de"} | + + And I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | tethered | cs-identifier;nodimer-tetherton;{"language":"de"} | + + And I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"de"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" + + When I am in workspace "live" and dimension space point {"language":"gsw"} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{"language":"gsw"} + And I expect this node to be of type "Neos.ContentRepository.Testing:GrandParentNodeTypeC" + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | tethered | cs-identifier;nodewyn-tetherton;{"language":"gsw"} | + + And I expect node aggregate identifier "nodewyn-tetherton" to lead to node cs-identifier;nodewyn-tetherton;{"language":"gsw"} + And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeC" + And I expect this node to have the following child nodes: + | Name | NodeDiscriminator | + | tethered | cs-identifier;nodimer-tetherton;{"language":"gsw"} | + + And I expect node aggregate identifier "nodimer-tetherton" to lead to node cs-identifier;nodimer-tetherton;{"language":"gsw"} + And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeCCollection" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_BasicErrorCases.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_BasicErrorCases.feature deleted file mode 100644 index 353ac66e2b9..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_BasicErrorCases.feature +++ /dev/null @@ -1,146 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Change node aggregate type - basic error cases - - As a user of the CR I want to change the type of a node aggregate. - - Background: - Given using the following content dimensions: - | Identifier | Values | Generalizations | - | language | de, gsw | gsw->de | - And using the following node types: - """yaml - 'Neos.ContentRepository.Testing:AutoCreated': [] - 'Neos.ContentRepository.Testing:ParentNodeType': - childNodes: - autocreated: - type: 'Neos.ContentRepository.Testing:AutoCreated' - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeB': FALSE - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeB': FALSE - 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': [] - 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': [] - 'Neos.ContentRepository.Testing:NodeTypeA': - childNodes: - childOfTypeA: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' - properties: - text: - type: string - defaultValue: 'text' - 'Neos.ContentRepository.Testing:NodeTypeB': - childNodes: - childOfTypeB: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' - properties: - otherText: - type: string - defaultValue: 'otherText' - """ - 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 {"language":"de"} - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "lady-eleonode-rootford" | - | nodeTypeName | "Neos.ContentRepository:Root" | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent" | - | initialPropertyValues | {} | - - - Scenario: Try to change the node aggregate type on a non-existing content stream - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | workspaceName | "non-existing" | - | nodeAggregateId | "sir-david-nodenborough" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeA" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" - - Scenario: Try to change the type on a non-existing node aggregate - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ChildOfNodeTypeA" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" - - Scenario: Try to change a node aggregate to a non existing type - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | newNodeTypeName | "Neos.ContentRepository.Testing:Undefined" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeTypeNotFound" - - Scenario: Try to change to a node type disallowed by the parent node - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | parentNodeAggregateId | "sir-david-nodenborough" | - | nodeName | "parent" | - | initialPropertyValues | {} | - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeConstraintException" - - Scenario: Try to change to a node type that is not allowed by the grand parent aggregate inside an autocreated parent aggregate - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent2" | - | initialPropertyValues | {} | - | tetheredDescendantNodeAggregateIds | {"autocreated": "autocreated-child"} | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "autocreated-child" | - | initialPropertyValues | {} | - - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeConstraintException" - - Scenario: Try to change the node type of an tethered child node: - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent2" | - | initialPropertyValues | {} | - | tetheredDescendantNodeAggregateIds | {"autocreated": "nody-mc-nodeface"} | - - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeAggregateIsTethered" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_DeleteStrategy.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_DeleteStrategy.feature deleted file mode 100644 index cc7a91baf52..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_DeleteStrategy.feature +++ /dev/null @@ -1,215 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Change node aggregate type - behavior of DELETE strategy - - As a user of the CR I want to change the type of a node aggregate. - - Background: - Given using the following content dimensions: - | Identifier | Values | Generalizations | - | language | de, gsw | gsw->de | - And using the following node types: - """yaml - 'Neos.ContentRepository.Testing:AutoCreated': [] - 'Neos.ContentRepository.Testing:ParentNodeType': - childNodes: - autocreated: - type: 'Neos.ContentRepository.Testing:AutoCreated' - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeB': FALSE - 'Neos.ContentRepository.Testing:ParentNodeTypeB': - childNodes: - autocreated: - type: 'Neos.ContentRepository.Testing:AutoCreated' - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeA': FALSE - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeA': FALSE - 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': [] - 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': [] - 'Neos.ContentRepository.Testing:NodeTypeA': - childNodes: - child-of-type-a: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' - properties: - text: - type: string - defaultValue: 'text' - 'Neos.ContentRepository.Testing:NodeTypeB': - childNodes: - # !! NodeTypeB has BOTH childOfTypeA AND childOfTypeB as tethered child nodes... - child-of-type-b: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' - child-of-type-a: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' - properties: - otherText: - type: string - defaultValue: 'otherText' - constraints: - nodeTypes: - # both of these types are forbidden. - 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': false - 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': false - """ - 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 {"language":"de"} - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "lady-eleonode-rootford" | - | nodeTypeName | "Neos.ContentRepository:Root" | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent" | - | initialPropertyValues | {} | - - - Scenario: Try to change to a node type that disallows already present children with the delete conflict resolution strategy - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "sir-david-nodenborough" | - - When the command ChangeNodeAggregateType is executed with payload: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | - | strategy | "delete" | - - # the type has changed - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{"language":"de"} - And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" - - # the child nodes have been removed - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node - When I am in workspace "live" and dimension space point {"language":"gsw"} - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node - - Scenario: Try to change to a node type that disallows already present grandchildren with the delete conflict resolution strategy - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent2" | - | tetheredDescendantNodeAggregateIds | {"autocreated": "autocreated-child"} | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "autocreated-child" | - | initialPropertyValues | {} | - - When the command ChangeNodeAggregateType is executed with payload: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | - | strategy | "delete" | - - # the type has changed - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "parent2-na" to lead to node cs-identifier;parent2-na;{"language":"de"} - And I expect this node to be of type "Neos.ContentRepository.Testing:ParentNodeTypeB" - - # the child nodes still exist - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "autocreated-child" to lead to node cs-identifier;autocreated-child;{"language":"de"} - When I am in workspace "live" and dimension space point {"language":"gsw"} - Then I expect node aggregate identifier "autocreated-child" to lead to node cs-identifier;autocreated-child;{"language":"de"} - - # the grandchild nodes have been removed - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node - When I am in workspace "live" and dimension space point {"language":"gsw"} - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node - - - Scenario: Change node type successfully - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {} | - | tetheredDescendantNodeAggregateIds | { "child-of-type-a": "child-of-type-a-id"} | - - When the command CreateNodeVariant is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | sourceOrigin | {"language":"de"} | - | targetOrigin | {"language":"gsw"} | - - When the command ChangeNodeAggregateType is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | - | strategy | "delete" | - | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "child-of-type-b-id"} | - - # the type has changed - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"de"} - And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" - - When I am in workspace "live" and dimension space point {"language":"gsw"} - Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"gsw"} - And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" - And I expect this node to have the following child nodes: - | Name | NodeDiscriminator | - | child-of-type-a | cs-identifier;child-of-type-a-id;{"language":"gsw"} | - | child-of-type-b | cs-identifier;child-of-type-b-id;{"language":"gsw"} | - - Scenario: When changing node type, a non-allowed tethered node should stay (Tethered nodes are not taken into account when checking constraints) - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {} | - | tetheredDescendantNodeAggregateIds | { "child-of-type-a": "child-of-type-a-id"} | - - When the command CreateNodeVariant is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | sourceOrigin | {"language":"de"} | - | targetOrigin | {"language":"gsw"} | - - When the command ChangeNodeAggregateType is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | - | strategy | "delete" | - | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "child-of-type-b-id"} | - - # the type has changed - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"de"} - And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" - - # BOTH tethered child nodes still need to exist - And I expect this node to have the following child nodes: - | Name | NodeDiscriminator | - | child-of-type-a | cs-identifier;child-of-type-a-id;{"language":"de"} | - | child-of-type-b | cs-identifier;child-of-type-b-id;{"language":"de"} | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_HappyPathStrategy.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_HappyPathStrategy.feature deleted file mode 100644 index 739cf1b7b2e..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRetyping/ChangeNodeAggregateType_HappyPathStrategy.feature +++ /dev/null @@ -1,154 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Change node aggregate type - behavior of HAPPYPATH strategy - - As a user of the CR I want to change the type of a node aggregate. - - # @todo change type to a type with a tethered child with the same name as one of the original one's but of different type - Background: - Given using the following content dimensions: - | Identifier | Values | Generalizations | - | language | de, gsw | gsw->de | - And using the following node types: - """yaml - 'Neos.ContentRepository.Testing:AutoCreated': [] - 'Neos.ContentRepository.Testing:ParentNodeType': - childNodes: - autocreated: - type: 'Neos.ContentRepository.Testing:AutoCreated' - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeB': FALSE - 'Neos.ContentRepository.Testing:ParentNodeTypeB': - childNodes: - autocreated: - type: 'Neos.ContentRepository.Testing:AutoCreated' - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeA': FALSE - constraints: - nodeTypes: - '*': TRUE - 'Neos.ContentRepository.Testing:NodeTypeA': FALSE - 'Neos.ContentRepository.Testing:ChildOfNodeTypeA': [] - 'Neos.ContentRepository.Testing:ChildOfNodeTypeB': [] - 'Neos.ContentRepository.Testing:NodeTypeA': - childNodes: - child-of-type-a: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeA' - properties: - text: - type: string - defaultValue: 'text' - 'Neos.ContentRepository.Testing:NodeTypeB': - childNodes: - child-of-type-b: - type: 'Neos.ContentRepository.Testing:ChildOfNodeTypeB' - properties: - otherText: - type: string - defaultValue: 'otherText' - """ - 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 {"language":"de"} - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "lady-eleonode-rootford" | - | nodeTypeName | "Neos.ContentRepository:Root" | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent" | - | initialPropertyValues | {} | - - - Scenario: Try to change to a node type that disallows already present children with the HAPPYPATH conflict resolution strategy - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "sir-david-nodenborough" | - - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "sir-david-nodenborough" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeConstraintException" - - Scenario: Try to change to a node type that disallows already present grandchildren with the HAPPYPATH conflict resolution strategy - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | nodeTypeName | "Neos.ContentRepository.Testing:ParentNodeType" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | nodeName | "parent2" | - | tetheredDescendantNodeAggregateIds | {"autocreated": "autocreated-child"} | - - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nody-mc-nodeface" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "autocreated-child" | - | initialPropertyValues | {} | - - When the command ChangeNodeAggregateType is executed with payload and exceptions are caught: - | Key | Value | - | nodeAggregateId | "parent2-na" | - | newNodeTypeName | "Neos.ContentRepository.Testing:ParentNodeTypeB" | - | strategy | "happypath" | - Then the last command should have thrown an exception of type "NodeConstraintException" - - Scenario: Change node type successfully - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | nodeTypeName | "Neos.ContentRepository.Testing:NodeTypeA" | - | originDimensionSpacePoint | {"language":"de"} | - | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {} | - | tetheredDescendantNodeAggregateIds | { "child-of-type-a": "child-of-type-a-id"} | - - When the command CreateNodeVariant is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | sourceOrigin | {"language":"de"} | - | targetOrigin | {"language":"gsw"} | - - When the command ChangeNodeAggregateType is executed with payload: - | Key | Value | - | nodeAggregateId | "nodea-identifier-de" | - | newNodeTypeName | "Neos.ContentRepository.Testing:NodeTypeB" | - | strategy | "happypath" | - | tetheredDescendantNodeAggregateIds | { "child-of-type-b": "child-of-type-b-id"} | - - # the type has changed - When I am in workspace "live" and dimension space point {"language":"de"} - Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"de"} - And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" - - When I am in workspace "live" and dimension space point {"language":"gsw"} - Then I expect node aggregate identifier "nodea-identifier-de" to lead to node cs-identifier;nodea-identifier-de;{"language":"gsw"} - And I expect this node to be of type "Neos.ContentRepository.Testing:NodeTypeB" - - # the old "childOfTypeA" has not been removed with this strategy. - And I expect this node to have the following child nodes: - | Name | NodeDiscriminator | - | child-of-type-a | cs-identifier;child-of-type-a-id;{"language":"gsw"} | - | child-of-type-b | cs-identifier;child-of-type-b-id;{"language":"gsw"} | - -# #missing default property values of target type must be set -# #extra properties of source target type must be removed (TBD) diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php index 428ac575ee8..1dccfbe42d2 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php @@ -45,6 +45,7 @@ use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateIsNoSibling; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateIsRoot; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateIsTethered; +use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateIsUntethered; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregatesTypeIsAmbiguous; use Neos\ContentRepository\Core\SharedModel\Exception\NodeConstraintException; use Neos\ContentRepository\Core\SharedModel\Exception\NodeNameIsAlreadyCovered; @@ -194,6 +195,30 @@ protected function requireTetheredDescendantNodeTypesToNotBeOfTypeRoot(NodeType } } + /** + * @throws NodeAggregateIsUntethered + */ + protected function requireExistingDeclaredTetheredDescendantsToBeTethered( + ContentGraphInterface $contentGraph, + NodeAggregate $nodeAggregate, + NodeType $nodeType + ): void { + foreach ($nodeType->tetheredNodeTypeDefinitions as $tetheredNodeTypeDefinition) { + $tetheredNodeAggregate = $contentGraph->findChildNodeAggregateByName($nodeAggregate->nodeAggregateId, $tetheredNodeTypeDefinition->name); + if ($tetheredNodeAggregate === null) { + continue; + } + if (!$tetheredNodeAggregate->classification->isTethered()) { + throw new NodeAggregateIsUntethered( + 'Node name ' . $tetheredNodeTypeDefinition->name->value . ' is occupied by untethered node aggregate ' . $tetheredNodeAggregate->nodeAggregateId->value, + 1729592202 + ); + } + $tetheredNodeType = $this->requireNodeType($tetheredNodeTypeDefinition->nodeTypeName); + $this->requireExistingDeclaredTetheredDescendantsToBeTethered($contentGraph, $tetheredNodeAggregate, $tetheredNodeType); + } + } + protected function requireNodeTypeToDeclareProperty(NodeTypeName $nodeTypeName, PropertyName $propertyName): void { $nodeType = $this->requireNodeType($nodeTypeName); diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeTypeChangeInternals.php b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeTypeChangeInternals.php new file mode 100644 index 00000000000..31d944fe121 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeTypeChangeInternals.php @@ -0,0 +1,224 @@ +findChildNodeAggregates( + $nodeAggregate->nodeAggregateId + ); + foreach ($childNodeAggregates as $childNodeAggregate) { + /* @var $childNodeAggregate NodeAggregate */ + // the "parent" of the $childNode is $node; so we use $newNodeType + // (the target node type of $node after the operation) here. + if ( + !$childNodeAggregate->classification->isTethered() + && !$this->areNodeTypeConstraintsImposedByParentValid( + $newNodeType, + $this->requireNodeType($childNodeAggregate->nodeTypeName) + ) + // descendants might be disallowed by both parent and grandparent after NodeTypeChange, but must be deleted only once + && !$alreadyRemovedNodeAggregateIds->contain($childNodeAggregate->nodeAggregateId) + ) { + // this aggregate (or parts thereof) are DISALLOWED according to constraints. + // We now need to find out which edges we need to remove, + $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( + $contentGraph, + $nodeAggregate, + $childNodeAggregate + ); + // AND REMOVE THEM + $events[] = $this->removeNodeInDimensionSpacePointSet( + $contentGraph, + $childNodeAggregate, + $dimensionSpacePointsToBeRemoved, + ); + $alreadyRemovedNodeAggregateIds = $alreadyRemovedNodeAggregateIds->merge( + NodeAggregateIds::create($childNodeAggregate->nodeAggregateId) + ); + } + + // we do not need to test for grandparents here, as we did not modify the grandparents. + // Thus, if it was allowed before, it is allowed now. + // additionally, we need to look one level down to the grandchildren as well + // - as it could happen that these are affected by our constraint checks as well. + $grandchildNodeAggregates = $contentGraph->findChildNodeAggregates($childNodeAggregate->nodeAggregateId); + foreach ($grandchildNodeAggregates as $grandchildNodeAggregate) { + // we do not need to test for the parent of grandchild (=child), + // as we do not change the child's node type. + // we however need to check for the grandparent node type. + if ( + $childNodeAggregate->nodeName !== null + && !$this->areNodeTypeConstraintsImposedByGrandparentValid( + $newNodeType, // the grandparent node type changes + $childNodeAggregate->nodeName, + $this->requireNodeType($grandchildNodeAggregate->nodeTypeName) + ) + // descendants might be disallowed by both parent and grandparent after NodeTypeChange, but must be deleted only once + && !$alreadyRemovedNodeAggregateIds->contain($grandchildNodeAggregate->nodeAggregateId) + ) { + // this aggregate (or parts thereof) are DISALLOWED according to constraints. + // We now need to find out which edges we need to remove, + $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( + $contentGraph, + $childNodeAggregate, + $grandchildNodeAggregate + ); + // AND REMOVE THEM + $events[] = $this->removeNodeInDimensionSpacePointSet( + $contentGraph, + $grandchildNodeAggregate, + $dimensionSpacePointsToBeRemoved, + ); + $alreadyRemovedNodeAggregateIds = $alreadyRemovedNodeAggregateIds->merge( + NodeAggregateIds::create($grandchildNodeAggregate->nodeAggregateId) + ); + } + } + } + + return Events::fromArray($events); + } + + private function deleteObsoleteTetheredNodesWhenChangingNodeType( + ContentGraphInterface $contentGraph, + NodeAggregate $nodeAggregate, + NodeType $newNodeType, + NodeAggregateIds &$alreadyRemovedNodeAggregateIds, + ): Events { + $events = []; + // find disallowed tethered nodes + $tetheredNodeAggregates = $contentGraph->findTetheredChildNodeAggregates($nodeAggregate->nodeAggregateId); + + foreach ($tetheredNodeAggregates as $tetheredNodeAggregate) { + /* @var $tetheredNodeAggregate NodeAggregate */ + if ( + $tetheredNodeAggregate->nodeName !== null + && !$newNodeType->tetheredNodeTypeDefinitions->contain($tetheredNodeAggregate->nodeName) + && !$alreadyRemovedNodeAggregateIds->contain($tetheredNodeAggregate->nodeAggregateId) + ) { + // this aggregate (or parts thereof) are DISALLOWED according to constraints. + // We now need to find out which edges we need to remove, + $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( + $contentGraph, + $nodeAggregate, + $tetheredNodeAggregate + ); + // AND REMOVE THEM + $events[] = $this->removeNodeInDimensionSpacePointSet( + $contentGraph, + $tetheredNodeAggregate, + $dimensionSpacePointsToBeRemoved, + ); + $alreadyRemovedNodeAggregateIds = $alreadyRemovedNodeAggregateIds->merge( + NodeAggregateIds::create($tetheredNodeAggregate->nodeAggregateId) + ); + } + } + + return Events::fromArray($events); + } + + /** + * Find all dimension space points which connect two Node Aggregates. + * + * After we found wrong node type constraints between two aggregates, we need to remove exactly the edges where the + * aggregates are connected as parent and child. + * + * Example: In this case, we want to find exactly the bold edge between PAR1 and A. + * + * ╔══════╗ <------ $parentNodeAggregate (PAR1) + * ┌──────┐ ║ PAR1 ║ ┌──────┐ + * │ PAR3 │ ╚══════╝ │ PAR2 │ + * └──────┘ ║ └──────┘ + * ╲ ║ ╱ + * ╲ ║ ╱ + * ▼──▼──┐ ┌───▼─┐ + * │ A │ │ A' │ <------ $childNodeAggregate (A+A') + * └─────┘ └─────┘ + * + * How do we do this? + * - we iterate over each covered dimension space point of the full aggregate + * - in each dimension space point, we check whether the parent node is "our" $nodeAggregate (where + * we originated from) + */ + private function findDimensionSpacePointsConnectingParentAndChildAggregate( + ContentGraphInterface $contentGraph, + NodeAggregate $parentNodeAggregate, + NodeAggregate $childNodeAggregate + ): DimensionSpacePointSet { + $points = []; + foreach ($childNodeAggregate->coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { + $parentNode = $contentGraph->getSubgraph($coveredDimensionSpacePoint, VisibilityConstraints::withoutRestrictions())->findParentNode( + $childNodeAggregate->nodeAggregateId + ); + if ( + $parentNode + && $parentNode->aggregateId->equals($parentNodeAggregate->nodeAggregateId) + ) { + $points[] = $coveredDimensionSpacePoint; + } + } + + return new DimensionSpacePointSet($points); + } + + private function removeNodeInDimensionSpacePointSet( + ContentGraphInterface $contentGraph, + NodeAggregate $nodeAggregate, + DimensionSpacePointSet $coveredDimensionSpacePointsToBeRemoved, + ): NodeAggregateWasRemoved { + return new NodeAggregateWasRemoved( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregate->nodeAggregateId, + // TODO: we also use the covered dimension space points as OCCUPIED dimension space points + // - however the OCCUPIED dimension space points are not really used by now + // (except for the change projector, which needs love anyways...) + OriginDimensionSpacePointSet::fromDimensionSpacePointSet( + $coveredDimensionSpacePointsToBeRemoved + ), + $coveredDimensionSpacePointsToBeRemoved, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php b/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php index a8717cb2088..a38dbe78646 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php @@ -15,18 +15,30 @@ */ use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; +use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; +use Neos\ContentRepository\Core\DimensionSpace\VariantType; use Neos\ContentRepository\Core\EventStore\Events; +use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; +use Neos\ContentRepository\Core\Feature\NodeTypeChange\Dto\NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy; +use Neos\ContentRepository\Core\Feature\NodeTypeChange\Event\NodeAggregateTypeWasChanged; +use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeGeneralizationVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodePeerVariantWasCreated; +use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeSpecializationVariantWasCreated; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\NodeType\TetheredNodeTypeDefinition; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\CoverageByOrigin; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; +use Neos\ContentRepository\Core\Projection\ContentGraph\NodePath; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; +use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; /** * @internal implementation details of command handlers @@ -148,4 +160,206 @@ protected function createEventsForMissingTetheredNode( $parentNodeAggregate ); } + + protected function createEventsForMissingTetheredNodeAggregate( + ContentGraphInterface $contentGraph, + TetheredNodeTypeDefinition $tetheredNodeTypeDefinition, + OriginDimensionSpacePointSet $affectedOriginDimensionSpacePoints, + CoverageByOrigin $coverageByOrigin, + NodeAggregateId $parentNodeAggregateId, + ?NodeAggregateId $succeedingSiblingNodeAggregateId, + NodeAggregateIdsByNodePaths $nodeAggregateIdsByNodePaths, + NodePath $currentNodePath, + ): Events { + $events = []; + $tetheredNodeType = $this->requireNodeType($tetheredNodeTypeDefinition->nodeTypeName); + $nodeAggregateId = $nodeAggregateIdsByNodePaths->getNodeAggregateId($currentNodePath) ?? NodeAggregateId::create(); + $defaultValues = SerializedPropertyValues::defaultFromNodeType( + $tetheredNodeType, + $this->getPropertyConverter() + ); + $creationOrigin = null; + foreach ($affectedOriginDimensionSpacePoints as $originDimensionSpacePoint) { + $coverage = $coverageByOrigin->getCoverage($originDimensionSpacePoint); + if (!$coverage) { + throw new \RuntimeException('Missing coverage for origin dimension space point ' . \json_encode($originDimensionSpacePoint)); + } + $interdimensionalSiblings = InterdimensionalSiblings::fromDimensionSpacePointSetWithSingleSucceedingSiblings( + $coverage, + $succeedingSiblingNodeAggregateId, + ); + $events[] = $creationOrigin + ? match ( + $this->interDimensionalVariationGraph->getVariantType( + $originDimensionSpacePoint->toDimensionSpacePoint(), + $creationOrigin->toDimensionSpacePoint(), + ) + ) { + VariantType::TYPE_SPECIALIZATION => new NodeSpecializationVariantWasCreated( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregateId, + $creationOrigin, + $originDimensionSpacePoint, + $interdimensionalSiblings, + ), + VariantType::TYPE_GENERALIZATION => new NodeGeneralizationVariantWasCreated( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregateId, + $creationOrigin, + $originDimensionSpacePoint, + $interdimensionalSiblings, + ), + default => new NodePeerVariantWasCreated( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregateId, + $creationOrigin, + $originDimensionSpacePoint, + $interdimensionalSiblings, + ), + } + : new NodeAggregateWithNodeWasCreated( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregateId, + $tetheredNodeTypeDefinition->nodeTypeName, + $originDimensionSpacePoint, + $interdimensionalSiblings, + $parentNodeAggregateId, + $tetheredNodeTypeDefinition->name, + $defaultValues, + NodeAggregateClassification::CLASSIFICATION_TETHERED, + ); + + $creationOrigin ??= $originDimensionSpacePoint; + } + + foreach ($tetheredNodeType->tetheredNodeTypeDefinitions as $childTetheredNodeTypeDefinition) { + $events = array_merge( + $events, + iterator_to_array( + $this->createEventsForMissingTetheredNodeAggregate( + $contentGraph, + $childTetheredNodeTypeDefinition, + $affectedOriginDimensionSpacePoints, + $coverageByOrigin, + $nodeAggregateId, + null, + $nodeAggregateIdsByNodePaths, + $currentNodePath->appendPathSegment($childTetheredNodeTypeDefinition->name), + ) + ) + ); + } + + return Events::fromArray($events); + } + + protected function createEventsForWronglyTypedNodeAggregate( + ContentGraphInterface $contentGraph, + NodeAggregate $nodeAggregate, + NodeTypeName $newNodeTypeName, + NodeAggregateIdsByNodePaths $nodeAggregateIdsByNodePaths, + NodePath $currentNodePath, + NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy $conflictResolutionStrategy, + NodeAggregateIds $alreadyRemovedNodeAggregateIds, + ): Events { + $events = []; + + $tetheredNodeType = $this->requireNodeType($newNodeTypeName); + + $events[] = new NodeAggregateTypeWasChanged( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregate->nodeAggregateId, + $newNodeTypeName, + ); + + # Handle property adjustments + foreach ($nodeAggregate->getNodes() as $node) { + $presentPropertyKeys = array_keys(iterator_to_array($node->properties->serialized())); + $complementaryPropertyValues = SerializedPropertyValues::defaultFromNodeType( + $tetheredNodeType, + $this->propertyConverter + ) + ->unsetProperties(PropertyNames::fromArray($presentPropertyKeys)); + $obsoletePropertyNames = PropertyNames::fromArray( + array_diff( + $presentPropertyKeys, + array_keys($tetheredNodeType->getProperties()), + ) + ); + + if (count($complementaryPropertyValues->values) > 0 || count($obsoletePropertyNames) > 0) { + $events[] = new NodePropertiesWereSet( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregate->nodeAggregateId, + $node->originDimensionSpacePoint, + $nodeAggregate->getCoverageByOccupant($node->originDimensionSpacePoint), + $complementaryPropertyValues, + $obsoletePropertyNames + ); + } + } + + // remove disallowed nodes + if ($conflictResolutionStrategy === NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy::STRATEGY_DELETE) { + array_push($events, ...iterator_to_array( + $this->deleteDisallowedNodesWhenChangingNodeType( + $contentGraph, + $nodeAggregate, + $tetheredNodeType, + $alreadyRemovedNodeAggregateIds + ) + )); + array_push($events, ...iterator_to_array( + $this->deleteObsoleteTetheredNodesWhenChangingNodeType( + $contentGraph, + $nodeAggregate, + $tetheredNodeType, + $alreadyRemovedNodeAggregateIds + ) + )); + } + + # Handle descendant nodes + foreach ($tetheredNodeType->tetheredNodeTypeDefinitions as $childTetheredNodeTypeDefinition) { + $tetheredChildNodeAggregate = $contentGraph->findChildNodeAggregateByName( + $nodeAggregate->nodeAggregateId, + $childTetheredNodeTypeDefinition->name + ); + if ($tetheredChildNodeAggregate === null) { + $events = array_merge( + $events, + iterator_to_array($this->createEventsForMissingTetheredNodeAggregate( + $contentGraph, + $childTetheredNodeTypeDefinition, + $nodeAggregate->occupiedDimensionSpacePoints, + $nodeAggregate->coverageByOccupant, + $nodeAggregate->nodeAggregateId, + null, + $nodeAggregateIdsByNodePaths, + $currentNodePath->appendPathSegment($childTetheredNodeTypeDefinition->name), + )) + ); + } elseif (!$tetheredChildNodeAggregate->nodeTypeName->equals($childTetheredNodeTypeDefinition->nodeTypeName)) { + $events = array_merge($events, iterator_to_array( + $this->createEventsForWronglyTypedNodeAggregate( + $contentGraph, + $tetheredChildNodeAggregate, + $childTetheredNodeTypeDefinition->nodeTypeName, + $nodeAggregateIdsByNodePaths, + $currentNodePath->appendPathSegment($childTetheredNodeTypeDefinition->name), + $conflictResolutionStrategy, + $alreadyRemovedNodeAggregateIds, + ) + )); + } + } + + return Events::fromArray($events); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php index f295cd6dabe..79f5412eaa5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php @@ -157,7 +157,7 @@ public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return ( $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) - && $this->originDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint) + && $nodeIdToPublish->dimensionSpacePoint?->equals($this->originDimensionSpacePoint) ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php index 454b7c2ccb2..f92f938df9c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php @@ -84,7 +84,7 @@ public function jsonSerialize(): array public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return ( - $this->coveredDimensionSpacePoint === $nodeIdToPublish->dimensionSpacePoint + $this->coveredDimensionSpacePoint === $nodeIdToPublish->dimensionSpacePoint && $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php index 52355fd5e4a..ba31a07c601 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php @@ -133,7 +133,7 @@ public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool ); return ( !is_null($targetNodeAggregateId) - && $this->targetDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint) + && $nodeIdToPublish->dimensionSpacePoint?->equals($this->targetDimensionSpacePoint) && $targetNodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php index ca589337e85..a2d684f6035 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php @@ -103,7 +103,7 @@ public function jsonSerialize(): array public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return ( - $this->originDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint) + $nodeIdToPublish->dimensionSpacePoint?->equals($this->originDimensionSpacePoint) && $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php index 3f635af08e0..1edf8409040 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php @@ -91,7 +91,7 @@ public function jsonSerialize(): array public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { - return ($this->sourceOriginDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint) + return ($nodeIdToPublish->dimensionSpacePoint?->equals($this->sourceOriginDimensionSpacePoint) && $this->sourceNodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php index 0398767e585..6cc77a9d6bb 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php @@ -15,41 +15,44 @@ namespace Neos\ContentRepository\Core\Feature\NodeTypeChange; use Neos\ContentRepository\Core\CommandHandlingDependencies; -use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\Common\NodeTypeChangeInternals; +use Neos\ContentRepository\Core\Feature\Common\TetheredNodeInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\NodeRemoval\Event\NodeAggregateWasRemoved; +use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; +use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Dto\NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Event\NodeAggregateTypeWasChanged; use Neos\ContentRepository\Core\NodeType\NodeType; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\NodeType\TetheredNodeTypeDefinition; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\CoverageByOrigin; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\Projection\ContentGraph\NodePath; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregatesTypeIsAmbiguous; use Neos\ContentRepository\Core\SharedModel\Exception\NodeConstraintException; use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFound; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; - -/** @codingStandardsIgnoreStart */ -/** @codingStandardsIgnoreEnd */ +use Neos\ContentRepository\Core\SharedModel\Node\PropertyNames; /** * @internal implementation detail of Command Handlers */ trait NodeTypeChange { + use TetheredNodeInternals; + use NodeTypeChangeInternals; + abstract protected function getNodeTypeManager(): NodeTypeManager; abstract protected function requireNodeAggregateToBeUntethered(NodeAggregate $nodeAggregate): void; @@ -87,6 +90,27 @@ abstract protected function areNodeTypeConstraintsImposedByGrandparentValid( NodeType $nodeType ): bool; + abstract protected function createEventsForMissingTetheredNodeAggregate( + ContentGraphInterface $contentGraph, + TetheredNodeTypeDefinition $tetheredNodeTypeDefinition, + OriginDimensionSpacePointSet $affectedOriginDimensionSpacePoints, + CoverageByOrigin $coverageByOrigin, + NodeAggregateId $parentNodeAggregateId, + ?NodeAggregateId $succeedingSiblingNodeAggregateId, + NodeAggregateIdsByNodePaths $nodeAggregateIdsByNodePaths, + NodePath $currentNodePath, + ): Events; + + abstract protected function createEventsForWronglyTypedNodeAggregate( + ContentGraphInterface $contentGraph, + NodeAggregate $nodeAggregate, + NodeTypeName $newNodeTypeName, + NodeAggregateIdsByNodePaths $nodeAggregateIdsByNodePaths, + NodePath $currentNodePath, + NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy $conflictResolutionStrategy, + NodeAggregateIds $alreadyRemovedNodeAggregates, + ): Events; + abstract protected function createEventsForMissingTetheredNode( ContentGraphInterface $contentGraph, NodeAggregate $parentNodeAggregate, @@ -110,6 +134,7 @@ private function handleChangeNodeAggregateType( * Constraint checks **************/ // existence of content stream, node type and node aggregate + $this->requireContentStream($command->workspaceName, $commandHandlingDependencies); $contentGraph = $commandHandlingDependencies->getContentGraph($command->workspaceName); $expectedVersion = $this->getExpectedVersionOfContentStream($contentGraph->getContentStreamId(), $commandHandlingDependencies); $newNodeType = $this->requireNodeType($command->newNodeTypeName); @@ -117,12 +142,15 @@ private function handleChangeNodeAggregateType( $contentGraph, $command->nodeAggregateId ); + $this->requireNodeAggregateToNotBeRoot($nodeAggregate); $this->requireNodeAggregateToBeUntethered($nodeAggregate); // node type detail checks $this->requireNodeTypeToNotBeOfTypeRoot($newNodeType); + $this->requireNodeTypeToNotBeAbstract($newNodeType); $this->requireTetheredDescendantNodeTypesToExist($newNodeType); $this->requireTetheredDescendantNodeTypesToNotBeOfTypeRoot($newNodeType); + $this->requireExistingDeclaredTetheredDescendantsToBeTethered($contentGraph, $nodeAggregate, $newNodeType); // the new node type must be allowed at this position in the tree $parentNodeAggregates = $contentGraph->findParentNodeAggregates( @@ -137,13 +165,15 @@ private function handleChangeNodeAggregateType( ); } - /** @codingStandardsIgnoreStart */ match ($command->strategy) { NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy::STRATEGY_HAPPY_PATH - => $this->requireConstraintsImposedByHappyPathStrategyAreMet($contentGraph, $nodeAggregate, $newNodeType), + => $this->requireConstraintsImposedByHappyPathStrategyAreMet( + $contentGraph, + $nodeAggregate, + $newNodeType + ), NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy::STRATEGY_DELETE => null }; - /** @codingStandardsIgnoreStop */ /************** * Preparation - make the command fully deterministic in case of rebase @@ -164,48 +194,86 @@ private function handleChangeNodeAggregateType( $contentGraph->getWorkspaceName(), $contentGraph->getContentStreamId(), $command->nodeAggregateId, - $command->newNodeTypeName + $command->newNodeTypeName, ), ]; + # Handle property adjustments + $newNodeType = $this->requireNodeType($command->newNodeTypeName); + foreach ($nodeAggregate->getNodes() as $node) { + $presentPropertyKeys = array_keys(iterator_to_array($node->properties->serialized())); + $complementaryPropertyValues = SerializedPropertyValues::defaultFromNodeType( + $newNodeType, + $this->propertyConverter + ) + ->unsetProperties(PropertyNames::fromArray($presentPropertyKeys)); + $obsoletePropertyNames = PropertyNames::fromArray( + array_diff( + $presentPropertyKeys, + array_keys($newNodeType->getProperties()), + ) + ); + + if (count($complementaryPropertyValues->values) > 0 || count($obsoletePropertyNames) > 0) { + $events[] = new NodePropertiesWereSet( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $nodeAggregate->nodeAggregateId, + $node->originDimensionSpacePoint, + $nodeAggregate->getCoverageByOccupant($node->originDimensionSpacePoint), + $complementaryPropertyValues, + $obsoletePropertyNames + ); + } + } + // remove disallowed nodes + $alreadyRemovedNodeAggregateIds = NodeAggregateIds::createEmpty(); if ($command->strategy === NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy::STRATEGY_DELETE) { array_push($events, ...iterator_to_array($this->deleteDisallowedNodesWhenChangingNodeType( $contentGraph, $nodeAggregate, - $newNodeType + $newNodeType, + $alreadyRemovedNodeAggregateIds, ))); array_push($events, ...iterator_to_array($this->deleteObsoleteTetheredNodesWhenChangingNodeType( $contentGraph, $nodeAggregate, - $newNodeType + $newNodeType, + $alreadyRemovedNodeAggregateIds ))); } - // new tethered child nodes - foreach ($nodeAggregate->getNodes() as $node) { - assert($node instanceof Node); - foreach ($newNodeType->tetheredNodeTypeDefinitions as $tetheredNodeTypeDefinition) { - $tetheredNode = $contentGraph->getSubgraph( - $node->originDimensionSpacePoint->toDimensionSpacePoint(), - VisibilityConstraints::withoutRestrictions() - )->findNodeByPath( - $tetheredNodeTypeDefinition->name, - $node->aggregateId, - ); - - if ($tetheredNode === null) { - $tetheredNodeAggregateId = $command->tetheredDescendantNodeAggregateIds - ->getNodeAggregateId(NodePath::fromNodeNames($tetheredNodeTypeDefinition->name)) - ?: NodeAggregateId::create(); - array_push($events, ...iterator_to_array($this->createEventsForMissingTetheredNode( - $contentGraph, - $nodeAggregate, - $node->originDimensionSpacePoint, - $tetheredNodeTypeDefinition, - $tetheredNodeAggregateId - ))); - } + // handle (missing) tethered node aggregates + $nextSibling = null; + $succeedingSiblingIds = []; + foreach (array_reverse(iterator_to_array($newNodeType->tetheredNodeTypeDefinitions)) as $tetheredNodeTypeDefinition) { + $succeedingSiblingIds[$tetheredNodeTypeDefinition->name->value] = $nextSibling; + $nextSibling = $command->tetheredDescendantNodeAggregateIds->getNodeAggregateId(NodePath::fromNodeNames($tetheredNodeTypeDefinition->name)); + } + foreach ($newNodeType->tetheredNodeTypeDefinitions as $tetheredNodeTypeDefinition) { + $tetheredNodeAggregate = $contentGraph->findChildNodeAggregateByName($nodeAggregate->nodeAggregateId, $tetheredNodeTypeDefinition->name); + if ($tetheredNodeAggregate === null) { + $events = array_merge($events, iterator_to_array($this->createEventsForMissingTetheredNodeAggregate( + $contentGraph, + $tetheredNodeTypeDefinition, + $nodeAggregate->occupiedDimensionSpacePoints, + $nodeAggregate->coverageByOccupant, + $nodeAggregate->nodeAggregateId, + $succeedingSiblingIds[$tetheredNodeTypeDefinition->nodeTypeName->value] ?? null, + $command->tetheredDescendantNodeAggregateIds, + NodePath::fromNodeNames($tetheredNodeTypeDefinition->name) + ))); + } elseif (!$tetheredNodeAggregate->nodeTypeName->equals($tetheredNodeTypeDefinition->nodeTypeName)) { + $events = array_merge($events, iterator_to_array($this->createEventsForWronglyTypedNodeAggregate( + $contentGraph, + $tetheredNodeAggregate, + $tetheredNodeTypeDefinition->nodeTypeName, + $command->tetheredDescendantNodeAggregateIds, + NodePath::fromNodeNames($tetheredNodeTypeDefinition->name), + $command->strategy, + $alreadyRemovedNodeAggregateIds + ))); } } @@ -219,7 +287,6 @@ private function handleChangeNodeAggregateType( ); } - /** * NOTE: when changing this method, {@see NodeTypeChange::deleteDisallowedNodesWhenChangingNodeType} * needs to be modified as well (as they are structurally the same) @@ -262,179 +329,18 @@ private function requireConstraintsImposedByHappyPathStrategyAreMet( $this->requireNodeType($grandchildNodeAggregate->nodeTypeName) ); } - } - } - - /** - * NOTE: when changing this method, {@see NodeTypeChange::requireConstraintsImposedByHappyPathStrategyAreMet} - * needs to be modified as well (as they are structurally the same) - */ - private function deleteDisallowedNodesWhenChangingNodeType( - ContentGraphInterface $contentGraph, - NodeAggregate $nodeAggregate, - NodeType $newNodeType - ): Events { - $events = []; - // if we have children, we need to check whether they are still allowed - // after we changed the node type of the $nodeAggregate to $newNodeType. - $childNodeAggregates = $contentGraph->findChildNodeAggregates( - $nodeAggregate->nodeAggregateId - ); - foreach ($childNodeAggregates as $childNodeAggregate) { - /* @var $childNodeAggregate NodeAggregate */ - // the "parent" of the $childNode is $node; so we use $newNodeType - // (the target node type of $node after the operation) here. - if ( - !$childNodeAggregate->classification->isTethered() - && !$this->areNodeTypeConstraintsImposedByParentValid( - $newNodeType, - $this->requireNodeType($childNodeAggregate->nodeTypeName) - ) - ) { - // this aggregate (or parts thereof) are DISALLOWED according to constraints. - // We now need to find out which edges we need to remove, - $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( - $contentGraph, - $nodeAggregate, - $childNodeAggregate - ); - // AND REMOVE THEM - $events[] = $this->removeNodeInDimensionSpacePointSet( - $contentGraph, - $childNodeAggregate, - $dimensionSpacePointsToBeRemoved, - ); - } - // we do not need to test for grandparents here, as we did not modify the grandparents. - // Thus, if it was allowed before, it is allowed now. - // additionally, we need to look one level down to the grandchildren as well - // - as it could happen that these are affected by our constraint checks as well. - $grandchildNodeAggregates = $contentGraph->findChildNodeAggregates($childNodeAggregate->nodeAggregateId); - foreach ($grandchildNodeAggregates as $grandchildNodeAggregate) { - /* @var $grandchildNodeAggregate NodeAggregate */ - // we do not need to test for the parent of grandchild (=child), - // as we do not change the child's node type. - // we however need to check for the grandparent node type. - if ( - $childNodeAggregate->nodeName !== null - && !$this->areNodeTypeConstraintsImposedByGrandparentValid( - $newNodeType, // the grandparent node type changes - $childNodeAggregate->nodeName, - $this->requireNodeType($grandchildNodeAggregate->nodeTypeName) - ) - ) { - // this aggregate (or parts thereof) are DISALLOWED according to constraints. - // We now need to find out which edges we need to remove, - $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( - $contentGraph, - $childNodeAggregate, - $grandchildNodeAggregate - ); - // AND REMOVE THEM - $events[] = $this->removeNodeInDimensionSpacePointSet( - $contentGraph, - $grandchildNodeAggregate, - $dimensionSpacePointsToBeRemoved, - ); + foreach ($newNodeType->tetheredNodeTypeDefinitions as $tetheredNodeTypeDefinition) { + foreach ($childNodeAggregates as $childNodeAggregate) { + if ($childNodeAggregate->nodeName?->equals($tetheredNodeTypeDefinition->name)) { + $this->requireConstraintsImposedByHappyPathStrategyAreMet( + $contentGraph, + $childNodeAggregate, + $this->requireNodeType($tetheredNodeTypeDefinition->nodeTypeName) + ); + } } } } - - return Events::fromArray($events); - } - - private function deleteObsoleteTetheredNodesWhenChangingNodeType( - ContentGraphInterface $contentGraph, - NodeAggregate $nodeAggregate, - NodeType $newNodeType - ): Events { - $events = []; - // find disallowed tethered nodes - $tetheredNodeAggregates = $contentGraph->findTetheredChildNodeAggregates($nodeAggregate->nodeAggregateId); - - foreach ($tetheredNodeAggregates as $tetheredNodeAggregate) { - /* @var $tetheredNodeAggregate NodeAggregate */ - if ($tetheredNodeAggregate->nodeName !== null && !$newNodeType->tetheredNodeTypeDefinitions->contain($tetheredNodeAggregate->nodeName)) { - // this aggregate (or parts thereof) are DISALLOWED according to constraints. - // We now need to find out which edges we need to remove, - $dimensionSpacePointsToBeRemoved = $this->findDimensionSpacePointsConnectingParentAndChildAggregate( - $contentGraph, - $nodeAggregate, - $tetheredNodeAggregate - ); - // AND REMOVE THEM - $events[] = $this->removeNodeInDimensionSpacePointSet( - $contentGraph, - $tetheredNodeAggregate, - $dimensionSpacePointsToBeRemoved, - ); - } - } - - return Events::fromArray($events); - } - - /** - * Find all dimension space points which connect two Node Aggregates. - * - * After we found wrong node type constraints between two aggregates, we need to remove exactly the edges where the - * aggregates are connected as parent and child. - * - * Example: In this case, we want to find exactly the bold edge between PAR1 and A. - * - * ╔══════╗ <------ $parentNodeAggregate (PAR1) - * ┌──────┐ ║ PAR1 ║ ┌──────┐ - * │ PAR3 │ ╚══════╝ │ PAR2 │ - * └──────┘ ║ └──────┘ - * ╲ ║ ╱ - * ╲ ║ ╱ - * ▼──▼──┐ ┌───▼─┐ - * │ A │ │ A' │ <------ $childNodeAggregate (A+A') - * └─────┘ └─────┘ - * - * How do we do this? - * - we iterate over each covered dimension space point of the full aggregate - * - in each dimension space point, we check whether the parent node is "our" $nodeAggregate (where - * we originated from) - */ - private function findDimensionSpacePointsConnectingParentAndChildAggregate( - ContentGraphInterface $contentGraph, - NodeAggregate $parentNodeAggregate, - NodeAggregate $childNodeAggregate - ): DimensionSpacePointSet { - $points = []; - foreach ($childNodeAggregate->coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { - $parentNode = $contentGraph->getSubgraph($coveredDimensionSpacePoint, VisibilityConstraints::withoutRestrictions())->findParentNode( - $childNodeAggregate->nodeAggregateId - ); - if ( - $parentNode - && $parentNode->aggregateId->equals($parentNodeAggregate->nodeAggregateId) - ) { - $points[] = $coveredDimensionSpacePoint; - } - } - - return new DimensionSpacePointSet($points); - } - - private function removeNodeInDimensionSpacePointSet( - ContentGraphInterface $contentGraph, - NodeAggregate $nodeAggregate, - DimensionSpacePointSet $coveredDimensionSpacePointsToBeRemoved, - ): NodeAggregateWasRemoved { - return new NodeAggregateWasRemoved( - $contentGraph->getWorkspaceName(), - $contentGraph->getContentStreamId(), - $nodeAggregate->nodeAggregateId, - // TODO: we also use the covered dimension space points as OCCUPIED dimension space points - // - however the OCCUPIED dimension space points are not really used by now - // (except for the change projector, which needs love anyways...) - OriginDimensionSpacePointSet::fromDimensionSpacePointSet( - $coveredDimensionSpacePointsToBeRemoved - ), - $coveredDimensionSpacePointsToBeRemoved, - ); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php index 001f9bd66e9..54873f489dc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php @@ -84,7 +84,7 @@ public function jsonSerialize(): array public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) - && $this->targetOrigin->equals($nodeIdToPublish->dimensionSpacePoint); + && $nodeIdToPublish->dimensionSpacePoint?->equals($this->targetOrigin); } public function createCopyForWorkspace( diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php index 71fb012b0cc..d4d37c8b8cb 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php @@ -92,7 +92,7 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) - && $this->coveredDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint); + && $nodeIdToPublish->dimensionSpacePoint === $this->coveredDimensionSpacePoint; } /** diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php index cca49333c95..1ae9b4624a2 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php @@ -93,7 +93,7 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) - && $this->coveredDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint); + && $this->coveredDimensionSpacePoint === $nodeIdToPublish->dimensionSpacePoint; } /** diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdToPublishOrDiscard.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdToPublishOrDiscard.php index a9a7e1339e2..b2b5c346be7 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdToPublishOrDiscard.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdToPublishOrDiscard.php @@ -30,7 +30,8 @@ { public function __construct( public NodeAggregateId $nodeAggregateId, - public DimensionSpacePoint $dimensionSpacePoint, + /** Can be null for aggregate scoped changes, e.g. ChangeNodeAggregateName or ChangeNodeAggregateName */ + public ?DimensionSpacePoint $dimensionSpacePoint, ) { } @@ -41,7 +42,9 @@ public static function fromArray(array $array): self { return new self( NodeAggregateId::fromString($array['nodeAggregateId']), - DimensionSpacePoint::fromArray($array['dimensionSpacePoint']), + is_array($array['dimensionSpacePoint'] ?? null) + ? DimensionSpacePoint::fromArray($array['dimensionSpacePoint']) + : null, ); } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php index c949bac0f57..33497841f00 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php @@ -77,7 +77,7 @@ private function __construct( public ?NodeName $nodeName, public OriginDimensionSpacePointSet $occupiedDimensionSpacePoints, private array $nodesByOccupiedDimensionSpacePoint, - private CoverageByOrigin $coverageByOccupant, + public CoverageByOrigin $coverageByOccupant, public DimensionSpacePointSet $coveredDimensionSpacePoints, private array $nodesByCoveredDimensionSpacePoint, private OriginByCoverage $occupationByCovered, diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php new file mode 100644 index 00000000000..fe0688363cb --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php @@ -0,0 +1,25 @@ +nodeTypeManager->getNodeType($nodeTypeName) ?? throw new NodeTypeNotFound( + 'Node type "' . $nodeTypeName->value . '" is unknown to the node type manager.', + 1729600849 + ); + } + protected function getInterDimensionalVariationGraph(): DimensionSpace\InterDimensionalVariationGraph { return $this->interDimensionalVariationGraph; @@ -248,4 +261,14 @@ private function reorderNodes( ExpectedVersion::ANY() ); } + + protected function getNodeTypeManager(): NodeTypeManager + { + return $this->nodeTypeManager; + } + + protected function getAllowedDimensionSubspace(): DimensionSpacePointSet + { + return $this->interDimensionalVariationGraph->getDimensionSpacePoints(); + } } diff --git a/Neos.ContentRepository.StructureAdjustment/src/StructureAdjustmentService.php b/Neos.ContentRepository.StructureAdjustment/src/StructureAdjustmentService.php index 6211faa4751..753525af34f 100644 --- a/Neos.ContentRepository.StructureAdjustment/src/StructureAdjustmentService.php +++ b/Neos.ContentRepository.StructureAdjustment/src/StructureAdjustmentService.php @@ -51,7 +51,7 @@ public function __construct( $this->liveContentGraph, $nodeTypeManager, $interDimensionalVariationGraph, - $propertyConverter + $propertyConverter, ); $this->unknownNodeTypeAdjustment = new UnknownNodeTypeAdjustment( diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index e240adb41bc..7867326e1ea 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -146,6 +146,10 @@ private function discardWorkspace(WorkspaceName $workspaceName): void private function discardNodes(WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIds): void { foreach ($nodeIds as $nodeId) { + if (!$nodeId->dimensionSpacePoint) { + // NodeAggregateTypeWasChanged and NodeAggregateNameWasChanged don't impact asset usage + continue; + } $this->assetUsageIndexingService->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( $this->contentRepository->id, $workspaceName, diff --git a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php index 31836d383d2..cb27d2b92db 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php @@ -26,9 +26,11 @@ use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\NodeType\NodeTypeName; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace as ContentRepositoryWorkspace; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyDoesNotExist; @@ -339,7 +341,7 @@ private function resolveNodeIdsToPublishOrDiscard( $nodeIdsToPublishOrDiscard[] = new NodeIdToPublishOrDiscard( $change->nodeAggregateId, - $change->originDimensionSpacePoint->toDimensionSpacePoint() + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ); } @@ -358,7 +360,6 @@ private function countPendingWorkspaceChangesInternal(ContentRepository $content return $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId); } - private function isChangePublishableWithinAncestorScope( ContentRepository $contentRepository, WorkspaceName $workspaceName, @@ -374,20 +375,38 @@ private function isChangePublishableWithinAncestorScope( } } - $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( - $change->originDimensionSpacePoint->toDimensionSpacePoint(), - VisibilityConstraints::withoutRestrictions() - ); + if ($change->originDimensionSpacePoint) { + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( + $change->originDimensionSpacePoint->toDimensionSpacePoint(), + VisibilityConstraints::withoutRestrictions() + ); - // A Change is publishable if the respective node (or the respective - // removal attachment point) has a closest ancestor that matches our - // current ancestor scope (Document/Site) - $actualAncestorNode = $subgraph->findClosestNode( - $change->removalAttachmentPoint ?? $change->nodeAggregateId, - FindClosestNodeFilter::create(nodeTypes: $ancestorNodeTypeName->value) - ); + // A Change is publishable if the respective node (or the respective + // removal attachment point) has a closest ancestor that matches our + // current ancestor scope (Document/Site) + $actualAncestorNode = $subgraph->findClosestNode( + $change->removalAttachmentPoint ?? $change->nodeAggregateId, + FindClosestNodeFilter::create(nodeTypes: $ancestorNodeTypeName->value) + ); + + return $actualAncestorNode?->aggregateId->equals($ancestorId) ?? false; + } else { + return $this->findAncestorAggregateIds( + $contentRepository->getContentGraph($workspaceName), + $change->nodeAggregateId + )->contain($ancestorId); + } + } + + private function findAncestorAggregateIds(ContentGraphInterface $contentGraph, NodeAggregateId $descendantNodeAggregateId): NodeAggregateIds + { + $nodeAggregateIds = NodeAggregateIds::create($descendantNodeAggregateId); + foreach ($contentGraph->findParentNodeAggregates($descendantNodeAggregateId) as $parentNodeAggregate) { + $nodeAggregateIds = $nodeAggregateIds->merge(NodeAggregateIds::create($parentNodeAggregate->nodeAggregateId)); + $nodeAggregateIds = $nodeAggregateIds->merge($this->findAncestorAggregateIds($contentGraph, $parentNodeAggregate->nodeAggregateId)); + } - return $actualAncestorNode?->aggregateId->equals($ancestorId) ?? false; + return $nodeAggregateIds; } /** diff --git a/Neos.Neos/Classes/PendingChangesProjection/Change.php b/Neos.Neos/Classes/PendingChangesProjection/Change.php index acee1cda5e6..798902f5517 100644 --- a/Neos.Neos/Classes/PendingChangesProjection/Change.php +++ b/Neos.Neos/Classes/PendingChangesProjection/Change.php @@ -30,13 +30,16 @@ */ final class Change { + public const AGGREGATE_DIMENSIONSPACEPOINT_HASH_PLACEHOLDER = 'AGGREGATE'; + /** * @param NodeAggregateId|null $removalAttachmentPoint {@see RemoveNodeAggregate::$removalAttachmentPoint} for docs */ public function __construct( public ContentStreamId $contentStreamId, public NodeAggregateId $nodeAggregateId, - public OriginDimensionSpacePoint $originDimensionSpacePoint, + // null for aggregate scoped changes (e.g. NodeAggregateNameWasChanged, NodeAggregateTypeWasChanged) + public ?OriginDimensionSpacePoint $originDimensionSpacePoint, public bool $created, public bool $changed, public bool $moved, @@ -55,8 +58,8 @@ public function addToDatabase(Connection $databaseConnection, string $tableName) $databaseConnection->insert($tableName, [ 'contentStreamId' => $this->contentStreamId->value, 'nodeAggregateId' => $this->nodeAggregateId->value, - 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), - 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, + 'originDimensionSpacePoint' => $this->originDimensionSpacePoint?->toJson(), + 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint?->hash ?: self::AGGREGATE_DIMENSIONSPACEPOINT_HASH_PLACEHOLDER, 'created' => (int)$this->created, 'changed' => (int)$this->changed, 'moved' => (int)$this->moved, @@ -83,8 +86,8 @@ public function updateToDatabase(Connection $databaseConnection, string $tableNa [ 'contentStreamId' => $this->contentStreamId->value, 'nodeAggregateId' => $this->nodeAggregateId->value, - 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), - 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, + 'originDimensionSpacePoint' => $this->originDimensionSpacePoint?->toJson(), + 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint?->hash ?: self::AGGREGATE_DIMENSIONSPACEPOINT_HASH_PLACEHOLDER, ] ); } catch (DbalException $e) { @@ -100,7 +103,9 @@ public static function fromDatabaseRow(array $databaseRow): self return new self( ContentStreamId::fromString($databaseRow['contentStreamId']), NodeAggregateId::fromString($databaseRow['nodeAggregateId']), - OriginDimensionSpacePoint::fromJsonString($databaseRow['originDimensionSpacePoint']), + $databaseRow['originDimensionSpacePoint'] ?? null + ? OriginDimensionSpacePoint::fromJsonString($databaseRow['originDimensionSpacePoint']) + : null, (bool)$databaseRow['created'], (bool)$databaseRow['changed'], (bool)$databaseRow['moved'], diff --git a/Neos.Neos/Classes/PendingChangesProjection/ChangeProjection.php b/Neos.Neos/Classes/PendingChangesProjection/ChangeProjection.php index 22cd97b9e7f..1d173895789 100644 --- a/Neos.Neos/Classes/PendingChangesProjection/ChangeProjection.php +++ b/Neos.Neos/Classes/PendingChangesProjection/ChangeProjection.php @@ -30,6 +30,8 @@ use Neos\ContentRepository\Core\Feature\NodeMove\Event\NodeAggregateWasMoved; use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\Feature\NodeRemoval\Event\NodeAggregateWasRemoved; +use Neos\ContentRepository\Core\Feature\NodeRenaming\Event\NodeAggregateNameWasChanged; +use Neos\ContentRepository\Core\Feature\NodeTypeChange\Event\NodeAggregateTypeWasChanged; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeGeneralizationVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodePeerVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeSpecializationVariantWasCreated; @@ -124,12 +126,12 @@ private function determineRequiredSqlStatements(): array (new Column('created', Type::getType(Types::BOOLEAN)))->setNotnull(true), (new Column('changed', Type::getType(Types::BOOLEAN)))->setNotnull(true), (new Column('moved', Type::getType(Types::BOOLEAN)))->setNotnull(true), - DbalSchemaFactory::columnForNodeAggregateId('nodeAggregateId')->setNotNull(true), - DbalSchemaFactory::columnForDimensionSpacePoint('originDimensionSpacePoint')->setNotNull(false), - DbalSchemaFactory::columnForDimensionSpacePointHash('originDimensionSpacePointHash')->setNotNull(true), + DbalSchemaFactory::columnForNodeAggregateId('nodeAggregateId')->setNotnull(true), + DbalSchemaFactory::columnForDimensionSpacePoint('originDimensionSpacePoint')->setNotnull(false), + DbalSchemaFactory::columnForDimensionSpacePointHash('originDimensionSpacePointHash')->setNotnull(true), (new Column('deleted', Type::getType(Types::BOOLEAN)))->setNotnull(true), // Despite the name suggesting this might be an anchor point of sorts, this is a nodeAggregateId type - DbalSchemaFactory::columnForNodeAggregateId('removalAttachmentPoint')->setNotNull(false) + DbalSchemaFactory::columnForNodeAggregateId('removalAttachmentPoint')->setNotnull(false) ]); $changeTable->setPrimaryKey([ @@ -167,7 +169,9 @@ public function canHandle(EventInterface $event): bool DimensionSpacePointWasMoved::class, NodeGeneralizationVariantWasCreated::class, NodeSpecializationVariantWasCreated::class, - NodePeerVariantWasCreated::class + NodePeerVariantWasCreated::class, + NodeAggregateTypeWasChanged::class, + NodeAggregateNameWasChanged::class, ]); } @@ -185,6 +189,8 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void NodeSpecializationVariantWasCreated::class => $this->whenNodeSpecializationVariantWasCreated($event), NodeGeneralizationVariantWasCreated::class => $this->whenNodeGeneralizationVariantWasCreated($event), NodePeerVariantWasCreated::class => $this->whenNodePeerVariantWasCreated($event), + NodeAggregateTypeWasChanged::class => $this->whenNodeAggregateTypeWasChanged($event), + NodeAggregateNameWasChanged::class => $this->whenNodeAggregateNameWasChanged($event), default => throw new \InvalidArgumentException(sprintf('Unsupported event %s', get_debug_type($event))), }; } @@ -404,6 +410,28 @@ private function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event) ); } + private function whenNodeAggregateTypeWasChanged(NodeAggregateTypeWasChanged $event): void + { + if ($event->workspaceName->isLive()) { + return; + } + $this->markAggregateAsChanged( + $event->contentStreamId, + $event->nodeAggregateId, + ); + } + + private function whenNodeAggregateNameWasChanged(NodeAggregateNameWasChanged $event): void + { + if ($event->workspaceName->isLive()) { + return; + } + $this->markAggregateAsChanged( + $event->contentStreamId, + $event->nodeAggregateId, + ); + } + private function markAsChanged( ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, @@ -419,6 +447,19 @@ static function (Change $change) { ); } + private function markAggregateAsChanged( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + ): void { + $this->modifyChangeForAggregate( + $contentStreamId, + $nodeAggregateId, + static function (Change $change) { + $change->changed = true; + } + ); + } + private function markAsCreated( ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, @@ -468,6 +509,23 @@ private function modifyChange( } } + private function modifyChangeForAggregate( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + callable $modifyFn + ): void { + $change = $this->getChangeForAggregate($contentStreamId, $nodeAggregateId); + + if ($change === null) { + $change = new Change($contentStreamId, $nodeAggregateId, null, false, false, false, false); + $modifyFn($change); + $change->addToDatabase($this->dbal, $this->tableNamePrefix); + } else { + $modifyFn($change); + $change->updateToDatabase($this->dbal, $this->tableNamePrefix); + } + } + private function getChange( ContentStreamId $contentStreamId, NodeAggregateId $nodeAggregateId, @@ -488,4 +546,23 @@ private function getChange( // We always allow root nodes return $changeRow ? Change::fromDatabaseRow($changeRow) : null; } + + private function getChangeForAggregate( + ContentStreamId $contentStreamId, + NodeAggregateId $nodeAggregateId, + ): ?Change { + $changeRow = $this->dbal->executeQuery( + 'SELECT n.* FROM ' . $this->tableNamePrefix . ' n +WHERE n.contentStreamId = :contentStreamId +AND n.nodeAggregateId = :nodeAggregateId +AND n.origindimensionspacepointhash = :origindimensionspacepointhash', + [ + 'contentStreamId' => $contentStreamId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'origindimensionspacepointhash' => Change::AGGREGATE_DIMENSIONSPACEPOINT_HASH_PLACEHOLDER + ] + )->fetchAssociative(); + + return $changeRow ? Change::fromDatabaseRow($changeRow) : null; + } } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index b6f7b4553a5..1b80ebbf636 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -16,6 +16,7 @@ use Doctrine\DBAL\Exception as DBALException; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; @@ -684,20 +685,23 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos ->findByContentStreamId( $selectedWorkspace->currentContentStreamId ); + $dimensionSpacePoints = iterator_to_array($contentRepository->getVariationGraph()->getDimensionSpacePoints()); + /** @var DimensionSpacePoint $arbitraryDimensionSpacePoint */ + $arbitraryDimensionSpacePoint = reset($dimensionSpacePoints); + + $selectedWorkspaceContentGraph = $contentRepository->getContentGraph($selectedWorkspace->workspaceName); + // If we deleted a node, there is no way for us to anymore find the deleted node in the ContentStream + // where the node was deleted. + // Thus, to figure out the rootline for display, we check the *base workspace* Content Stream. + // + // This is safe because the UI basically shows what would be removed once the deletion is published. + $baseWorkspace = $this->getBaseWorkspaceWhenSureItExists($selectedWorkspace, $contentRepository); + $baseWorkspaceContentGraph = $contentRepository->getContentGraph($baseWorkspace->workspaceName); foreach ($changes as $change) { - $workspaceName = $selectedWorkspace->workspaceName; - if ($change->deleted) { - // If we deleted a node, there is no way for us to anymore find the deleted node in the ContentStream - // where the node was deleted. - // Thus, to figure out the rootline for display, we check the *base workspace* Content Stream. - // - // This is safe because the UI basically shows what would be removed once the deletion is published. - $baseWorkspace = $this->getBaseWorkspaceWhenSureItExists($selectedWorkspace, $contentRepository); - $workspaceName = $baseWorkspace->workspaceName; - } - $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( - $change->originDimensionSpacePoint->toDimensionSpacePoint(), + $contentGraph = $change->deleted ? $baseWorkspaceContentGraph : $selectedWorkspaceContentGraph; + $subgraph = $contentGraph->getSubgraph( + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?: $arbitraryDimensionSpacePoint, VisibilityConstraints::withoutRestrictions() ); @@ -765,7 +769,7 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos $nodeAddress = NodeAddress::create( $contentRepository->id, $selectedWorkspace->workspaceName, - $change->originDimensionSpacePoint->toDimensionSpacePoint(), + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?: $arbitraryDimensionSpacePoint, $change->nodeAggregateId ); @@ -882,8 +886,8 @@ protected function renderContentChanges( 'diff' => $diffArray ]; } - // The && in belows condition is on purpose as creating a thumbnail for comparison only works - // if actually BOTH are ImageInterface (or NULL). + // The && in belows condition is on purpose as creating a thumbnail for comparison only works + // if actually BOTH are ImageInterface (or NULL). } elseif ( ($originalPropertyValue instanceof ImageInterface || $originalPropertyValue === null) && ($changedPropertyValue instanceof ImageInterface || $changedPropertyValue === null)