Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Edit, delete and switch workspaces #4186

Merged
merged 8 commits into from
Apr 16, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@
use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPublished;
use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceRebaseFailed;
use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceWasRenamed;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceWasRemoved;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceOwnerWasChanged;
use Neos\EventStore\Model\Event\EventData;
use Neos\EventStore\Model\Event;
use Neos\EventStore\Model\Event\EventType;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged;

/**
* Central authority to convert Content Repository domain events to Event Store EventData and EventType, vice versa.
Expand Down Expand Up @@ -85,11 +89,15 @@ public function __construct()
RootNodeAggregateDimensionsWereUpdated::class,
WorkspaceRebaseFailed::class,
WorkspaceWasCreated::class,
WorkspaceWasRenamed::class,
WorkspaceWasDiscarded::class,
WorkspaceWasPartiallyDiscarded::class,
WorkspaceWasPartiallyPublished::class,
WorkspaceWasPublished::class,
WorkspaceWasRebased::class
WorkspaceWasRebased::class,
WorkspaceWasRemoved::class,
WorkspaceOwnerWasChanged::class,
WorkspaceBaseWorkspaceWasChanged::class,
];

foreach ($supportedEventClassNames as $fullEventClassName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ private function handleForkContentStream(
$sourceContentStreamVersion->unwrap(),
),
),
ExpectedVersion::ANY()
// NO_STREAM to ensure the "fork" happens as the first event of the new content stream
ExpectedVersion::NO_STREAM()
dlubitz marked this conversation as resolved.
Show resolved Hide resolved
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Command\CreateContentStream;
use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream;
use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked;
use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Command\RemoveContentStream;
use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamAlreadyExists;
use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet;
use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherContentStreamsInterface;
Expand All @@ -51,6 +52,17 @@
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\BaseWorkspaceDoesNotExist;
use Neos\ContentRepository\Core\Feature\WorkspacePublication\Exception\BaseWorkspaceHasBeenModifiedInTheMeantime;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\RenameWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceWasRemoved;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceWasRenamed;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeWorkspaceOwner;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceOwnerWasChanged;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\WorkspaceIsNotEmptyException;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\BaseWorkspaceEqualsWorkspaceException;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\CircularRelationBetweenWorkspacesException;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName;
use Neos\ContentRepository\Core\Projection\Workspace\Workspace;
Expand All @@ -61,6 +73,7 @@
use Neos\EventStore\Exception\ConcurrencyException;
use Neos\EventStore\Model\EventEnvelope;
use Neos\EventStore\Model\EventStream\ExpectedVersion;
use Neos\EventStore\Model\Event\EventType;

/**
* @internal from userland, you'll use ContentRepository::handle to dispatch commands
Expand All @@ -84,12 +97,16 @@ public function handle(CommandInterface $command, ContentRepository $contentRepo
/** @phpstan-ignore-next-line */
return match ($command::class) {
CreateWorkspace::class => $this->handleCreateWorkspace($command, $contentRepository),
RenameWorkspace::class => $this->handleRenameWorkspace($command, $contentRepository),
CreateRootWorkspace::class => $this->handleCreateRootWorkspace($command, $contentRepository),
PublishWorkspace::class => $this->handlePublishWorkspace($command, $contentRepository),
RebaseWorkspace::class => $this->handleRebaseWorkspace($command, $contentRepository),
PublishIndividualNodesFromWorkspace::class => $this->handlePublishIndividualNodesFromWorkspace($command, $contentRepository),
DiscardIndividualNodesFromWorkspace::class => $this->handleDiscardIndividualNodesFromWorkspace($command, $contentRepository),
DiscardWorkspace::class => $this->handleDiscardWorkspace($command, $contentRepository),
DeleteWorkspace::class => $this->handleDeleteWorkspace($command, $contentRepository),
ChangeWorkspaceOwner::class => $this->handleChangeWorkspaceOwner($command, $contentRepository),
ChangeBaseWorkspace::class => $this->handleChangeBaseWorkspace($command, $contentRepository),
};
}

Expand Down Expand Up @@ -146,6 +163,28 @@ private function handleCreateWorkspace(
);
}

/**
* @throws WorkspaceDoesNotExist
*/
private function handleRenameWorkspace(RenameWorkspace $command, ContentRepository $contentRepository): EventsToPublish
{
$this->requireWorkspace($command->workspaceName, $contentRepository);

$events = Events::with(
new WorkspaceWasRenamed(
$command->workspaceName,
$command->workspaceTitle,
$command->workspaceDescription,
)
);

return new EventsToPublish(
WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(),
$events,
ExpectedVersion::STREAM_EXISTS()
);
}

/**
* @param CreateRootWorkspace $command
* @return EventsToPublish
Expand Down Expand Up @@ -368,8 +407,8 @@ private function handleRebaseWorkspace(

$rebaseStatistics->commandRebaseError(sprintf(
"The content stream %s cannot be rebased. Error with command %d (%s)"
. " - see nested exception for details.\n\n The base workspace %s is at content stream %s."
. "\n The full list of commands applied so far is: %s",
. " - see nested exception for details.\n\n The base workspace %s is at content stream %s."
. "\n The full list of commands applied so far is: %s",
$workspaceContentStreamName->value,
$i,
get_class($commandToRebase),
Expand Down Expand Up @@ -693,6 +732,102 @@ private function handleDiscardWorkspace(
);
}

/**
* @throws BaseWorkspaceDoesNotExist
* @throws WorkspaceDoesNotExist
* @throws WorkspaceHasNoBaseWorkspaceName
* @throws WorkspaceIsNotEmptyException
* @throws BaseWorkspaceEqualsWorkspaceException
* @throws CircularRelationBetweenWorkspacesException
*/
private function handleChangeBaseWorkspace(
ChangeBaseWorkspace $command,
ContentRepository $contentRepository,
): EventsToPublish {
$workspace = $this->requireWorkspace($command->workspaceName, $contentRepository);
$this->requireEmptyWorkspace($workspace);
$this->requireBaseWorkspace($workspace, $contentRepository);

$baseWorkspace = $this->requireWorkspace($command->baseWorkspaceName, $contentRepository);

$this->requireNonCircularRelationBetweenWorkspaces($workspace, $baseWorkspace, $contentRepository);

$contentRepository->handle(
new ForkContentStream(
$command->newContentStreamId,
$baseWorkspace->currentContentStreamId,
)
)->block();

$streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName();
$events = Events::with(
new WorkspaceBaseWorkspaceWasChanged(
$command->workspaceName,
$command->baseWorkspaceName,
$command->newContentStreamId,
)
);

return new EventsToPublish(
$streamName,
$events,
ExpectedVersion::ANY()
);
}

/**
* @throws WorkspaceDoesNotExist
*/
private function handleDeleteWorkspace(
DeleteWorkspace $command,
ContentRepository $contentRepository,
): EventsToPublish {
$workspace = $this->requireWorkspace($command->workspaceName, $contentRepository);

$contentRepository->handle(
new RemoveContentStream(
$workspace->currentContentStreamId
)
)->block();

$events = Events::with(
new WorkspaceWasRemoved(
$command->workspaceName,
)
);

$streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName();
return new EventsToPublish(
$streamName,
$events,
ExpectedVersion::ANY()
);
}

/**
* @throws WorkspaceDoesNotExist
*/
private function handleChangeWorkspaceOwner(
ChangeWorkspaceOwner $command,
ContentRepository $contentRepository,
): EventsToPublish {
$this->requireWorkspace($command->workspaceName, $contentRepository);

$events = Events::with(
new WorkspaceOwnerWasChanged(
$command->workspaceName,
$command->newWorkspaceOwner
)
);

$streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName();
return new EventsToPublish(
$streamName,
$events,
ExpectedVersion::STREAM_EXISTS()
);
}

/**
* @throws WorkspaceDoesNotExist
*/
Expand Down Expand Up @@ -723,4 +858,57 @@ private function requireBaseWorkspace(Workspace $workspace, ContentRepository $c

return $baseWorkspace;
}

/**
* @throws BaseWorkspaceEqualsWorkspaceException
* @throws CircularRelationBetweenWorkspacesException
*/
private function requireNonCircularRelationBetweenWorkspaces(Workspace $workspace, Workspace $baseWorkspace, ContentRepository $contentRepository): void
{
if ($workspace->workspaceName->equals($baseWorkspace->workspaceName)) {
throw new BaseWorkspaceEqualsWorkspaceException(sprintf('The base workspace of the target must be different from the given workspace "%s".', $workspace->workspaceName->value));
}

$nextBaseWorkspace = $baseWorkspace;
while ($nextBaseWorkspace?->baseWorkspaceName !== null) {
if ($workspace->workspaceName->equals($nextBaseWorkspace->baseWorkspaceName)) {
throw new CircularRelationBetweenWorkspacesException(sprintf('The workspace "%s" is already on the path of the target workspace "%s".', $workspace->workspaceName->value, $baseWorkspace->workspaceName->value));
}
$nextBaseWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName($nextBaseWorkspace->baseWorkspaceName);
}
}

/**
* @throws WorkspaceIsNotEmptyException
*/
private function requireEmptyWorkspace(Workspace $workspace): void
{
$workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId(
$workspace->currentContentStreamId
);
if ($this->hasEventsInContentStreamExceptForking($workspaceContentStreamName)) {
throw new WorkspaceIsNotEmptyException('The user workspace needs to be empty before switching the base workspace.', 1681455989);
}
}

/**
* @return bool
*/
private function hasEventsInContentStreamExceptForking(
ContentStreamEventStreamName $workspaceContentStreamName,
): bool {
$workspaceContentStream = $this->eventStore->load($workspaceContentStreamName->getEventStreamName());

$fullQualifiedEventClassName = ContentStreamWasForked::class;
$shortEventClassName = substr($fullQualifiedEventClassName, strrpos($fullQualifiedEventClassName, '\\') + 1);

foreach ($workspaceContentStream as $eventEnvelope) {
if ($eventEnvelope->event->type->value === EventType::fromString($shortEventClassName)->value) {
continue;
}
return true;
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Feature\WorkspaceModification\Command;

use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;

/**
* Changes the base workspace of a given workspace, identified by $workspaceName.
*
* @api commands are the write-API of the ContentRepository
*/
final class ChangeBaseWorkspace implements CommandInterface
{
public function __construct(
public readonly WorkspaceName $workspaceName,
public readonly WorkspaceName $baseWorkspaceName,
public readonly ContentStreamId $newContentStreamId,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Feature\WorkspaceModification\Command;

use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;

/**
* Change workspace owner of a workspace, identified by $workspaceName.
* Setting $newWorkspaceOwner to null, removes the current workspace owner.
*
* @api commands are the write-API of the ContentRepository
*/
final class ChangeWorkspaceOwner implements CommandInterface
{
public function __construct(
public readonly WorkspaceName $workspaceName,
public readonly ?string $newWorkspaceOwner,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Feature\WorkspaceModification\Command;

use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;

/**
* Delete a workspace
*
* @api commands are the write-API of the ContentRepository
*/
final class DeleteWorkspace implements CommandInterface
{
public function __construct(
public readonly WorkspaceName $workspaceName,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Neos\ContentRepository\Core\Feature\WorkspaceModification\Command;

use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle;

/**
* Change the title or description of a workspace
*
* @api commands are the write-API of the ContentRepository
*/
final class RenameWorkspace implements CommandInterface
{
public function __construct(
public readonly WorkspaceName $workspaceName,
public readonly WorkspaceTitle $workspaceTitle,
public readonly WorkspaceDescription $workspaceDescription,
) {
}
}
Loading