diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php index 38733eaf7a7..d1f3190d1e8 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php @@ -68,6 +68,10 @@ public function findContentStreams(): ContentStreams */ public function getContentGraphByWorkspaceName(WorkspaceName $workspaceName): ContentGraphInterface { + if ($workspaceName->isVirtual() && $workspaceName->virtualContentStreamId()) { + return $this->adapter->buildContentGraph($workspaceName, $workspaceName->virtualContentStreamId()); + } + $workspace = $this->findWorkspaceByName($workspaceName); if ($workspace === null) { throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index ea4f29c6232..21cbd9006ed 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -470,60 +470,58 @@ private function handlePublishIndividualNodesFromWorkspace( /** @var array $matchingCommands */ /** @var array $remainingCommands */ - // 3) fork a new contentStream, based on the base WS, and apply MATCHING + // 3) fork a temporary workspace, based on the base WS, and apply MATCHING + $matchingWorkspaceName = WorkspaceName::virtualFromContentStreamId($command->contentStreamIdForMatchingPart); $commandHandlingDependencies->handle( ForkContentStream::create( $command->contentStreamIdForMatchingPart, - $baseWorkspace->currentContentStreamId, + $baseWorkspace->currentContentStreamId ) ); try { // 4) using the new content stream, apply the matching commands - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, - $command->contentStreamIdForMatchingPart, - function () use ($matchingCommands, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($matchingCommands as $matchingCommand) { - if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { - throw new \RuntimeException( - 'ERROR: The command ' . get_class($matchingCommand) - . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' - ); - } - - $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace( - $baseWorkspace->workspaceName, - )); - } + foreach ($matchingCommands as $matchingCommand) { + if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { + throw new \RuntimeException( + 'ERROR: The command ' . get_class($matchingCommand) + . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' + ); } - ); - // 5) take EVENTS(MATCHING) and apply them to base WS. + $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace($matchingWorkspaceName)); + } + + // 5) publish EVENTS(MATCHING) and apply them to base WS. $this->publishContentStream( $command->contentStreamIdForMatchingPart, $baseWorkspace->workspaceName, $baseWorkspace->currentContentStreamId ); - // 6) fork a new content stream, based on the base WS, and apply REST + // 6) fork a new content stream, based on the base WS and make this the new workspace state $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->contentStreamIdForRemainingPart, - $baseWorkspace->currentContentStreamId - ) + DiscardWorkspace::create($command->workspaceName) + ->withNewContentStreamId($command->contentStreamIdForRemainingPart) + ); + + // 7) fork a new (temporary) content stream, based on the clean WS above, and apply REST + $virtualWorkspaceContentStreamId = ContentStreamId::create(); + $virtualWorkspaceForRemaining = WorkspaceName::virtualFromContentStreamId($virtualWorkspaceContentStreamId); + $commandHandlingDependencies->handle( + ForkContentStream::create($virtualWorkspaceContentStreamId, $command->contentStreamIdForRemainingPart) ); + // 8) apply REMAINING commands to the workspace's new content stream + foreach ($remainingCommands as $remainingCommand) { + $commandHandlingDependencies->handle($remainingCommand->createCopyForWorkspace($virtualWorkspaceForRemaining)); + } - // 7) apply REMAINING commands to the workspace's new content stream - $commandHandlingDependencies->overrideContentStreamId( + // 9) publish virtual workspace into clean REMAINING workspace + $this->publishContentStream( + $virtualWorkspaceContentStreamId, $command->workspaceName, - $command->contentStreamIdForRemainingPart, - function () use ($commandHandlingDependencies, $remainingCommands) { - foreach ($remainingCommands as $remainingCommand) { - $commandHandlingDependencies->handle($remainingCommand); - } - } + $command->contentStreamIdForRemainingPart ); } catch (\Exception $exception) { // 4.E) In case of an exception, reopen the old content stream and remove the newly created @@ -533,7 +531,6 @@ function () use ($commandHandlingDependencies, $remainingCommands) { $oldWorkspaceContentStreamIdState, ) ); - $commandHandlingDependencies->handle(RemoveContentStream::create( $command->contentStreamIdForMatchingPart )); @@ -549,27 +546,23 @@ function () use ($commandHandlingDependencies, $remainingCommands) { throw $exception; } - // 8) to avoid dangling content streams, we need to remove our temporary content stream (whose events - // have already been published) as well as the old one + // 8) to avoid dangling content streams, we need to remove our temporary workspace (whose events + // have already been published) as well as the old content stream $commandHandlingDependencies->handle(RemoveContentStream::create( $command->contentStreamIdForMatchingPart )); $commandHandlingDependencies->handle(RemoveContentStream::create( $oldWorkspaceContentStreamId )); + $commandHandlingDependencies->handle(RemoveContentStream::create( + $virtualWorkspaceContentStreamId + )); $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); return new EventsToPublish( $streamName, Events::fromArray([ - new WorkspaceWasPartiallyPublished( - $command->workspaceName, - $baseWorkspace->workspaceName, - $command->contentStreamIdForRemainingPart, - $oldWorkspaceContentStreamId, - $command->nodesToPublish - ) ]), ExpectedVersion::ANY() ); @@ -855,6 +848,10 @@ private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, Comman */ private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace { + if ($workspaceName->isVirtual()) { + throw new \RuntimeException("Required workspace's name cannot be virtual", 1729379383); + } + $workspace = $commandHandlingDependencies->findWorkspaceByName($workspaceName); if (is_null($workspace)) { throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php index 1a5b65999d4..0841a017e9f 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php @@ -28,8 +28,13 @@ final class WorkspaceName implements \JsonSerializable private const PATTERN = '/^[a-z0-9][a-z0-9\-]{0,' . (self::MAX_LENGTH - 1) . '}$/'; + private const VIRTUAL_PREFIX = 'vrt-'; + public const WORKSPACE_NAME_LIVE = 'live'; + /** This is only used for virtual workspaces */ + protected ContentStreamId $contentStreamId; + /** * @var array */ @@ -96,6 +101,29 @@ public static function transliterateFromString(string $name): self return self::fromString($name); } + /** + * @internal + */ + public static function virtualFromContentStreamId(ContentStreamId $contentStreamId): self + { + $instance = self::instance(self::VIRTUAL_PREFIX . md5($contentStreamId->value)); + $instance->contentStreamId = $contentStreamId; + return $instance; + } + + public function isVirtual(): bool + { + return str_starts_with($this->value, self::VIRTUAL_PREFIX); + } + + /** + * @internal + */ + public function virtualContentStreamId(): ?ContentStreamId + { + return $this->contentStreamId ?? null; + } + public function isLive(): bool { return $this->value === self::WORKSPACE_NAME_LIVE; diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index e240adb41bc..2c3a6bcd091 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -49,6 +49,9 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even { if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance->getWorkspaceName()->isVirtual()) { + return; + } try { $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { @@ -70,6 +73,9 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event { if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance->getWorkspaceName()->isVirtual()) { + return; + } try { $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature new file mode 100644 index 00000000000..6c82bbeacbb --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature @@ -0,0 +1,68 @@ +@flowEntities @contentrepository +Feature: Test cases for partial publish to live and uri path generation + + Scenario: Create Document in another workspace and partially publish to live + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | example | source,peer,peerSpec | peerSpec->peer | + And using the following node types: + """yaml + 'Neos.Neos:Sites': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.Neos:Document': + properties: + uriPathSegment: + type: string + 'Neos.Neos:Site': + superTypes: + 'Neos.Neos:Document': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {"example":"source"} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.Neos:Sites" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "shernode-homes" | + | nodeTypeName | "Neos.Neos:Site" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | originDimensionSpacePoint | {"example":"source"} | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | baseWorkspaceName | "live" | + | workspaceTitle | "My Personal Workspace" | + | workspaceDescription | "" | + | newContentStreamId | "cs-myworkspace" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "justsomepage" | + | nodeTypeName | "Neos.Neos:Document" | + | parentNodeAggregateId | "shernode-homes" | + | originDimensionSpacePoint | {"example":"source"} | + | properties | {"uriPathSegment": "just"}| + | workspaceName | "myworkspace" | + And the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | nodesToPublish | [{"nodeAggregateId": "justsomepage", "dimensionSpacePoint": {"example":"source"}}] | + + Then I expect the documenturipath table to contain exactly: + # source: 65901ded4f068dac14ad0dce4f459b29 + # spec: 9a723c057afa02982dae9d0b541739be + # leafSpec: c60c44685475d0e2e4f2b964e6158ce2 + | dimensionspacepointhash | uripath | nodeaggregateidpath | nodeaggregateid | parentnodeaggregateid | precedingnodeaggregateid | succeedingnodeaggregateid | nodetypename | + | "2ca4fae2f65267c94c85602df0cbb728" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "fbe53ddc3305685fbb4dbf529f283a0e" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes" | "shernode-homes" | "lady-eleonode-rootford" | null | null | "Neos.Neos:Site" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes/justsomepage" | "justsomepage" | "shernode-homes" | null | null | "Neos.Neos:Document" |