diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature index 6472b2f5afe..dee9830c9cb 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature +++ b/Neos.ContentGraph.DoctrineDbalAdapter/Tests/Behavior/Features/Projection/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature @@ -51,8 +51,7 @@ Feature: Run integrity violation detection regarding reference relations | Key | Value | | sourceOriginDimensionSpacePoint | {"language":"de"} | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | And I detach the following reference relation from its source: | Key | Value | | contentStreamId | "cs-identifier" | diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php index 891a07e441d..7e5e2051a5f 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php @@ -16,15 +16,16 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Query\QueryBuilder; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\ContentGraph; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\NodeFactory; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; @@ -45,16 +46,11 @@ public function __construct( ) { } - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraph + public function getContentGraph(WorkspaceName $workspaceName): ContentGraph { - return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $contentStreamId); - } - - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace - { - $workspaceByNameStatement = <<tableNames->workspace()} WHERE @@ -62,9 +58,27 @@ public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace LIMIT 1 SQL; try { - $row = $this->dbal->fetchAssociative($workspaceByNameStatement, [ + $row = $this->dbal->fetchAssociative($currentContentStreamIdStatement, [ 'workspaceName' => $workspaceName->value, ]); + } catch (Exception $e) { + throw new \RuntimeException(sprintf('Failed to load current content stream id from database: %s', $e->getMessage()), 1716903166, $e); + } + if ($row === false) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + $currentContentStreamId = ContentStreamId::fromString($row['currentContentStreamId']); + return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $currentContentStreamId); + } + + public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace + { + $workspaceQuery = $this->getBasicWorkspaceQuery() + ->where('ws.name = :workspaceName') + ->setMaxResults(1) + ->setParameter('workspaceName', $workspaceName->value); + try { + $row = $workspaceQuery->fetchAssociative(); } catch (Exception $e) { throw new \RuntimeException(sprintf('Failed to load workspace from database: %s', $e->getMessage()), 1716486077, $e); } @@ -76,14 +90,9 @@ public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace public function findWorkspaces(): Workspaces { - $workspacesStatement = <<tableNames->workspace()} - SQL; + $workspacesQuery = $this->getBasicWorkspaceQuery(); try { - $rows = $this->dbal->fetchAllAssociative($workspacesStatement); + $rows = $workspacesQuery->fetchAllAssociative(); } catch (Exception $e) { throw new \RuntimeException(sprintf('Failed to load workspaces from database: %s', $e->getMessage()), 1716902981, $e); } @@ -94,7 +103,7 @@ public function findContentStreamById(ContentStreamId $contentStreamId): ?Conten { $contentStreamByIdStatement = <<tableNames->contentStream()} WHERE @@ -118,7 +127,7 @@ public function findContentStreams(): ContentStreams { $contentStreamsStatement = <<tableNames->contentStream()} SQL; @@ -145,16 +154,43 @@ public function countNodes(): int } } + private function getBasicWorkspaceQuery(): QueryBuilder + { + $queryBuilder = $this->dbal->createQueryBuilder(); + + return $queryBuilder + ->select('ws.name, ws.baseWorkspaceName, ws.currentContentStreamId, cs.hasChanges, cs.sourceContentStreamVersion = scs.version as upToDateWithBase') + ->from($this->tableNames->workspace(), 'ws') + ->join('ws', $this->tableNames->contentStream(), 'cs', 'cs.id = ws.currentcontentstreamid') + ->leftJoin('cs', $this->tableNames->contentStream(), 'scs', 'scs.id = cs.sourceContentStreamId'); + } + /** * @param array $row */ private static function workspaceFromDatabaseRow(array $row): Workspace { - return new Workspace( + $baseWorkspaceName = $row['baseWorkspaceName'] !== null ? WorkspaceName::fromString($row['baseWorkspaceName']) : null; + + if ($baseWorkspaceName === null) { + // no base workspace, a root is always up-to-date + $status = WorkspaceStatus::UP_TO_DATE; + } elseif ($row['upToDateWithBase'] === 1) { + // base workspace didnt change + $status = WorkspaceStatus::UP_TO_DATE; + } else { + // base content stream was removed or contains newer changes + $status = WorkspaceStatus::OUTDATED; + } + + return Workspace::create( WorkspaceName::fromString($row['name']), - isset($row['baseWorkspaceName']) ? WorkspaceName::fromString($row['baseWorkspaceName']) : null, + $baseWorkspaceName, ContentStreamId::fromString($row['currentContentStreamId']), - WorkspaceStatus::from($row['status']), + $status, + $baseWorkspaceName === null + ? false + : (bool)$row['hasChanges'], ); } @@ -163,12 +199,11 @@ private static function workspaceFromDatabaseRow(array $row): Workspace */ private static function contentStreamFromDatabaseRow(array $row): ContentStream { - return new ContentStream( + return ContentStream::create( ContentStreamId::fromString($row['id']), isset($row['sourceContentStreamId']) ? ContentStreamId::fromString($row['sourceContentStreamId']) : null, - ContentStreamStatus::from($row['status']), Version::fromInteger((int)$row['version']), - (bool)$row['removed'] + (bool)$row['closed'], ); } } diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index 2b6ef815f5d..39186aa616f 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -4,6 +4,7 @@ namespace Neos\ContentGraph\DoctrineDbalAdapter; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DBALException; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\ContentStream; @@ -17,13 +18,14 @@ use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\NodeRelationAnchorPoint; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\DimensionSpacePointsRepository; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Repository\ProjectionContentGraph; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\EventStore\EventInterface; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Feature\Common\EmbedsContentStreamId; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; +use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasClosed; use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened; use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Event\ContentStreamWasCreated; @@ -36,7 +38,7 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; use Neos\ContentRepository\Core\Feature\NodeMove\Event\NodeAggregateWasMoved; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReference; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\Feature\NodeRemoval\Event\NodeAggregateWasRemoved; use Neos\ContentRepository\Core\Feature\NodeRenaming\Event\NodeAggregateNameWasChanged; @@ -63,15 +65,16 @@ use Neos\ContentRepository\Core\Infrastructure\DbalSchemaDiff; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\CheckpointStorageStatusType; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeTags; use Neos\ContentRepository\Core\Projection\ContentGraph\Timestamps; use Neos\ContentRepository\Core\Projection\ProjectionStatus; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; +use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\EventEnvelope; @@ -110,24 +113,6 @@ public function setUp(): void { $statements = $this->determineRequiredSqlStatements(); - // MIGRATION from 2024-05-25: copy data from "cr__p_workspace"/"cr__p_contentstream" to "cr__p_graph_workspace"/"cr__p_graph_contentstream" tables - $legacyWorkspaceTableName = str_replace('_p_graph_workspace', '_p_workspace', $this->tableNames->workspace()); - if ( - $this->dbal->getSchemaManager()->tablesExist([$legacyWorkspaceTableName]) - && !$this->dbal->getSchemaManager()->tablesExist([$this->tableNames->workspace()]) - ) { - // we ignore the legacy fields workspacetitle, workspacedescription and workspaceowner - $statements[] = 'INSERT INTO ' . $this->tableNames->workspace() . ' (name, baseWorkspaceName, currentContentStreamId, status) SELECT workspacename AS name, baseworkspacename, currentcontentstreamid, status FROM ' . $legacyWorkspaceTableName; - } - $legacyContentStreamTableName = str_replace('_p_graph_contentstream', '_p_contentstream', $this->tableNames->contentStream()); - if ( - $this->dbal->getSchemaManager()->tablesExist([$legacyContentStreamTableName]) - && !$this->dbal->getSchemaManager()->tablesExist([$this->tableNames->contentStream()]) - ) { - $statements[] = 'INSERT INTO ' . $this->tableNames->contentStream() . ' (id, version, sourceContentStreamId, status, removed) SELECT contentStreamId AS id, version, sourceContentStreamId, state AS status, removed FROM ' . $legacyContentStreamTableName; - } - // /MIGRATION - foreach ($statements as $statement) { try { $this->dbal->executeStatement($statement); @@ -160,6 +145,7 @@ public function status(): ProjectionStatus if ($requiredSqlStatements !== []) { return ProjectionStatus::setupRequired(sprintf('The following SQL statement%s required: %s', count($requiredSqlStatements) !== 1 ? 's are' : ' is', implode(chr(10), $requiredSqlStatements))); } + return ProjectionStatus::ok(); } @@ -254,19 +240,42 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void WorkspaceWasRemoved::class => $this->whenWorkspaceWasRemoved($event), default => $event instanceof EmbedsContentStreamId || throw new \InvalidArgumentException(sprintf('Unsupported event %s', get_debug_type($event))), }; - if ($event instanceof EmbedsContentStreamId && ContentStreamEventStreamName::isContentStreamStreamName($eventEnvelope->streamName)) { - $this->updateContentStreamVersion($event->getContentStreamId(), $eventEnvelope->version); + if ( + $event instanceof EmbedsContentStreamId + && ContentStreamEventStreamName::isContentStreamStreamName($eventEnvelope->streamName) + && !( + // special case as we dont need to update anything. The handling above takes care of setting the version to 0 + $event instanceof ContentStreamWasForked + || $event instanceof ContentStreamWasCreated + ) + ) { + $this->updateContentStreamVersion($event->getContentStreamId(), $eventEnvelope->version, $event instanceof PublishableToWorkspaceInterface); + } + } + + public function inSimulation(\Closure $fn): mixed + { + if ($this->dbal->isTransactionActive()) { + throw new \RuntimeException(sprintf('Invoking %s is not allowed to be invoked recursively. Current transaction nesting %d.', __FUNCTION__, $this->dbal->getTransactionNestingLevel())); + } + $this->dbal->beginTransaction(); + $this->dbal->setRollbackOnly(); + try { + return $fn(); + } finally { + // unsets rollback only flag and allows the connection to work regular again + $this->dbal->rollBack(); } } private function whenContentStreamWasClosed(ContentStreamWasClosed $event): void { - $this->updateContentStreamStatus($event->contentStreamId, ContentStreamStatus::CLOSED); + $this->closeContentStream($event->contentStreamId); } private function whenContentStreamWasCreated(ContentStreamWasCreated $event): void { - $this->createContentStream($event->contentStreamId, ContentStreamStatus::CREATED); + $this->createContentStream($event->contentStreamId); } private function whenContentStreamWasForked(ContentStreamWasForked $event): void @@ -305,7 +314,7 @@ private function whenContentStreamWasForked(ContentStreamWasForked $event): void // NOTE: as reference edges are attached to Relation Anchor Points (and they are lazily copy-on-written), // we do not need to copy reference edges here (but we need to do it during copy on write). - $this->createContentStream($event->newContentStreamId, ContentStreamStatus::FORKED, $event->sourceContentStreamId); + $this->createContentStream($event->newContentStreamId, $event->sourceContentStreamId, $event->versionOfSourceContentStream); } private function whenContentStreamWasRemoved(ContentStreamWasRemoved $event): void @@ -355,7 +364,7 @@ private function whenContentStreamWasRemoved(ContentStreamWasRemoved $event): vo private function whenContentStreamWasReopened(ContentStreamWasReopened $event): void { - $this->updateContentStreamStatus($event->contentStreamId, $event->previousState); + $this->reopenContentStream($event->contentStreamId); } private function whenDimensionShineThroughWasAdded(DimensionShineThroughWasAdded $event): void @@ -513,6 +522,7 @@ private function whenNodeAggregateWithNodeWasCreated(NodeAggregateWithNodeWasCre $event->originDimensionSpacePoint, $event->succeedingSiblingsForCoverage, $event->initialPropertyValues, + $event->nodeReferences, $event->nodeAggregateClassification, $event->nodeName, $eventEnvelope, @@ -598,22 +608,40 @@ function (NodeRecord $node) use ($eventEnvelope) { $event->contentStreamId ); + // remove old + $deleteOldReferencesSql = <<tableNames->referenceRelation()} + WHERE nodeanchorpoint = :nodeanchorpoint + AND name in (:names) + SQL; try { - $this->dbal->delete($this->tableNames->referenceRelation(), [ - 'nodeanchorpoint' => $nodeAnchorPoint?->value, - 'name' => $event->referenceName->value - ]); - } catch (DBALException $e) { + $this->dbal->executeStatement( + $deleteOldReferencesSql, + [ + 'nodeanchorpoint' => $nodeAnchorPoint?->value, + 'names' => array_map(fn (ReferenceName $name) => $name->value, $event->references->getReferenceNames()) + ], + [ + 'names' => ArrayParameterType::STRING + ] + ); + } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to remove reference relation: %s', $e->getMessage()), 1716486309, $e); } // set new - $position = 0; - /** @var SerializedNodeReference $reference */ - foreach ($event->references as $reference) { + $nodeAnchorPoint && $this->writeReferencesForTargetAnchorPoint($event->references, $nodeAnchorPoint); + } + } + + private function writeReferencesForTargetAnchorPoint(SerializedNodeReferences $nodeReferences, NodeRelationAnchorPoint $nodeAnchorPoint): void + { + $position = 0; + foreach ($nodeReferences as $referencesByProperty) { + foreach ($referencesByProperty->references as $reference) { $referencePropertiesJson = null; - if ($reference->properties !== null) { + if ($reference->properties !== null && $reference->properties->count() > 0) { try { $referencePropertiesJson = \json_encode($reference->properties, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT); } catch (\JsonException $e) { @@ -622,13 +650,13 @@ function (NodeRecord $node) use ($eventEnvelope) { } try { $this->dbal->insert($this->tableNames->referenceRelation(), [ - 'name' => $event->referenceName->value, + 'name' => $referencesByProperty->referenceName->value, 'position' => $position, - 'nodeanchorpoint' => $nodeAnchorPoint?->value, + 'nodeanchorpoint' => $nodeAnchorPoint->value, 'destinationnodeaggregateid' => $reference->targetNodeAggregateId->value, 'properties' => $referencePropertiesJson, ]); - } catch (DBALException $e) { + } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to insert reference relation: %s', $e->getMessage()), 1716486309, $e); } $position++; @@ -710,9 +738,6 @@ private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNo private function whenRootWorkspaceWasCreated(RootWorkspaceWasCreated $event): void { $this->createWorkspace($event->workspaceName, null, $event->newContentStreamId); - - // the content stream is in use now - $this->updateContentStreamStatus($event->newContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); } private function whenSubtreeWasTagged(SubtreeWasTagged $event): void @@ -732,91 +757,41 @@ private function whenWorkspaceBaseWorkspaceWasChanged(WorkspaceBaseWorkspaceWasC private function whenWorkspaceRebaseFailed(WorkspaceRebaseFailed $event): void { - $this->markWorkspaceAsOutdatedConflict($event->workspaceName); - $this->updateContentStreamStatus($event->candidateContentStreamId, ContentStreamStatus::REBASE_ERROR); + // legacy handling: + // before https://github.com/neos/neos-development-collection/pull/4965 this event was emitted and set the content stream status to `REBASE_ERROR` + // instead of setting the error state on replay for old events we make it almost behave like if the rebase had failed today: reopen the workspaces content stream id + // the candidateContentStreamId will be removed by the ContentStreamPruner + $this->reopenContentStream($event->sourceContentStreamId); } private function whenWorkspaceWasCreated(WorkspaceWasCreated $event): void { $this->createWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); - - // the content stream is in use now - $this->updateContentStreamStatus($event->newContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); } private function whenWorkspaceWasDiscarded(WorkspaceWasDiscarded $event): void { $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); - $this->markWorkspaceAsOutdated($event->workspaceName); - $this->markDependentWorkspacesAsOutdated($event->workspaceName); - - // the new content stream is in use now - $this->updateContentStreamStatus($event->newContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); - // the previous content stream is no longer in use - $this->updateContentStreamStatus($event->previousContentStreamId, ContentStreamStatus::NO_LONGER_IN_USE); } private function whenWorkspaceWasPartiallyDiscarded(WorkspaceWasPartiallyDiscarded $event): void { $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); - $this->markDependentWorkspacesAsOutdated($event->workspaceName); - - // the new content stream is in use now - $this->updateContentStreamStatus($event->newContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); - - // the previous content stream is no longer in use - $this->updateContentStreamStatus($event->previousContentStreamId, ContentStreamStatus::NO_LONGER_IN_USE); } private function whenWorkspaceWasPartiallyPublished(WorkspaceWasPartiallyPublished $event): void { - // TODO: How do we test this method? – It's hard to design a BDD testcase that fails if this method is commented out... $this->updateWorkspaceContentStreamId($event->sourceWorkspaceName, $event->newSourceContentStreamId); - $this->markDependentWorkspacesAsOutdated($event->targetWorkspaceName); - - // NASTY: we need to set the source workspace name as non-outdated; as it has been made up-to-date again. - $this->markWorkspaceAsUpToDate($event->sourceWorkspaceName); - - $this->markDependentWorkspacesAsOutdated($event->sourceWorkspaceName); - - // the new content stream is in use now - $this->updateContentStreamStatus($event->newSourceContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); - - // the previous content stream is no longer in use - $this->updateContentStreamStatus($event->previousSourceContentStreamId, ContentStreamStatus::NO_LONGER_IN_USE); } private function whenWorkspaceWasPublished(WorkspaceWasPublished $event): void { - // TODO: How do we test this method? – It's hard to design a BDD testcase that fails if this method is commented out... $this->updateWorkspaceContentStreamId($event->sourceWorkspaceName, $event->newSourceContentStreamId); - $this->markDependentWorkspacesAsOutdated($event->targetWorkspaceName); - - // NASTY: we need to set the source workspace name as non-outdated; as it has been made up-to-date again. - $this->markWorkspaceAsUpToDate($event->sourceWorkspaceName); - - $this->markDependentWorkspacesAsOutdated($event->sourceWorkspaceName); - - // the new content stream is in use now - $this->updateContentStreamStatus($event->newSourceContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); - - // the previous content stream is no longer in use - $this->updateContentStreamStatus($event->previousSourceContentStreamId, ContentStreamStatus::NO_LONGER_IN_USE); } private function whenWorkspaceWasRebased(WorkspaceWasRebased $event): void { $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); - $this->markDependentWorkspacesAsOutdated($event->workspaceName); - - // When the rebase is successful, we can set the status of the workspace back to UP_TO_DATE. - $this->markWorkspaceAsUpToDate($event->workspaceName); - - // the new content stream is in use now - $this->updateContentStreamStatus($event->newContentStreamId, ContentStreamStatus::IN_USE_BY_WORKSPACE); - - // the previous content stream is no longer in use - $this->updateContentStreamStatus($event->previousContentStreamId, ContentStreamStatus::NO_LONGER_IN_USE); } private function whenWorkspaceWasRemoved(WorkspaceWasRemoved $event): void @@ -947,12 +922,14 @@ private function copyReferenceRelations( private static function initiatingDateTime(EventEnvelope $eventEnvelope): \DateTimeImmutable { - $initiatingTimestamp = $eventEnvelope->event->metadata?->get('initiatingTimestamp'); - $result = $initiatingTimestamp !== null ? \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $initiatingTimestamp) : $eventEnvelope->recordedAt; - if (!$result instanceof \DateTimeImmutable) { + if ($eventEnvelope->event->metadata?->has(InitiatingEventMetadata::INITIATING_TIMESTAMP) !== true) { + return $eventEnvelope->recordedAt; + } + $initiatingTimestamp = InitiatingEventMetadata::getInitiatingTimestamp($eventEnvelope->event->metadata); + if ($initiatingTimestamp === null) { throw new \RuntimeException(sprintf('Failed to extract initiating timestamp from event "%s"', $eventEnvelope->event->id->value), 1678902291); } - return $result; + return $initiatingTimestamp; } private function createNodeWithHierarchy( @@ -963,6 +940,7 @@ private function createNodeWithHierarchy( OriginDimensionSpacePoint $originDimensionSpacePoint, InterdimensionalSiblings $coverageSucceedingSiblings, SerializedPropertyValues $propertyDefaultValuesAndTypes, + SerializedNodeReferences $references, NodeAggregateClassification $nodeAggregateClassification, ?NodeName $nodeName, EventEnvelope $eventEnvelope, @@ -1018,6 +996,8 @@ private function createNodeWithHierarchy( } } } + + $this->writeReferencesForTargetAnchorPoint($references, $node->relationAnchorPoint); } private function connectHierarchy( diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php index e10b4f16c62..d19bb944f78 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php @@ -115,22 +115,25 @@ private function createWorkspaceTable(): Table DbalSchemaFactory::columnForWorkspaceName('name')->setNotnull(true), DbalSchemaFactory::columnForWorkspaceName('baseWorkspaceName')->setNotnull(false), DbalSchemaFactory::columnForContentStreamId('currentContentStreamId')->setNotNull(true), - (new Column('status', self::type(Types::BINARY)))->setLength(20)->setNotnull(false), ]); + $workspaceTable->addUniqueIndex(['currentContentStreamId']); + return $workspaceTable->setPrimaryKey(['name']); } private function createContentStreamTable(): Table { - return self::createTable($this->tableNames->contentStream(), [ + $contentStreamTable = self::createTable($this->tableNames->contentStream(), [ DbalSchemaFactory::columnForContentStreamId('id')->setNotnull(true), (new Column('version', Type::getType(Types::INTEGER)))->setNotnull(true), DbalSchemaFactory::columnForContentStreamId('sourceContentStreamId')->setNotnull(false), - // Should become a DB ENUM (unclear how to configure with DBAL) or int (latter needs adaption to code) - (new Column('status', Type::getType(Types::BINARY)))->setLength(20)->setNotnull(true), - (new Column('removed', Type::getType(Types::BOOLEAN)))->setDefault(false)->setNotnull(false) + (new Column('sourceContentStreamVersion', Type::getType(Types::INTEGER)))->setNotnull(false), + (new Column('closed', Type::getType(Types::BOOLEAN)))->setNotnull(true), + (new Column('hasChanges', Type::getType(Types::BOOLEAN)))->setNotnull(true), ]); + + return $contentStreamTable->setPrimaryKey(['id']); } /** diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStream.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStream.php index dfe1f7f46d5..08e2e151824 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStream.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/ContentStream.php @@ -5,7 +5,6 @@ namespace Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\EventStore\Model\Event\Version; /** @@ -15,39 +14,52 @@ */ trait ContentStream { - private function createContentStream(ContentStreamId $contentStreamId, ContentStreamStatus $status, ?ContentStreamId $sourceContentStreamId = null): void + private function createContentStream(ContentStreamId $contentStreamId, ?ContentStreamId $sourceContentStreamId = null, ?Version $sourceVersion = null): void { $this->dbal->insert($this->tableNames->contentStream(), [ 'id' => $contentStreamId->value, - 'sourceContentStreamId' => $sourceContentStreamId?->value, 'version' => 0, - 'status' => $status->value, + 'sourceContentStreamId' => $sourceContentStreamId?->value, + 'sourceContentStreamVersion' => $sourceVersion?->value, + 'closed' => 0, + 'hasChanges' => 0 ]); } - private function updateContentStreamStatus(ContentStreamId $contentStreamId, ContentStreamStatus $status): void + private function closeContentStream(ContentStreamId $contentStreamId): void { $this->dbal->update($this->tableNames->contentStream(), [ - 'status' => $status->value, + 'closed' => 1, ], [ 'id' => $contentStreamId->value ]); } - private function removeContentStream(ContentStreamId $contentStreamId): void + private function reopenContentStream(ContentStreamId $contentStreamId): void { $this->dbal->update($this->tableNames->contentStream(), [ - 'removed' => true, + 'closed' => 0, ], [ 'id' => $contentStreamId->value ]); } - private function updateContentStreamVersion(ContentStreamId $contentStreamId, Version $version): void + private function removeContentStream(ContentStreamId $contentStreamId): void { - $this->dbal->update($this->tableNames->contentStream(), [ + $this->dbal->delete($this->tableNames->contentStream(), [ + 'id' => $contentStreamId->value + ]); + } + + private function updateContentStreamVersion(ContentStreamId $contentStreamId, Version $version, bool $markAsDirty): void + { + $updatePayload = [ 'version' => $version->value, - ], [ + ]; + if ($markAsDirty) { + $updatePayload['hasChanges'] = 1; + } + $this->dbal->update($this->tableNames->contentStream(), $updatePayload, [ 'id' => $contentStreamId->value, ]); } diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php index 5f1e744ef96..882e4469f96 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php @@ -4,7 +4,6 @@ namespace Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -20,8 +19,7 @@ private function createWorkspace(WorkspaceName $workspaceName, ?WorkspaceName $b $this->dbal->insert($this->tableNames->workspace(), [ 'name' => $workspaceName->value, 'baseWorkspaceName' => $baseWorkspaceName?->value, - 'currentContentStreamId' => $contentStreamId->value, - 'status' => WorkspaceStatus::UP_TO_DATE->value + 'currentContentStreamId' => $contentStreamId->value ]); } @@ -55,58 +53,4 @@ private function updateWorkspaceContentStreamId( 'name' => $workspaceName->value ]); } - - private function markWorkspaceAsUpToDate(WorkspaceName $workspaceName): void - { - $this->dbal->executeStatement(' - UPDATE ' . $this->tableNames->workspace() . ' - SET status = :upToDate - WHERE - name = :workspaceName - ', [ - 'upToDate' => WorkspaceStatus::UP_TO_DATE->value, - 'workspaceName' => $workspaceName->value - ]); - } - - private function markDependentWorkspacesAsOutdated(WorkspaceName $baseWorkspaceName): void - { - $this->dbal->executeStatement(' - UPDATE ' . $this->tableNames->workspace() . ' - SET status = :outdated - WHERE - baseWorkspaceName = :baseWorkspaceName - ', [ - 'outdated' => WorkspaceStatus::OUTDATED->value, - 'baseWorkspaceName' => $baseWorkspaceName->value - ]); - } - - private function markWorkspaceAsOutdated(WorkspaceName $workspaceName): void - { - $this->dbal->executeStatement(' - UPDATE ' . $this->tableNames->workspace() . ' - SET - status = :outdated - WHERE - name = :workspaceName - ', [ - 'outdated' => WorkspaceStatus::OUTDATED->value, - 'workspaceName' => $workspaceName->value - ]); - } - - private function markWorkspaceAsOutdatedConflict(WorkspaceName $workspaceName): void - { - $this->dbal->executeStatement(' - UPDATE ' . $this->tableNames->workspace() . ' - SET - status = :outdatedConflict - WHERE - name = :workspaceName - ', [ - 'outdatedConflict' => WorkspaceStatus::OUTDATED_CONFLICT->value, - 'workspaceName' => $workspaceName->value - ]); - } } 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.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php b/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php index d4d68465df2..b6f1015661a 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/ContentHyperGraphReadModelAdapter.php @@ -11,6 +11,7 @@ use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; @@ -32,8 +33,12 @@ public function __construct( ) { } - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraphInterface + public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { + $contentStreamId = $this->findWorkspaceByName($workspaceName)?->currentContentStreamId; + if ($contentStreamId === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } return new ContentHyperGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNamePrefix, $workspaceName, $contentStreamId); } diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/Feature/NodeReferencing.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/Feature/NodeReferencing.php index 9eaaddb790b..41bf9e17850 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/Feature/NodeReferencing.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/Feature/NodeReferencing.php @@ -50,24 +50,26 @@ function (NodeRecord $node) { } ); - // remove old - $this->getDatabaseConnection()->delete($this->tableNamePrefix . '_referencerelation', [ - 'sourcenodeanchor' => $anchorPoint->value, - 'name' => $event->referenceName->value - ]); - - // set new $position = 0; - foreach ($event->references as $reference) { - $referenceRecord = new ReferenceRelationRecord( - $anchorPoint, - $event->referenceName, - $position, - $reference->properties, - $reference->targetNodeAggregateId - ); - $referenceRecord->addToDatabase($this->getDatabaseConnection(), $this->tableNamePrefix); - $position++; + foreach ($event->references as $referencesForProperty) { + // TODO can't we turn this into two atomic queries? + $this->getDatabaseConnection()->delete($this->tableNamePrefix . '_referencerelation', [ + 'sourcenodeanchor' => $anchorPoint->value, + 'name' => $referencesForProperty->referenceName->value + ]); + + foreach ($referencesForProperty->references as $reference) { + // set new + $referenceRecord = new ReferenceRelationRecord( + $anchorPoint, + $referencesForProperty->referenceName, + $position, + $reference->properties, + $reference->targetNodeAggregateId + ); + $referenceRecord->addToDatabase($this->getDatabaseConnection(), $this->tableNamePrefix); + $position++; + } } } else { throw EventCouldNotBeAppliedToContentGraph::becauseTheSourceNodeIsMissing(get_class($event)); diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php index 75b373c9c22..8c6ff9118b8 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Projection/HypergraphProjection.php @@ -213,6 +213,21 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void }; } + public function inSimulation(\Closure $fn): mixed + { + if ($this->dbal->isTransactionActive()) { + throw new \RuntimeException(sprintf('Invoking %s is not allowed to be invoked recursively. Current transaction nesting %d.', __FUNCTION__, $this->dbal->getTransactionNestingLevel())); + } + $this->dbal->beginTransaction(); + $this->dbal->setRollbackOnly(); + try { + return $fn(); + } finally { + // unsets rollback only flag and allows the connection to work regular again + $this->dbal->rollBack(); + } + } + public function getCheckpointStorage(): DbalCheckpointStorage { return $this->checkpointStorage; diff --git a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementCommandController.php b/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementCommandController.php deleted file mode 100644 index a34583a39e7..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementCommandController.php +++ /dev/null @@ -1,67 +0,0 @@ -performanceMeasurementService = $contentRepositoryRegistry->buildService( - ContentRepositoryId::fromString('default'), - $performanceMeasurementServiceFactory - ); - - parent::__construct(); - } - - /** - * Prepare the performance test by removing existing data and creating nodes for the test. - * - * @param int $nodesPerLevel The number of nodes to create per level. - * @param int $levels The number of levels in the node tree. - * @internal - */ - public function preparePerformanceTestCommand(int $nodesPerLevel, int $levels): void - { - $this->performanceMeasurementService->removeEverything(); - $this->outputLine("All removed. Starting to fill."); - GraphProjectorCatchUpHookForCacheFlushing::disabled( - fn() => $this->performanceMeasurementService->createNodesForPerformanceTest($nodesPerLevel, $levels) - ); - } - - /** - * Test the performance of forking a content stream and measure the time taken. - * - * @internal - */ - public function testPerformanceCommand(): void - { - $time = microtime(true); - $this->performanceMeasurementService->forkContentStream(); - - $timeElapsed = microtime(true) - $time; - $this->outputLine('Time: ' . $timeElapsed); - } -} diff --git a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementService.php b/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementService.php deleted file mode 100644 index cc67508bade..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementService.php +++ /dev/null @@ -1,162 +0,0 @@ -contentStreamId = contentStreamId::fromString('cs-identifier'); - $this->workspaceName = WorkspaceName::fromString('some-workspace'); - $this->dimensionSpacePoints = new DimensionSpacePointSet([ - DimensionSpacePoint::fromArray(['language' => 'mul']), - DimensionSpacePoint::fromArray(['language' => 'de']), - DimensionSpacePoint::fromArray(['language' => 'gsw']), - DimensionSpacePoint::fromArray(['language' => 'en']), - DimensionSpacePoint::fromArray(['language' => 'fr']) - ]); - - $this->contentStreamEventStream = ContentStreamEventStreamName::fromContentStreamId( - $this->contentStreamId - ); - } - - public function removeEverything(): void - { - $eventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId); - $this->connection->executeStatement('TRUNCATE ' . $this->connection->quoteIdentifier($eventTableName)); - $this->contentRepository->resetProjectionStates(); - } - - public function createNodesForPerformanceTest(int $nodesPerLevel, int $levels): void - { - $this->contentRepository->handle(CreateRootWorkspace::create( - WorkspaceName::forLive(), - $this->contentStreamId - )); - - $rootNodeAggregateId = nodeAggregateId::fromString('lady-eleonode-rootford'); - $rootNodeAggregateWasCreated = new RootNodeAggregateWithNodeWasCreated( - $this->workspaceName, - $this->contentStreamId, - $rootNodeAggregateId, - NodeTypeName::fromString('Neos.ContentRepository:Root'), - $this->dimensionSpacePoints, - NodeAggregateClassification::CLASSIFICATION_ROOT, - ); - - $this->eventPersister->publishEvents($this->contentRepository, new EventsToPublish( - $this->contentStreamEventStream->getEventStreamName(), - Events::with($rootNodeAggregateWasCreated), - ExpectedVersion::ANY() - )); - - #$time = microtime(true); - $sumSoFar = 0; - $events = []; - $this->createHierarchy($rootNodeAggregateId, 1, $levels, $nodesPerLevel, $sumSoFar, $events); - $this->eventPersister->publishEvents($this->contentRepository, new EventsToPublish( - $this->contentStreamEventStream->getEventStreamName(), - Events::fromArray($events), - ExpectedVersion::ANY() - )); - echo $sumSoFar; - #$this->outputLine(microtime(true) - $time . ' elapsed'); - } - - - /** - * @throws \Throwable - * @param array $events - */ - private function createHierarchy( - nodeAggregateId $parentNodeAggregateId, - int $currentLevel, - int $maximumLevel, - int $numberOfNodes, - int &$sumSoFar, - array &$events, - ): void { - if ($currentLevel <= $maximumLevel) { - for ($i = 0; $i < $numberOfNodes; $i++) { - $nodeAggregateId = nodeAggregateId::create(); - $events[] = new NodeAggregateWithNodeWasCreated( - $this->workspaceName, - $this->contentStreamId, - $nodeAggregateId, - NodeTypeName::fromString('Neos.ContentRepository:Testing'), - OriginDimensionSpacePoint::fromArray(['language' => 'mul']), - InterdimensionalSiblings::fromDimensionSpacePointSetWithoutSucceedingSiblings($this->dimensionSpacePoints), - $parentNodeAggregateId, - null, - SerializedPropertyValues::createEmpty(), - NodeAggregateClassification::CLASSIFICATION_REGULAR, - ); - $sumSoFar++; - $this->createHierarchy( - $nodeAggregateId, - $currentLevel + 1, - $maximumLevel, - $numberOfNodes, - $sumSoFar, - $events - ); - } - } - } - - public function forkContentStream(): void - { - $this->contentRepository->handle(ForkContentStream::create( - ContentStreamId::create(), - $this->contentStreamId, - )); - } -} diff --git a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementServiceFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementServiceFactory.php deleted file mode 100644 index 1b3c9611228..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Classes/Command/PerformanceMeasurementServiceFactory.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @internal - */ -class PerformanceMeasurementServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - public function __construct( - private readonly Connection $connection, - ) { - } - - public function build( - ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies - ): PerformanceMeasurementService { - return new PerformanceMeasurementService( - $serviceFactoryDependencies->eventPersister, - $serviceFactoryDependencies->contentRepository, - $this->connection, - $serviceFactoryDependencies->contentRepositoryId - ); - } -} diff --git a/Neos.ContentRepository.BehavioralTests/Classes/ProjectionRaceConditionTester/RaceTrackerCatchUpHookFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/ProjectionRaceConditionTester/RaceTrackerCatchUpHookFactory.php index 30467e9dd48..389d7a324a5 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/ProjectionRaceConditionTester/RaceTrackerCatchUpHookFactory.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/ProjectionRaceConditionTester/RaceTrackerCatchUpHookFactory.php @@ -14,18 +14,20 @@ namespace Neos\ContentRepository\BehavioralTests\ProjectionRaceConditionTester; -use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; /** * For full docs and context, see {@see RaceTrackerCatchUpHook} * + * @implements CatchUpHookFactoryInterface * @internal */ final class RaceTrackerCatchUpHookFactory implements CatchUpHookFactoryInterface { - public function build(ContentRepository $contentRepository): CatchUpHookInterface + public function build(CatchUpHookFactoryDependencies $dependencies): CatchUpHookInterface { return new RaceTrackerCatchUpHook(); } diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/01-RootNodeCreation/01-CreateRootNodeAggregateWithNode_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/01-RootNodeCreation/01-CreateRootNodeAggregateWithNode_ConstraintChecks.feature index be7df0bd441..10960530bf2 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/01-RootNodeCreation/01-CreateRootNodeAggregateWithNode_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/01-RootNodeCreation/01-CreateRootNodeAggregateWithNode_ConstraintChecks.feature @@ -39,7 +39,7 @@ Feature: Create a root node aggregate Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to create a root node aggregate in a closed content stream: - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | And the command CreateRootNodeAggregateWithNode is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/01-CreateNodeAggregateWithNode_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/01-CreateNodeAggregateWithNode_ConstraintChecks.feature index 6e608301510..19a5500ab86 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/01-CreateNodeAggregateWithNode_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/02-NodeCreation/01-CreateNodeAggregateWithNode_ConstraintChecks.feature @@ -53,7 +53,7 @@ Feature: Create node aggregate with node Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to create a node aggregate in a workspace whose content stream is closed: - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | And the command CreateNodeAggregateWithNode is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/03-NodeVariation/01-CreateNodeVariant_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/03-NodeVariation/01-CreateNodeVariant_ConstraintChecks.feature index cbe2098d6da..5df29d7fe45 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/03-NodeVariation/01-CreateNodeVariant_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/03-NodeVariation/01-CreateNodeVariant_ConstraintChecks.feature @@ -47,7 +47,7 @@ Feature: Create node variant Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to create a variant in a workspace that does not exist - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | And the command CreateNodeVariant is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/04-NodeModification/01-SetNodeProperties_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/04-NodeModification/01-SetNodeProperties_ConstraintChecks.feature index 3dd1295af19..968798de225 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/04-NodeModification/01-SetNodeProperties_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/04-NodeModification/01-SetNodeProperties_ConstraintChecks.feature @@ -44,7 +44,7 @@ Feature: Set node properties: Constraint checks Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to set properties in a workspace whose content stream is closed - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command SetNodeProperties is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/01-SetNodeReferences_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/01-SetNodeReferences_ConstraintChecks.feature index 7f6aa1c37ca..8ed94bcf524 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/01-SetNodeReferences_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/01-SetNodeReferences_ConstraintChecks.feature @@ -55,14 +55,13 @@ Feature: Constraint checks on SetNodeReferences | berta-destinode | Neos.ContentRepository.Testing:ReferencedNode | lady-eleonode-rootford | Scenario: Try to reference nodes in a workspace whose content stream is closed - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "ContentStreamIsClosed" # checks for contentStreamId @@ -71,8 +70,7 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | workspaceName | "i-do-not-exist" | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" with code 1513924741 # checks for sourceNodeAggregateId @@ -80,16 +78,14 @@ Feature: Constraint checks on SetNodeReferences When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "i-do-not-exist" | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" with code 1541678486 Scenario: Try to reference nodes in a root node aggregate When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "lady-eleonode-rootford" | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "NodeAggregateIsRoot" # checks for sourceOriginDimensionSpacePoint @@ -98,8 +94,7 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"undeclared":"undefined"} | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "DimensionSpacePointNotFound" with code 1505929456 Scenario: Try to reference nodes in an origin dimension space point the source node aggregate does not occupy @@ -107,8 +102,7 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"language":"en"} | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "DimensionSpacePointIsNotYetOccupied" with code 1552595396 # checks for destinationnodeAggregateIds @@ -116,40 +110,35 @@ Feature: Constraint checks on SetNodeReferences When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target":"i-do-not-exist"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"i-do-not-exist"}]}] | Then the last command should have thrown an exception of type "NodeAggregateCurrentlyDoesNotExist" with code 1541678486 Scenario: Try to reference a root node aggregate When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target":"lady-eleonode-rootford"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"lady-eleonode-rootford"}]}] | Then the last command should have thrown an exception of type "NodeAggregateIsRoot" Scenario: Try to set references exceeding the maxItems count When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "constrainedReferenceCount" | - | references | [{"target":"anthony-destinode"}, {"target":"berta-destinode"}] | + | references | [{"referenceName": "constrainedReferenceCount", "references": [{"target":"anthony-destinode"}, {"target":"berta-destinode"}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1700150156 Scenario: Try to set references exceeding the maxItems count for legacy property reference declaration When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target":"anthony-destinode"}, {"target":"berta-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode"}, {"target":"berta-destinode"}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1700150156 Scenario: Try to reference a node aggregate of a type not matching the constraints When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "constrainedReferenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "constrainedReferenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1648502149 Scenario: Try to reference a node aggregate which does not cover the source origin @@ -164,8 +153,7 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | referenceName | "referenceProperty" | - | references | [{"target":"sir-david-nodenborough"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"sir-david-nodenborough"}]}] | Then the last command should have thrown an exception of type "NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint" # checks for referenceName @@ -173,16 +161,14 @@ Feature: Constraint checks on SetNodeReferences When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "i-do-not-exist" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "i-do-not-exist", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1618670106 Scenario: Try to reference nodes in a property that is not of type reference(s): When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "nonReferenceProperty" | - | references | [{"target":"anthony-destinode"}] | + | references | [{"referenceName": "nonReferenceProperty", "references": [{"target":"anthony-destinode"}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1618670106 Scenario: Try to reference a node aggregate using a property the reference does not declare @@ -190,8 +176,7 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencePropertyWithProperties" | - | references | [{"target":"anthony-destinode", "properties":{"i-do-not-exist": "whatever"}}] | + | references | [{"referenceName": "referencePropertyWithProperties", "references": [{"target":"anthony-destinode", "properties":{"i-do-not-exist": "whatever"}}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1658406662 Scenario: Try to set a property with a value of a wrong type @@ -199,14 +184,12 @@ Feature: Constraint checks on SetNodeReferences | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencePropertyWithProperties" | - | references | [{"target":"anthony-destinode", "properties":{"postalAddress": "28 31st of February Street"}}] | + | references | [{"referenceName": "referencePropertyWithProperties", "references": [{"target":"anthony-destinode", "properties":{"postalAddress": "28 31st of February Street"}}]}] | Then the last command should have thrown an exception of type "ReferenceCannotBeSet" with code 1658406762 Scenario: Node reference cannot hold multiple targets to the same node When the command SetNodeReferences is executed with payload and exceptions are caught: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencesProperty" | - | references | [{"target":"anthony-destinode"}, {"target":"anthony-destinode"}] | - Then the last command should have thrown an exception of type "InvalidArgumentException" with code 1700150910 + | references | [{"referenceName": "referencesProperty", "references": [{"target":"anthony-destinode"}, {"target":"anthony-destinode"}]}] | + Then the last command should have thrown an exception of type "InvalidArgumentException" with code 1730365958 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/02-SetNodeReferences_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/02-SetNodeReferences_WithoutDimensions.feature index 2855e07d108..4788d11a290 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/02-SetNodeReferences_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/02-SetNodeReferences_WithoutDimensions.feature @@ -65,8 +65,7 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -82,8 +81,7 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencePropertyWithProperty" | - | references | [{"target": "anthony-destinode", "properties":{"text":"my text", "dayOfWeek":"DayOfWeek:https://schema.org/Friday", "postalAddress":"PostalAddress:dummy"}}] | + | references | [{"referenceName": "referencePropertyWithProperty", "references": [{"target": "anthony-destinode", "properties":{"text":"my text", "dayOfWeek":"DayOfWeek:https://schema.org/Friday", "postalAddress":"PostalAddress:dummy"}}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -99,8 +97,7 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencesProperty" | - | references | [{"target": "berta-destinode"}, {"target": "carl-destinode"}] | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "berta-destinode"}, {"target": "carl-destinode"}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -122,8 +119,7 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referencesPropertyWithProperty" | - | references | [{"target":"berta-destinode", "properties":{"text":"my text", "dayOfWeek":"DayOfWeek:https://schema.org/Wednesday", "postalAddress":"PostalAddress:dummy"}}, {"target":"carl-destinode", "properties":{"text":"my other text", "dayOfWeek":"DayOfWeek:https://schema.org/Friday", "postalAddress":"PostalAddress:anotherDummy"}}] | + | references | [{"referenceName": "referencesPropertyWithProperty", "references": [{"target":"berta-destinode", "properties":{"text":"my text", "dayOfWeek":"DayOfWeek:https://schema.org/Wednesday", "postalAddress":"PostalAddress:dummy"}}, {"target":"carl-destinode", "properties":{"text":"my other text", "dayOfWeek":"DayOfWeek:https://schema.org/Friday", "postalAddress":"PostalAddress:anotherDummy"}}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -145,14 +141,12 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "berta-destinode"}, {"target": "carl-destinode"}] | - | referenceName | "referencesProperty" | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "berta-destinode"}, {"target": "carl-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "anthony-destinode"}] | - | referenceName | "referencesProperty" | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "anthony-destinode"}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -175,14 +169,12 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "berta-destinode"}, {"target": "carl-destinode"}] | - | referenceName | "referencesProperty" | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "berta-destinode"}, {"target": "carl-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "carl-destinode"}, {"target": "berta-destinode"}] | - | referenceName | "referencesProperty" | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "carl-destinode"}, {"target": "berta-destinode"}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: @@ -195,14 +187,12 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "berta-destinode"}, {"target": "carl-destinode"}] | - | referenceName | "referencesProperty" | + | references | [{"referenceName": "referencesProperty", "references": [{"target": "berta-destinode"}, {"target": "carl-destinode"}]}] | And the command SetNodeReferences is executed with payload: - | Key | Value | - | sourceNodeAggregateId | "source-nodandaise" | - | references | [] | - | referenceName | "referencesProperty" | + | Key | Value | + | sourceNodeAggregateId | "source-nodandaise" | + | references | [{"referenceName": "referencesProperty", "references": []}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} @@ -219,14 +209,12 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "anthony-destinode"}] | - | referenceName | "referenceProperty" | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "node-wan-kenodi" | - | references | [{"target": "anthony-destinode"}] | - | referenceName | "referenceProperty" | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | Then I expect node aggregate identifier "anthony-destinode" to lead to node cs-identifier;anthony-destinode;{} And I expect this node to be referenced by: @@ -238,8 +226,7 @@ Feature: Node References without Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | references | [{"target": "anthony-destinode"}] | - | referenceName | "restrictedReferenceProperty" | + | references | [{"referenceName": "restrictedReferenceProperty", "references": [{"target": "anthony-destinode"}]}] | Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{} And I expect this node to have the following references: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/03-SetNodeReferences_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/03-SetNodeReferences_WithDimensions.feature index d61dd0a983c..7a428a007a2 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/03-SetNodeReferences_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/03-SetNodeReferences_WithDimensions.feature @@ -41,8 +41,7 @@ Feature: Node References with Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | When I am in workspace "live" and dimension space point {"language": "de"} Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{"language": "de"} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/04-SetNodeReferences_PropertyScopes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/04-SetNodeReferences_PropertyScopes.feature index 61ae3096764..48c724d3e40 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/04-SetNodeReferences_PropertyScopes.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/04-SetNodeReferences_PropertyScopes.feature @@ -63,55 +63,108 @@ Feature: Set node properties with different scopes | sourceOrigin | {"language":"mul"} | | targetOrigin | {"language":"gsw"} | - Scenario: Set node properties + Scenario: Set node references in separate commands And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "unscopedReference" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "unscopedReference", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "unscopedReferences" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "unscopedReferences", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "nodeScopedReference" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "nodeScopedReference", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "nodeScopedReferences" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "nodeScopedReferences", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "nodeAggregateScopedReference" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "nodeAggregateScopedReference", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "nodeAggregateScopedReferences" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "nodeAggregateScopedReferences", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "specializationsScopedReference" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "specializationsScopedReference", "references": [{"target": "anthony-destinode"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "specializationsScopedReferences" | | sourceOriginDimensionSpacePoint | {"language": "de"} | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "specializationsScopedReferences", "references": [{"target": "anthony-destinode"}]}] | + + When I am in workspace "live" and dimension space point {"language": "mul"} + Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{"language": "mul"} + And I expect this node to have the following references: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | nodeAggregateScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + + And I expect node aggregate identifier "anthony-destinode" to lead to node cs-identifier;anthony-destinode;{"language": "mul"} + And I expect this node to be referenced by: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;source-nodandaise;{"language": "mul"} | null | + | nodeAggregateScopedReferences | cs-identifier;source-nodandaise;{"language": "mul"} | null | + + When I am in workspace "live" and dimension space point {"language": "de"} + Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{"language": "de"} + And I expect this node to have the following references: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | nodeAggregateScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | nodeScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | nodeScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | specializationsScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | specializationsScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | unscopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | unscopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + And I expect node aggregate identifier "anthony-destinode" to lead to node cs-identifier;anthony-destinode;{"language": "mul"} + And I expect this node to be referenced by: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;source-nodandaise;{"language": "de"} | null | + | nodeAggregateScopedReferences | cs-identifier;source-nodandaise;{"language": "de"} | null | + | nodeScopedReference | cs-identifier;source-nodandaise;{"language": "de"} | null | + | nodeScopedReferences | cs-identifier;source-nodandaise;{"language": "de"} | null | + | specializationsScopedReference | cs-identifier;source-nodandaise;{"language": "de"} | null | + | specializationsScopedReferences | cs-identifier;source-nodandaise;{"language": "de"} | null | + | unscopedReference | cs-identifier;source-nodandaise;{"language": "de"} | null | + | unscopedReferences | cs-identifier;source-nodandaise;{"language": "de"} | null | + + When I am in workspace "live" and dimension space point {"language": "gsw"} + Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{"language": "gsw"} + And I expect this node to have the following references: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | nodeAggregateScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | specializationsScopedReference | cs-identifier;anthony-destinode;{"language": "mul"} | null | + | specializationsScopedReferences | cs-identifier;anthony-destinode;{"language": "mul"} | null | + And I expect node aggregate identifier "anthony-destinode" to lead to node cs-identifier;anthony-destinode;{"language": "mul"} + And I expect this node to be referenced by: + | Name | Node | Properties | + | nodeAggregateScopedReference | cs-identifier;source-nodandaise;{"language": "gsw"} | null | + | nodeAggregateScopedReferences | cs-identifier;source-nodandaise;{"language": "gsw"} | null | + | specializationsScopedReference | cs-identifier;source-nodandaise;{"language": "gsw"} | null | + | specializationsScopedReferences | cs-identifier;source-nodandaise;{"language": "gsw"} | null | + + + Scenario: Set node properties in single command + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "source-nodandaise" | + | sourceOriginDimensionSpacePoint | {"language": "de"} | + | references | [{"referenceName": "unscopedReference", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "unscopedReferences", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "nodeScopedReference", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "nodeScopedReferences", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "nodeAggregateScopedReference", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "nodeAggregateScopedReferences", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "specializationsScopedReference", "references": [{"target": "anthony-destinode"}]}, {"referenceName": "specializationsScopedReferences", "references": [{"target": "anthony-destinode"}]}]| When I am in workspace "live" and dimension space point {"language": "mul"} Then I expect node aggregate identifier "source-nodandaise" to lead to node cs-identifier;source-nodandaise;{"language": "mul"} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/05-NodeVariation_After_NodeReferencing.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/05-NodeVariation_After_NodeReferencing.feature index a9ec1e9d54a..ac7f2645431 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/05-NodeVariation_After_NodeReferencing.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/05-NodeReferencing/05-NodeVariation_After_NodeReferencing.feature @@ -41,8 +41,7 @@ Feature: Node References with Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | When the command CreateNodeVariant is executed with payload: | Key | Value | @@ -78,8 +77,7 @@ Feature: Node References with Dimensions | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"language": "ch"} | - | referenceName | "referenceProperty" | - | references | [{"target": "source-nodandaise"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "source-nodandaise"}]}] | # reference to self (modified 2 lines above) When I am in workspace "live" and dimension space point {"language": "ch"} @@ -113,8 +111,7 @@ Feature: Node References with Dimensions | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"language": "ch"} | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | # on the specialization, the reference exists. @@ -148,8 +145,7 @@ Feature: Node References with Dimensions When the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | When the command CreateNodeVariant is executed with payload: | Key | Value | @@ -195,8 +191,7 @@ Feature: Node References with Dimensions | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | | sourceOriginDimensionSpacePoint | {"language": "en"} | - | referenceName | "referenceProperty" | - | references | [{"target": "source-nodandaise"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "source-nodandaise"}]}] | # reference to self (modified 2 lines above) When I am in workspace "live" and dimension space point {"language": "en"} @@ -242,8 +237,7 @@ Feature: Node References with Dimensions | Key | Value | | sourceNodeAggregateId | "ch-only" | | sourceOriginDimensionSpacePoint | {"language": "ch"} | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | # here we generalize When the command CreateNodeVariant is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature index 4a353636c61..91c845e2b27 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/01-DisableNodeAggregate_ConstraintChecks.feature @@ -38,7 +38,7 @@ Feature: Constraint checks on node aggregate disabling Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to disable a node aggregate in a workspace whose content stream is closed - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command DisableNodeAggregate is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature index 67181a191e0..9146edf8d64 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature @@ -35,8 +35,7 @@ Feature: Disable a node aggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "preceding-nodenborough" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | Scenario: Disable node with arbitrary strategy since dimensions are not involved When the command DisableNodeAggregate is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature index 356eccb0f7d..ebfef8c33d5 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature @@ -37,8 +37,7 @@ Feature: Disable a node aggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "preceding-nodenborough" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | # We need both a real and a virtual specialization to test the different selection strategies And the command CreateNodeVariant is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature index 6a77ec9a4d5..1004173e4b4 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature @@ -35,8 +35,7 @@ Feature: Enable a node aggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "preceding-nodenborough" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | Scenario: Enable a previously disabled node with arbitrary strategy since dimensions are not involved Given the command DisableNodeAggregate is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature index 49c6a27f297..929293a9a61 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature @@ -38,8 +38,7 @@ Feature: Enable a node aggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "preceding-nodenborough" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | # We need both a real and a virtual specialization to test the different selection strategies And the command CreateNodeVariant is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/01-RemoveNodeAggregate_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/01-RemoveNodeAggregate_ConstraintChecks.feature index c11417b9ad0..e7e35cb4a5a 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/01-RemoveNodeAggregate_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/01-RemoveNodeAggregate_ConstraintChecks.feature @@ -43,7 +43,7 @@ Feature: Remove NodeAggregate Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to remove a node aggregate in a workspace whose content stream is closed - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command RemoveNodeAggregate is executed with payload and exceptions are caught: 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..9f00f7d69fc 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 @@ -34,8 +34,7 @@ Feature: Remove NodeAggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "nodingers-cat" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | Scenario: Remove a node aggregate When the command RemoveNodeAggregate is executed with payload: @@ -122,3 +121,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/07-NodeRemoval/03-RemoveNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/03-RemoveNodeAggregate_WithDimensions.feature index 7a39d74b7ab..27be85fbf2b 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/03-RemoveNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/07-NodeRemoval/03-RemoveNodeAggregate_WithDimensions.feature @@ -36,8 +36,7 @@ Feature: Remove NodeAggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "nodingers-cat" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | Scenario: Remove a node aggregate with strategy allSpecializations When the command RemoveNodeAggregate is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/08-NodeMove/01-MoveNodes_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/08-NodeMove/01-MoveNodes_ConstraintChecks.feature index 3ebce961613..37f9a1c8523 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/08-NodeMove/01-MoveNodes_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/08-NodeMove/01-MoveNodes_ConstraintChecks.feature @@ -59,7 +59,7 @@ Feature: Move node to a new parent / within the current parent before a sibling Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to move a node in a workspace whose content stream is closed: - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command MoveNodeAggregate is executed with payload and exceptions are caught: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/09-NodeRenaming/01-ChangeNodeAggregateName_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/09-NodeRenaming/01-ChangeNodeAggregateName_ConstraintChecks.feature index 50c499912b4..b8a003c26a4 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/09-NodeRenaming/01-ChangeNodeAggregateName_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/09-NodeRenaming/01-ChangeNodeAggregateName_ConstraintChecks.feature @@ -43,7 +43,7 @@ Feature: Change node name Then the last command should have thrown an exception of type "WorkspaceDoesNotExist" Scenario: Try to rename a node aggregate in a workspace whose content stream is closed: - When the command CloseContentStream is executed with payload: + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | When the command ChangeNodeAggregateName is executed with payload and exceptions are caught: 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..9ee1fafd150 --- /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 event ContentStreamWasClosed was published 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/ContentStreamClosing/01-CloseContentStream_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamClosing/01-CloseContentStream_ConstraintChecks.feature deleted file mode 100644 index 595c2e53055..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamClosing/01-CloseContentStream_ConstraintChecks.feature +++ /dev/null @@ -1,30 +0,0 @@ -@contentrepository @adapters=DoctrineDBAL -Feature: Constraint check test cases for closing content streams - - Background: - Given using no content dimensions - And using the following node types: - """yaml - Neos.ContentRepository:Root: {} - """ - 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" | - - Scenario: Try to close a non-existing content stream: - And the command CloseContentStream is executed with payload and exceptions are caught: - | Key | Value | - | contentStreamId | "i-do-not-exist" | - Then the last command should have thrown an exception of type "ContentStreamDoesNotExistYet" - - Scenario: Try to close a content stream that is already closed: - When the command CloseContentStream is executed with payload: - | Key | Value | - | contentStreamId | "cs-identifier" | - And the command CloseContentStream is executed with payload and exceptions are caught: - | Key | Value | - | contentStreamId | "cs-identifier" | - Then the last command should have thrown an exception of type "ContentStreamIsClosed" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/01-ForkContentStream_ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/01-ForkContentStream_ConstraintChecks.feature index 8e1d7e8a71d..2aa54f9856f 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/01-ForkContentStream_ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/01-ForkContentStream_ConstraintChecks.feature @@ -49,12 +49,13 @@ Feature: ForkContentStream Without Dimensions | propertyValues | {"text": {"value": "original value", "type": "string"}} | | propertiesToUnset | {} | - Scenario: Try to fork a content stream that is closed: - When the command CloseContentStream is executed with payload: + Scenario: Try to create a workspace with the base workspace referring to a closed content stream + When the event ContentStreamWasClosed was published with payload: | Key | Value | | contentStreamId | "cs-identifier" | - When the command ForkContentStream is executed with payload and exceptions are caught: - | Key | Value | - | contentStreamId | "user-cs-identifier" | - | sourceContentStreamId | "cs-identifier" | + When the command CreateWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | Then the last command should have thrown an exception of type "ContentStreamIsClosed" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/NodeReferencesOnForkContentStream.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/NodeReferencesOnForkContentStream.feature index bf1988c7386..7049a063589 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/NodeReferencesOnForkContentStream.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/NodeReferencesOnForkContentStream.feature @@ -40,8 +40,7 @@ Feature: On forking a content stream, node references should be copied as well. Given the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "source-nodandaise" | - | referenceName | "referenceProperty" | - | references | [{"target": "anthony-destinode"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target": "anthony-destinode"}]}] | # Uses ForkContentStream implicitly When the command CreateWorkspace is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature index e579399e2c4..c8c036f9ed2 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeCopying/CopyNode_NoDimensions.feature @@ -5,7 +5,9 @@ Feature: Copy nodes (without dimensions) Given using no content dimensions And using the following node types: """yaml - 'Neos.ContentRepository.Testing:Document': [] + 'Neos.ContentRepository.Testing:Document': + references: + ref: [] """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -65,3 +67,24 @@ Feature: Copy nodes (without dimensions) | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | Then I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + + Scenario: Copy References + When I am in workspace "live" and dimension space point {} + And the command SetNodeReferences is executed with payload: + | Key | Value | + | sourceNodeAggregateId | "sir-nodeward-nodington-iii" | + | references | [{"referenceName": "ref", "references": [{"target": "sir-david-nodenborough"}]}] | + + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} + And the command CopyNodesRecursively is executed, copying the current node aggregate with payload: + | Key | Value | + | targetDimensionSpacePoint | {} | + | targetParentNodeAggregateId | "nody-mc-nodeface" | + | targetNodeName | "target-nn" | + | targetSucceedingSiblingnodeAggregateId | null | + | nodeAggregateIdMapping | {"sir-nodeward-nodington-iii": "sir-nodeward-nodington-iii-copy"} | + + And I expect node aggregate identifier "sir-nodeward-nodington-iii-copy" to lead to node cs-identifier;sir-nodeward-nodington-iii-copy;{} + And I expect this node to have the following references: + | Name | Node | Properties | + | ref | cs-identifier;sir-david-nodenborough;{} | null | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature index d583afa77fa..8652a6a8252 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature @@ -35,8 +35,7 @@ Feature: Disable a node aggregate And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "preceding-nodenborough" | - | referenceName | "references" | - | references | [{"target": "sir-david-nodenborough"}] | + | references | [{"referenceName": "references", "references": [{"target": "sir-david-nodenborough"}]}] | Scenario: Restore a hidden node by removing and recreating it Given the command DisableNodeAggregate is executed with payload: 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.BehavioralTests/Tests/Behavior/Features/NodeTraversal/References.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/References.feature index 6d86e673a29..2dca1661699 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/References.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/References.feature @@ -93,54 +93,45 @@ Feature: Find and count references and their target nodes using the findReferenc And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "a" | - | referenceName | "refs" | - | references | [{"target":"b1"}, {"target":"a2a2"}] | + | references | [{"referenceName": "refs", "references": [{"target":"b1"}, {"target":"a2a2"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "b1" | - | referenceName | "ref" | - | references | [{"target":"a"}] | + | references | [{"referenceName": "ref", "references": [{"target":"a"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "b" | - | referenceName | "refs" | - | references | [{"target":"a2", "properties": {"foo": "bar"}}, {"target":"a2a1", "properties": {"foo": "baz"}}] | + | references | [{"referenceName": "refs", "references": [{"target":"a2", "properties": {"foo": "bar"}}, {"target":"a2a1", "properties": {"foo": "baz"}}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "a" | - | referenceName | "ref" | - | references | [{"target":"b1", "properties": {"foo": "bar"}}] | + | references | [{"referenceName": "ref", "references": [{"target":"b1", "properties": {"foo": "bar"}}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "a2" | - | referenceName | "ref" | - | references | [{"target":"a2a3"}] | + | references | [{"referenceName": "ref", "references": [{"target":"a2a3"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "a2a3" | - | referenceName | "ref" | - | references | [{"target":"a2"}] | + | references | [{"referenceName": "ref", "references": [{"target":"a2"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "b" | - | referenceName | "refs" | - | references | [{"target":"a3", "properties": {"foo": "bar"}}] | + | references | [{"referenceName": "refs", "references": [{"target":"a3", "properties": {"foo": "bar"}}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "c" | - | referenceName | "refs" | - | references | [{"target":"b1", "properties": {"foo": "foos"}}] | + | references | [{"referenceName": "refs", "references": [{"target":"b1", "properties": {"foo": "foos"}}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | sourceNodeAggregateId | "c" | - | referenceName | "ref" | - | references | [{"target":"b"}] | + | references | [{"referenceName": "ref", "references": [{"target":"b"}]}] | And the command DisableNodeAggregate is executed with payload: | Key | Value | | nodeAggregateId | "a2a3" | | nodeVariantSelectionStrategy | "allVariants" | - Scenario: + Scenario: Check consistency of findReferences results # findReferences queries without results When I execute the findReferences query for node aggregate id "non-existing" I expect no references to be returned When I execute the findReferences query for node aggregate id "c" and filter '{"nodeTypes": "Neos.ContentRepository.Testing:NonExisting"}' I expect no references to be returned diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature index 02601b06f70..b4fffab11f3 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeTraversal/Timestamps.feature @@ -177,8 +177,7 @@ Feature: Behavior of Node timestamp properties "created", "originalCreated", "la | workspaceName | "user-test" | | sourceOriginDimensionSpacePoint | {"language": "ch"} | | sourceNodeAggregateId | "a" | - | referenceName | "ref" | - | references | [{"target": "b"}] | + | references | [{"referenceName": "ref", "references": [{"target": "b"}]}] | And I am in workspace "user-test" and dimension space point {"language":"de"} Then I expect the node "a" to have the following timestamps: | created | originalCreated | lastModified | originalLastModified | @@ -359,3 +358,19 @@ Feature: Behavior of Node timestamp properties "created", "originalCreated", "la And I expect the node "b" to have the following timestamps: | created | originalCreated | lastModified | originalLastModified | | 2023-03-16 15:00:00 | 2023-03-16 12:00:00 | | | + + Scenario: Original created when rebasing and partially publishing nodes + And I am in workspace "user-test" and dimension space point {"language":"de"} + Then I expect the node "a" to have the following timestamps: + | created | originalCreated | lastModified | originalLastModified | + | 2023-03-16 12:00:00 | 2023-03-16 12:00:00 | null | null | + + Given the current date and time is "2023-03-16T14:00:00+01:00" + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebaseErrorHandlingStrategy | "force" | + + Then I expect the node "a" to have the following timestamps: + | created | originalCreated | lastModified | originalLastModified | + | 2023-03-16 14:00:00 | 2023-03-16 12:00:00 | null | null | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/IntactContentGraph.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/IntactContentGraph.feature index 06612e203d8..cd6d43d1c20 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/IntactContentGraph.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/IntactContentGraph.feature @@ -66,7 +66,6 @@ Feature: Create an intact content graph and run integrity violation detection | contentStreamId | "cs-identifier" | | sourceNodeAggregateId | "nody-mc-nodeface" | | affectedSourceOriginDimensionSpacePoints | [{"language":"de"}] | - | referenceName | "referenceProperty" | - | references | [{"targetNodeAggregateId":"sir-david-nodenborough", "properties":null}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"sir-david-nodenborough", "properties":null}]}] | And I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 0 errors diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature index 8b3348b6931..ef1d85b4647 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ProjectionIntegrityViolationDetection/ReferenceIntegrityIsProvided.feature @@ -40,8 +40,7 @@ Feature: Run integrity violation detection regarding reference relations | contentStreamId | "cs-identifier" | | sourceNodeAggregateId | "source-nodandaise" | | affectedSourceOriginDimensionSpacePoints | [{"language":"de"}] | - | referenceName | "referenceProperty" | - | references | [{"targetNodeAggregateId":"anthony-destinode", "properties":null}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode", "properties":null}]}] | And I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 1 error And I expect integrity violation detection result error number 1 to have code 1597919585 @@ -63,8 +62,7 @@ Feature: Run integrity violation detection regarding reference relations | contentStreamId | "cs-identifier" | | sourceNodeAggregateId | "source-nodandaise" | | affectedSourceOriginDimensionSpacePoints | [{"language":"de"}] | - | referenceName | "referenceProperty" | - | references | [{"targetNodeAggregateId":"anthony-destinode", "properties":null}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"anthony-destinode", "properties":null}]}] | And I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 1 error And I expect integrity violation detection result error number 1 to have code 1597919585 diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature index ceb7d58509c..7c5c5a7c002 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/01-ConstraintChecks.feature @@ -72,7 +72,9 @@ Feature: Workspace discarding - complex chained functionality | workspaceName | "user-ws" | | nodesToDiscard | [{"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}] | | newContentStreamId | "user-cs-id-rebased" | - Then the last command should have thrown an exception of type "NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint" + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 11 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command DiscardWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature index 24a0de90eb7..94c2077c647 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W10-IndividualNodeDiscarding/02-BasicFeatures.feature @@ -29,44 +29,38 @@ Feature: Discard individual nodes (basics) | Key | Value | | workspaceName | "live" | | newContentStreamId | "cs-identifier" | - And I am in workspace "live" + And I am in workspace "live" and dimension space point {} And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t1"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t1"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | - | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "sir-david-nodenborough" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t2"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t2"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-nodeward-nodington-iii" | | nodeTypeName | "Neos.ContentRepository.Testing:Image" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"image": {"type": "string", "value": "Initial image"}} | - | nodeAggregateClassification | "regular" | + | initialPropertyValues | {"image": "Initial image"} | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeTypeName | "Neos.ContentRepository.Testing:Image" | + | parentNodeAggregateId | "lady-eleonode-rootford" | # Create user workspace And the command CreateWorkspace is executed with payload: @@ -104,7 +98,7 @@ Feature: Discard individual nodes (basics) | workspaceName | "user-test" | | nodesToDiscard | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | newContentStreamId | "user-cs-identifier-new" | - + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} @@ -119,28 +113,45 @@ Feature: Discard individual nodes (basics) And I expect this node to have the following properties: | Key | Value | | image | "Initial image" | - - Scenario: It is possible to discard no node + Scenario: Discard no node, non existing ones or unchanged nodes is a no-op + # no node When the command DiscardIndividualNodesFromWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodesToDiscard | [] | | newContentStreamId | "user-cs-identifier-new" | + Then I expect the content stream "user-cs-identifier-new" to not exist + + # unchanged or non existing nodes + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToDiscard | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing-node"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | newContentStreamId | "user-cs-identifier-new-two" | + # all nodes are still on the original user cs When I am in workspace "user-test" and dimension space point {} - Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t1" | - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-new;nody-mc-nodeface;{} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t2" | - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier-new;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier;sir-nodeward-nodington-iii;{} And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + # assert that content stream is still open by writing to it: + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {} | + | propertyValues | {"image": "Bla bli blub"} | + Scenario: It is possible to discard all nodes When the command DiscardIndividualNodesFromWorkspace is executed with payload: | Key | Value | @@ -148,6 +159,14 @@ Feature: Discard individual nodes (basics) | nodesToDiscard | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "nody-mc-nodeface"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | newContentStreamId | "user-cs-identifier-new" | + # when discarding all nodes we expect a full discard via WorkspaceWasDiscarded + Then I expect exactly 2 events to be published on stream with prefix "Workspace:user-test" + And event at index 1 is of type "WorkspaceWasDiscarded" with payload: + | Key | Expected | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-identifier-new" | + | previousContentStreamId | "user-cs-identifier" | + When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-new;sir-david-nodenborough;{} And I expect this node to have the following properties: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/01-ConstraintChecks.feature new file mode 100644 index 00000000000..51e3a70cc2d --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/01-ConstraintChecks.feature @@ -0,0 +1,115 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Change base workspace constraints + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared" | + | baseWorkspaceName | "live" | + | newContentStreamId | "shared-cs-identifier" | + + Scenario: Changing the base workspace is not allowed for root workspaces + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "groot" | + | newContentStreamId | "cs-groot-identifier" | + + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "live" | + | baseWorkspaceName | "groot" | + + Then the last command should have thrown an exception of type "WorkspaceHasNoBaseWorkspaceName" + + Scenario: Changing the base workspace is not allowed if there are pending changes + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "holy-nody" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "New node in shared"} | + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to node user-cs-identifier;holy-nody;{} + + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "user-rebased-cs-identifier" | + + Then the last command should have thrown an exception of type "WorkspaceIsNotEmptyException" + + Scenario: Changing the base workspace does not work if the new base is the current workspace (cyclic) + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "user-test" | + + Then the last command should have thrown an exception of type "BaseWorkspaceEqualsWorkspaceException" + + Scenario: Changing the base workspace does not work if the new base is a base of the current (cyclic) + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared-branched" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "shared-branched-cs-identifier" | + + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "shared" | + | baseWorkspaceName | "shared-branched" | + + Then the last command should have thrown an exception of type "CircularRelationBetweenWorkspacesException" + + Scenario: Changing the base workspace does not work if the new base complexly cyclic + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared-a" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "shared-a-cs-identifier" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared-a1" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "shared-a1-cs-identifier" | + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "shared" | + | baseWorkspaceName | "shared-a1" | + Then the last command should have thrown an exception of type "CircularRelationBetweenWorkspacesException" diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/02-BasicFeatures.feature new file mode 100644 index 00000000000..bc4616b5ce6 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W11-ChangeBaseWorkspace/02-BasicFeatures.feature @@ -0,0 +1,138 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Change base workspace works :D what else + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Original text"} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared" | + | baseWorkspaceName | "live" | + | newContentStreamId | "shared-cs-identifier" | + + Scenario: Change base workspace is a no-op if the base already matches + When the command ChangeBaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + + Then I expect exactly 1 event to be published on stream "Workspace:user-test" + And event at index 0 is of type "WorkspaceWasCreated" with payload: + | Key | Expected | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + Scenario: Change base workspace is a no-op if the base already matches but the workspace is outdated + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "holy-nody" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "New node in live"} | + + Then workspaces user-test has status OUTDATED + + When the command ChangeBaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 event to be published on stream "Workspace:user-test" + And event at index 0 is of type "WorkspaceWasCreated" with payload: + | Key | Expected | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to no node + + Given I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to node cs-identifier;holy-nody;{} + + Scenario: Change base workspace if user has no changes and is up to date with new base + When the command ChangeBaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "user-rebased-cs-identifier" | + + Then workspaces user-test has status UP_TO_DATE + + Given I am in workspace "user-test" and dimension space point {} + # todo no fork needed? + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-rebased-cs-identifier;nody-mc-nodeface;{} + + Scenario: Change base workspace if user has no changes and is not up to date with new base + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "shared" | + | nodeAggregateId | "holy-nody" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "New node in shared"} | + + Given I am in workspace "shared" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to node shared-cs-identifier;holy-nody;{} + + When the command ChangeBaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "user-rebased-cs-identifier" | + + Then workspaces user-test has status UP_TO_DATE + + Given I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "holy-nody" to lead to node user-rebased-cs-identifier;holy-nody;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "New node in shared" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature new file mode 100644 index 00000000000..e080cf4d6bb --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/01-BasicFeatures.feature @@ -0,0 +1,120 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Rebasing with no conflict + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': {} + 'Neos.ContentRepository.Testing:Document': + childNodes: + child1: + type: 'Neos.ContentRepository.Testing:Content' + child2: + type: 'Neos.ContentRepository.Testing:Content' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + When I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + + # Create user workspace + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + Then workspaces live,user-test have status UP_TO_DATE + + Scenario: Rebase is a no-op if there are no changes + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-rebased" to not exist + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} + + # only if the force flag is used we enforce a fork: + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + | rebaseErrorHandlingStrategy | "force" | + Then I expect the content stream "user-cs-identifier" to not exist + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-rebased;sir-david-nodenborough;{} + + Scenario: Rebase only the base contains changes + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + Then workspaces user-test has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-identifier" to not exist + Then workspaces live,user-test have status UP_TO_DATE + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-rebased;sir-nodeward-nodington-iii;{} + + + Scenario: Rebase workspace and base contains changes + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "nordisch-nodel" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + Then workspaces user-test has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | + Then I expect the content stream "user-cs-identifier" to not exist + + Then workspaces live,user-test have status UP_TO_DATE + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node cs-identifier;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "nordisch-nodel" to lead to no node + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-rebased;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "nordisch-nodel" to lead to node user-cs-rebased;nordisch-nodel;{} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature index 3272dcc1af5..39eca525c82 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/02-RebasingWithAutoCreatedNodes.feature @@ -47,7 +47,7 @@ Feature: Rebasing auto-created nodes works Scenario: complex scenario (to reproduce the bug) -- see the feature description # USER workspace: create a new node with auto-created child nodes - When the command CreateNodeAggregateWithNodeAndSerializedProperties is executed with payload: + When the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodeAggregateId | "nody-mc-nodeface" | @@ -69,8 +69,19 @@ Feature: Rebasing auto-created nodes works | propertyValues | {"text": {"value":"Modified","type":"string"}} | | propertiesToUnset | {} | + # ensure that live is outdated so the rebase is required: + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "changington-van-live" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + + # rebase of SetSerializedNodeProperties When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-rebased" | # This should properly work; no error. diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature index cf7d6746261..bbd1de5307c 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W6-WorkspaceRebasing/03-RebasingWithConflictingChanges.feature @@ -29,21 +29,18 @@ Feature: Workspace rebasing - conflicting changes | nodeTypeName | "Neos.ContentRepository:Root" | And the following CreateNodeAggregateWithNode commands are executed: - | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | - | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | child | + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | + | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | + | sir-nodebelig | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | + | nobody-node | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | And the command SetNodeProperties is executed with payload: | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | | originDimensionSpacePoint | {} | | propertyValues | {"text": "Original"} | - And the command CreateWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | - | baseWorkspaceName | "live" | - | newContentStreamId | "user-cs-identifier" | - Scenario: Conflicting changes lead to OUTDATED_CONFLICT which can be recovered from via forced rebase + Scenario: Conflicting changes lead to WorkspaceRebaseFailed exception which can be recovered from via forced rebase When the command CreateWorkspace is executed with payload: | Key | Value | @@ -56,6 +53,8 @@ Feature: Workspace rebasing - conflicting changes | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-two" | + Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + When the command RemoveNodeAggregate is executed with payload: | Key | Value | | nodeAggregateId | "nody-mc-nodeface" | @@ -85,17 +84,107 @@ Feature: Workspace rebasing - conflicting changes | originDimensionSpacePoint | {} | | propertyValues | {"text": "The other node"} | + Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + And the command PublishWorkspace is executed with payload: | Key | Value | | workspaceName | "user-ws-one" | + Then workspaces live,user-ws-one have status UP_TO_DATE Then workspace user-ws-two has status OUTDATED + # Rebase without force fails + When the command RebaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-ws-two" | + | rebasedContentStreamId | "user-cs-two-rebased" | + Then I expect the content stream "user-cs-two" to exist + Then I expect the content stream "user-cs-two-rebased" to not exist + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + When the command RebaseWorkspace is executed with payload: | Key | Value | | workspaceName | "user-ws-two" | | rebasedContentStreamId | "user-cs-two-rebased" | | rebaseErrorHandlingStrategy | "force" | - Then workspace user-ws-two has status UP_TO_DATE - And I expect a node identified by user-cs-two-rebased;noderus-secundus;{} to exist in the content graph + Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + + Then I expect the content stream "user-cs-two" to not exist + Then I expect the content stream "user-cs-two-rebased" to exist + + When I am in workspace "user-ws-two" and dimension space point {} + Then I expect node aggregate identifier "noderus-secundus" to lead to node user-cs-two-rebased;noderus-secundus;{} + + Scenario: Not conflicting changes are preserved on force rebase + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-identifier" | + + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodebelig" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nobody-node" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allVariants" | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "sir-nodebelig" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified text"} | + + # change that is rebaseable + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Rebaseable change in ws"} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "nobody-node" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified text"} | + + # Rebase without force fails + When the command RebaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-ws" | + | newContentStreamId | "user-cs-identifier-rebased" | + Then I expect the content stream "user-cs-identifier" to exist + Then I expect the content stream "user-cs-identifier-rebased" to not exist + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 12 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | 14 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | rebasedContentStreamId | "user-cs-identifier-rebased" | + | rebaseErrorHandlingStrategy | "force" | + + Then I expect the content stream "user-cs-identifier" to not exist + Then I expect the content stream "user-cs-identifier-rebased" to exist + + When I am in workspace "user-ws" and dimension space point {} + Then I expect node aggregate identifier "sir-nodebelig" to lead to no node + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-rebased;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Rebaseable change in ws" | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature index ad3ee6db36c..43372bfcad3 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W7-WorkspacePublication/02-PublishWorkspace.feature @@ -95,6 +95,7 @@ Feature: Workspace based content publishing When the command PublishWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node cs-identifier;nody-mc-nodeface;{} @@ -102,7 +103,7 @@ Feature: Workspace based content publishing | Key | Value | | text | "Modified" | - Scenario: modify the property in the nested workspace, do modification in live workspace; publish afterwards will not work because rebase is missing; then rebase and publish + Scenario: modify the property in the nested workspace, do modification in live workspace; publish afterwards will rebase the changes When the command SetNodeProperties is executed with payload: | Key | Value | @@ -117,22 +118,11 @@ Feature: Workspace based content publishing | originDimensionSpacePoint | {} | | propertyValues | {"text": "Modified in live workspace"} | - # PUBLISHING without rebase: error - When the command PublishWorkspace is executed with payload and exceptions are caught: - | Key | Value | - | workspaceName | "user-test" | - - Then the last command should have thrown an exception of type "BaseWorkspaceHasBeenModifiedInTheMeantime" - - # REBASING + Publishing: works now (TODO soft constraint check for old value) - When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | - | rebasedContentStreamId | "rebased-cs-id" | - - And the command PublishWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + # PUBLISHING (with rebase internally) + When the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | When I am in workspace "live" and dimension space point {} @@ -175,3 +165,45 @@ Feature: Workspace based content publishing And I expect this node to have the following properties: | Key | Value | | text | "Modified anew" | + + Scenario: Publish is a no-op if there are no changes + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | + + # the user and live workspace are unchanged + Then I expect exactly 1 event to be published on stream "Workspace:user-test" + Then I expect exactly 1 event to be published on stream "ContentStream:user-cs-identifier" + + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + + # checks for the live workspace (same as above) + Then I expect exactly 4 events to be published on stream "ContentStream:cs-identifier" + Then I expect exactly 1 event to be published on stream "Workspace:live" + + Scenario: Publish is a no-op if there are no changes (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Original" | + Then I expect the content stream "user-cs-new" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature index 8c50e8da5f6..ac52e28289b 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/01-ConstraintChecks.feature @@ -36,9 +36,11 @@ Feature: Workspace publication - complex chained functionality | nodeTypeName | "Neos.ContentRepository:Root" | And the following CreateNodeAggregateWithNode commands are executed: - | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | tetheredDescendantNodeAggregateIds | - | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | lady-eleonode-rootford | document | {"tethered": "nodewyn-tetherton"} | - | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | nodewyn-tetherton | grandchild | {} | + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | tetheredDescendantNodeAggregateIds | properties | + | sir-david-nodenborough | Neos.ContentRepository.Testing:Document | lady-eleonode-rootford | {"tethered": "nodewyn-tetherton"} | | + | sir-nodebelig | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | | | + | nobody-node | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | | | + | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | nodewyn-tetherton | | | And the command CreateWorkspace is executed with payload: | Key | Value | @@ -46,6 +48,45 @@ Feature: Workspace publication - complex chained functionality | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-id" | + Scenario: Deleted nodes cannot be edited + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-nodebelig" | + | coveredDimensionSpacePoint | {"language": "de"} | + | nodeVariantSelectionStrategy | "allVariants" | + + When the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nobody-node" | + | coveredDimensionSpacePoint | {"language": "de"} | + | nodeVariantSelectionStrategy | "allVariants" | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "sir-nodebelig" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"text": "Modified text"} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws" | + | nodeAggregateId | "nobody-node" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"text": "Modified text"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "user-ws" | + | nodesToPublish | [{"dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-nodebelig"}] | + | newContentStreamId | "user-cs-id-rebased" | + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + | 14 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | + Scenario: Vary to generalization, then publish only the child node so that an exception is thrown. Ensure that the workspace recovers from this When the command CreateNodeVariant is executed with payload: | Key | Value | @@ -65,7 +106,9 @@ Feature: Workspace publication - complex chained functionality | workspaceName | "user-ws" | | nodesToPublish | [{"workspaceName": "user-ws", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "nody-mc-nodeface"}] | | newContentStreamId | "user-cs-id-rebased" | - Then the last command should have thrown an exception of type "NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint" + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | CreateNodeVariant | NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint | When the command PublishWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature index 487007ba37d..dfa2d30a523 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/02-BasicFeatures.feature @@ -7,7 +7,10 @@ Feature: Individual node publication Given using no content dimensions And using the following node types: """yaml - 'Neos.ContentRepository.Testing:Content': {} + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string 'Neos.ContentRepository.Testing:Document': childNodes: child1: @@ -41,7 +44,6 @@ Feature: Individual node publication Scenario: It is possible to publish a single node; and only this one is live. # create nodes in user WS Given I am in workspace "user-test" - And I am in workspace "user-test" And I am in dimension space point {} And the following CreateNodeAggregateWithNode commands are executed: | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | tetheredDescendantNodeAggregateIds | @@ -54,9 +56,34 @@ Feature: Individual node publication | Key | Value | | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I am in workspace "live" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{} to exist in the content graph + + Scenario: Partial publish is a no-op if the workspace doesnt contain any changes (and the workspace is outdated) + + When the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | originDimensionSpacePoint | {} | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Original text"} | + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing"}] | + | contentStreamIdForRemainingPart | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to no node + + Then I expect the content stream "user-cs-new" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature index 85adbd6c3ab..398d368f0a5 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/03-MoreBasicFeatures.feature @@ -29,44 +29,39 @@ Feature: Publishing individual nodes (basics) | Key | Value | | workspaceName | "live" | | newContentStreamId | "cs-identifier" | - And I am in workspace "live" + And I am in workspace "live" and dimension space point {} And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-david-nodenborough" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t1"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t1"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | - | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "nody-mc-nodeface" | | nodeTypeName | "Neos.ContentRepository.Testing:Content" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "sir-david-nodenborough" | - | initialPropertyValues | {"text": {"type": "string", "value": "Initial t2"}} | - | nodeAggregateClassification | "regular" | - And the event NodeAggregateWithNodeWasCreated was published with payload: + | initialPropertyValues | {"text": "Initial t2"} | + And the command CreateNodeAggregateWithNode is executed with payload: | Key | Value | | workspaceName | "live" | - | contentStreamId | "cs-identifier" | | nodeAggregateId | "sir-nodeward-nodington-iii" | | nodeTypeName | "Neos.ContentRepository.Testing:Image" | - | originDimensionSpacePoint | {} | - | coveredDimensionSpacePoints | [{}] | | parentNodeAggregateId | "lady-eleonode-rootford" | - | initialPropertyValues | {"image": {"type": "string", "value": "Initial image"}} | - | nodeAggregateClassification | "regular" | + | initialPropertyValues | {"image": "Initial image"} | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeTypeName | "Neos.ContentRepository.Testing:Content" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | initialPropertyValues | {"text": "Initial text"} | # Create user workspace And the command CreateWorkspace is executed with payload: @@ -104,7 +99,7 @@ Feature: Publishing individual nodes (basics) | workspaceName | "user-test" | | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} @@ -134,12 +129,21 @@ Feature: Publishing individual nodes (basics) | Key | Value | | image | "Modified image" | - Scenario: It is possible to publish no node + Scenario: Publish no node, non existing ones or unchanged nodes is a no-op + # no node When the command PublishIndividualNodesFromWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | | nodesToPublish | [] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + Then I expect the content stream "user-cs-identifier-remaining" to not exist + + # unchanged or non existing nodes + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing-node"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining-two" | When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} @@ -155,20 +159,57 @@ Feature: Publishing individual nodes (basics) | Key | Value | | image | "Initial image" | + # all nodes are still on the original user cs When I am in workspace "user-test" and dimension space point {} - Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier-remaining;sir-david-nodenborough;{} + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t1" | - Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-remaining;nody-mc-nodeface;{} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} And I expect this node to have the following properties: | Key | Value | | text | "Modified t2" | - Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier-remaining;sir-nodeward-nodington-iii;{} + Then I expect node aggregate identifier "sir-nodeward-nodington-iii" to lead to node user-cs-identifier;sir-nodeward-nodington-iii;{} And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + # assert that content stream is still open by writing to it: + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {} | + | propertyValues | {"image": "Bla bli blub"} | + + Scenario: Tag the same node in live and in the user workspace so that a rebase will omit the user change + When the command TagSubtree is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + When the command TagSubtree is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodeAggregateId | "sir-unchanged" | + | nodeVariantSelectionStrategy | "allVariants" | + | tag | "tag1" | + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "sir-unchanged"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + + When I am in workspace "live" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node cs-identifier;sir-unchanged;{} + And I expect this node to be exactly explicitly tagged "tag1" + + When I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node user-cs-identifier-remaining;sir-unchanged;{} + And I expect this node to be exactly explicitly tagged "tag1" + Then workspace user-test has status UP_TO_DATE + Scenario: It is possible to publish all nodes When the command PublishIndividualNodesFromWorkspace is executed with payload: | Key | Value | @@ -176,6 +217,15 @@ Feature: Publishing individual nodes (basics) | nodesToPublish | [{"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "nody-mc-nodeface"}, {"workspaceName": "user-test", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + # when publishing all nodes we expect a full discard via WorkspaceWasPublished + Then I expect exactly 2 events to be published on stream with prefix "Workspace:user-test" + And event at index 1 is of type "WorkspaceWasPublished" with payload: + | Key | Expected | + | sourceWorkspaceName | "user-test" | + | targetWorkspaceName | "live" | + | newSourceContentStreamId | "user-cs-identifier-remaining" | + | previousSourceContentStreamId | "user-cs-identifier" | + When I am in workspace "live" and dimension space point {} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node cs-identifier;sir-david-nodenborough;{} And I expect this node to have the following properties: @@ -203,3 +253,65 @@ Feature: Publishing individual nodes (basics) And I expect this node to have the following properties: | Key | Value | | image | "Modified image" | + + Scenario: Publish individual nodes commits exactly the expected events on each stream + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"}, {"dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | + | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | + + Then I expect exactly 8 events to be published on stream "ContentStream:cs-identifier" + And event at index 6 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-david-nodenborough" | + And event at index 7 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + + Then I expect exactly 4 events to be published on stream "ContentStream:user-cs-identifier-remaining" + And event at index 0 is of type "ContentStreamWasForked" with payload: + | Key | Expected | + | newContentStreamId | "user-cs-identifier-remaining" | + And event at index 1 is of type "ContentStreamWasClosed" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + And event at index 2 is of type "NodePropertiesWereSet" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + | nodeAggregateId | "nody-mc-nodeface" | + And event at index 3 is of type "ContentStreamWasReopened" with payload: + | Key | Expected | + | contentStreamId | "user-cs-identifier-remaining" | + + Scenario: Partial publish keeps remaining changes if nothing matches (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-unchanged" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | nodesToPublish | [{"dimensionSpacePoint": {}, "nodeAggregateId": "non-existing"}] | + | contentStreamIdForRemainingPart | "user-cs-new" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "sir-unchanged" to lead to node user-cs-identifier;sir-unchanged;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Initial text" | + + Then I expect node aggregate identifier "sir-david-nodenborough" to lead to node user-cs-identifier;sir-david-nodenborough;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Modified t1" | + + Then I expect the content stream "user-cs-new" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature index f08d5bf47d4..a5e32169667 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W8-IndividualNodePublication/04-AllFeaturePublication.feature @@ -102,7 +102,6 @@ Feature: Publishing hide/show scenario of nodes | workspaceName | "user-test" | | nodesToPublish | [{"nodeAggregateId": "sir-david-nodenborough", "workspaceName": "user-test", "dimensionSpacePoint": {}}] | | contentStreamIdForRemainingPart | "remaining-cs-id" | - | contentStreamIdForMatchingPart | "matching-cs-id" | When I am in workspace "live" Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node @@ -297,15 +296,13 @@ Feature: Publishing hide/show scenario of nodes | workspaceName | "user-test" | | sourceNodeAggregateId | "sir-david-nodenborough" | | sourceOriginDimensionSpacePoint | {} | - | referenceName | "referenceProperty" | - | references | [{"target":"sir-nodeward-nodington-iii"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"sir-nodeward-nodington-iii"}]}] | And the command SetNodeReferences is executed with payload: | Key | Value | | workspaceName | "user-test" | | sourceNodeAggregateId | "nody-mc-nodeface" | | sourceOriginDimensionSpacePoint | {} | - | referenceName | "referenceProperty" | - | references | [{"target":"sir-nodeward-nodington-iii"}] | + | references | [{"referenceName": "referenceProperty", "references": [{"target":"sir-nodeward-nodington-iii"}]}] | When the command PublishIndividualNodesFromWorkspace is executed with payload: | Key | Value | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature index e55d3c8326d..0e180f7d0dd 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W9-WorkspaceDiscarding/02-DiscardWorkspace.feature @@ -63,6 +63,7 @@ Feature: Workspace discarding - basic functionality | Key | Value | | workspaceName | "user-test" | | newContentStreamId | "user-cs-identifier-modified" | + Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "user-test" and dimension space point {} Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier-modified;nody-mc-nodeface;{} @@ -97,7 +98,7 @@ Feature: Workspace discarding - basic functionality | Key | Value | | text | "Modified in live workspace" | - Scenario: Conflicting changes lead to OUTDATED which can be recovered from via discard + Scenario: Conflicting changes lead to exception on rebase which can be recovered from via discard When the command CreateWorkspace is executed with payload: | Key | Value | @@ -110,6 +111,8 @@ Feature: Workspace discarding - basic functionality | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-two" | + Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + When the command RemoveNodeAggregate is executed with payload: | Key | Value | | workspaceName | "user-ws-one" | @@ -128,12 +131,16 @@ Feature: Workspace discarding - basic functionality | Key | Value | | workspaceName | "user-ws-one" | + Then workspaces live,user-ws-one have status UP_TO_DATE Then workspace user-ws-two has status OUTDATED When the command RebaseWorkspace is executed with payload and exceptions are caught: | Key | Value | | workspaceName | "user-ws-two" | | rebasedContentStreamId | "user-cs-two-rebased" | + Then the last command should have thrown the WorkspaceRebaseFailed exception with: + | SequenceNumber | Command | Exception | + | 13 | SetSerializedNodeProperties | NodeAggregateCurrentlyDoesNotExist | Then workspace user-ws-two has status OUTDATED @@ -142,5 +149,27 @@ Feature: Workspace discarding - basic functionality | workspaceName | "user-ws-two" | | newContentStreamId | "user-cs-two-discarded" | - Then workspace user-ws-two has status OUTDATED + Then workspaces live,user-ws-one,user-ws-two have status UP_TO_DATE + + Scenario: Discard is a no-op if there are no changes (and the workspace is outdated) + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Modified in live workspace"} | + And the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-test" | + | newContentStreamId | "user-cs-two-discarded" | + Then workspaces user-test has status OUTDATED + + Then I expect exactly 1 events to be published on stream with prefix "Workspace:user-test" + + And I am in workspace "user-test" and dimension space point {} + Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-cs-identifier;nody-mc-nodeface;{} + And I expect this node to have the following properties: + | Key | Value | + | text | "Original" | + Then I expect the content stream "user-cs-two-discarded" to not exist diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature index 67fa06bbd93..c64ec997a14 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/PruneContentStreams.feature @@ -19,35 +19,38 @@ Feature: If content streams are not in use anymore by the workspace, they can be | nodeAggregateId | "root-node" | | nodeTypeName | "Neos.ContentRepository:Root" | - Scenario: content streams are marked as IN_USE_BY_WORKSPACE properly after creation - Then the content stream "cs-identifier" has status "IN_USE_BY_WORKSPACE" + # + # Before Neos 9 beta 15 (publishing version 3 #5301), dangling content streams were not removed during publishing, discard or rebase + # The first scenarios assert that the automatic deletion works correctly + # + + Scenario: content streams are in use after creation Then I expect the content stream "non-existing" to not exist + Then I expect the content stream "cs-identifier" to exist - Scenario: on creating a nested workspace, the new content stream is marked as IN_USE_BY_WORKSPACE. - When the command CreateWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | - | baseWorkspaceName | "live" | - | newContentStreamId | "user-cs-identifier" | + Then I expect the content stream pruner status output: + """ + Okay. No dangling streams found - Then the content stream "user-cs-identifier" has status "IN_USE_BY_WORKSPACE" + Okay. No pruneable streams in the event stream + """ - Scenario: when rebasing a nested workspace, the new content stream will be marked as IN_USE_BY_WORKSPACE; and the old content stream is NO_LONGER_IN_USE. + Scenario: on creating a nested workspace, the new content stream is not pruned When the command CreateWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-identifier" | - When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + Then I expect the content stream "user-cs-identifier" to exist - When I am in workspace "user-test" and dimension space point {} - Then the current content stream has status "IN_USE_BY_WORKSPACE" - And the content stream "user-cs-identifier" has status "NO_LONGER_IN_USE" + Then I expect the content stream pruner status output: + """ + Okay. No dangling streams found + Okay. No pruneable streams in the event stream + """ - Scenario: when pruning content streams, NO_LONGER_IN_USE content streams will be properly cleaned from the graph projection. + Scenario: no longer in use content streams will be properly cleaned from the graph projection. When the command CreateWorkspace is executed with payload: | Key | Value | | workspaceName | "user-test" | @@ -61,15 +64,16 @@ Feature: If content streams are not in use anymore by the workspace, they can be | Key | Value | | workspaceName | "user-test" | | rebasedContentStreamId | "user-cs-identifier-rebased" | + | rebaseErrorHandlingStrategy | "force" | # now, we have one unused content stream (the old content stream of the user-test workspace) - When I prune unused content streams Then I expect the content stream "user-cs-identifier" to not exist When I am in workspace "user-test" and dimension space point {} + # todo test that the graph projection really is cleaned up and that no hierarchy stil exist? Then I expect node aggregate identifier "root-node" to lead to node user-cs-identifier-rebased;root-node;{} - Scenario: NO_LONGER_IN_USE content streams can be cleaned up completely (simple case) + Scenario: no longer in use content streams can be cleaned up completely (simple case) When the command CreateWorkspace is executed with payload: | Key | Value | @@ -77,17 +81,37 @@ Feature: If content streams are not in use anymore by the workspace, they can be | baseWorkspaceName | "live" | | newContentStreamId | "user-cs-identifier" | When the command RebaseWorkspace is executed with payload: - | Key | Value | - | workspaceName | "user-test" | + | Key | Value | + | workspaceName | "user-test" | + | rebasedContentStreamId | "user-cs-identifier-rebased" | + | rebaseErrorHandlingStrategy | "force" | + + Then I expect the content stream "user-cs-identifier-rebased" to exist + Then I expect the content stream "user-cs-identifier" to not exist + # now, we have one unused content stream (the old content stream of the user-test workspace) - When I prune unused content streams + Then I expect the content stream pruner status output: + """ + Okay. No dangling streams found + + Removed content streams that can be pruned from the event stream + id: user-cs-identifier previous state: no longer in use + To prune the removed streams from the event stream run ./flow contentStream:pruneRemovedFromEventstream + """ + And I prune removed content streams from the event stream Then I expect exactly 0 events to be published on stream "ContentStream:user-cs-identifier" + Then I expect the content stream pruner status output: + """ + Okay. No dangling streams found + + Okay. No pruneable streams in the event stream + """ - Scenario: NO_LONGER_IN_USE content streams are only cleaned up if no other content stream which is still in use depends on it + Scenario: no longer in use content streams are only cleaned up if no other content stream which is still in use depends on it # we build a "review" workspace, and then a "user-test" workspace depending on the review workspace. When the command CreateWorkspace is executed with payload: | Key | Value | @@ -100,15 +124,22 @@ Feature: If content streams are not in use anymore by the workspace, they can be | baseWorkspaceName | "review" | | newContentStreamId | "user-cs-identifier" | - # now, we rebase the "review" workspace, effectively marking the "review-cs-identifier" content stream as NO_LONGER_IN_USE. + # now, we rebase the "review" workspace, effectively marking the "review-cs-identifier" content stream as no longer in use. # however, we are not allowed to drop the content stream from the event store yet, because the "user-cs-identifier" is based # on the (no-longer-in-direct-use) review-cs-identifier. When the command RebaseWorkspace is executed with payload: | Key | Value | | workspaceName | "review" | + | rebaseErrorHandlingStrategy | "force" | - When I prune unused content streams And I prune removed content streams from the event stream # the events should still exist Then I expect exactly 3 events to be published on stream "ContentStream:review-cs-identifier" + + Then I expect the content stream pruner status output: + """ + Okay. No dangling streams found + + Okay. No pruneable streams in the event stream + """ diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/WorkspaceState.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/WorkspaceState.feature new file mode 100644 index 00000000000..74e3fcd502a --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/Workspaces/WorkspaceState.feature @@ -0,0 +1,109 @@ +@contentrepository @adapters=DoctrineDBAL +Feature: Workspace status + The workspace status signals if the workspace is UP_TO_DATE or OUTDATED + All depending workspaces are considered OUTDATED if changes are made or published into a workspace + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Content': + properties: + text: + type: string + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | + | nody-mc-nodeface | Neos.ContentRepository.Testing:Content | lady-eleonode-rootford | child | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Original"} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-one" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-one" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared" | + | baseWorkspaceName | "live" | + | newContentStreamId | "shared-cs-identifier" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | baseWorkspaceName | "shared" | + | newContentStreamId | "user-cs-two" | + + Scenario: Changes to the root workspace render dependents outdated + Then workspaces live,shared,user-ws-one,user-ws-two have status UP_TO_DATE + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Revision"} | + + Then workspace live has status UP_TO_DATE + Then workspaces shared,user-ws-one have status OUTDATED + # the others users workspace is not outdated because it depends on shared + Then workspace user-ws-two has status UP_TO_DATE + + Scenario: Publishing to the root workspace render dependents outdated + Then workspaces live,shared,user-ws-one,user-ws-two have status UP_TO_DATE + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-ws-one" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"text": "Revision"} | + + Then workspaces live,shared,user-ws-one,user-ws-two have status UP_TO_DATE + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-one" | + + Then workspaces live,user-ws-one have status UP_TO_DATE + Then workspace shared has status OUTDATED + # the others users workspace is not outdated because it depends on shared + Then workspace user-ws-two has status UP_TO_DATE + + # + # Rebasing to get everything up to date + # + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "shared" | + | rebasedContentStreamId | "shared-rebased" | + + Then workspaces live,shared,user-ws-one have status UP_TO_DATE + Then workspace user-ws-two has status OUTDATED + + When the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-ws-two" | + | rebasedContentStreamId | "user-ws-two-rebased" | + + Then workspaces live,shared,user-ws-one,user-ws-two have status UP_TO_DATE diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php index b77ce854cf6..92673a00c82 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandBus.php @@ -4,7 +4,6 @@ namespace Neos\ContentRepository\Core\CommandHandler; -use Neos\ContentRepository\Core\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventsToPublish; @@ -14,26 +13,41 @@ * * @internal */ -final class CommandBus +final readonly class CommandBus { /** * @var CommandHandlerInterface[] */ private array $handlers; - public function __construct(CommandHandlerInterface ...$handlers) - { + public function __construct( + // todo pass $commandHandlingDependencies in each command handler instead of into the commandBus + private CommandHandlingDependencies $commandHandlingDependencies, + CommandHandlerInterface ...$handlers + ) { $this->handlers = $handlers; } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish + /** + * @return EventsToPublish|\Generator + */ + public function handle(CommandInterface $command): EventsToPublish|\Generator { - // TODO fail if multiple handlers can handle the same command + // multiple handlers must not handle the same command foreach ($this->handlers as $handler) { if ($handler->canHandle($command)) { - return $handler->handle($command, $commandHandlingDependencies); + return $handler->handle($command, $this->commandHandlingDependencies); } } throw new \RuntimeException(sprintf('No handler found for Command "%s"', get_debug_type($command)), 1649582778); } + + public function withAdditionalHandlers(CommandHandlerInterface ...$handlers): self + { + return new self( + $this->commandHandlingDependencies, + ...$this->handlers, + ...$handlers, + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php index e190337c370..b36d5d3ab75 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlerInterface.php @@ -4,20 +4,26 @@ namespace Neos\ContentRepository\Core\CommandHandler; -use Neos\ContentRepository\Core\CommandHandlingDependencies; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventsToPublish; /** * Common interface for all Content Repository command handlers * - * Note: The Content Repository instance is passed to the handle() method for it to do soft-constraint checks or - * trigger "sub commands" + * The {@see CommandHandlingDependencies} are available during handling to do soft-constraint checks * * @internal no public API, because commands are no extension points of the CR */ interface CommandHandlerInterface { public function canHandle(CommandInterface $command): bool; - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish; + + /** + * "simple" command handlers return EventsToPublish directly + * + * For the case of the workspace command handler that need to publish to many streams and "close" the content-stream directly, + * it's allowed to yield the events to interact with the control flow of event publishing. + * + * @return EventsToPublish|\Generator + */ + public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish|\Generator; } diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php new file mode 100644 index 00000000000..9e726cacea1 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandHandlingDependencies.php @@ -0,0 +1,72 @@ +contentGraphReadModel->findContentStreamById($contentStreamId); + if ($contentStream === null) { + throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902051); + } + return $contentStream->version; + } + + public function contentStreamExists(ContentStreamId $contentStreamId): bool + { + return $this->contentGraphReadModel->findContentStreamById($contentStreamId) !== null; + } + + public function isContentStreamClosed(ContentStreamId $contentStreamId): bool + { + $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); + if ($contentStream === null) { + throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1729863973); + } + return $contentStream->isClosed; + } + + public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace + { + return $this->contentGraphReadModel->findWorkspaceByName($workspaceName); + } + + /** + * @throws WorkspaceDoesNotExist if the workspace does not exist + */ + public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface + { + return $this->contentGraphReadModel->getContentGraph($workspaceName); + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php new file mode 100644 index 00000000000..ee3ee2de52c --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulator.php @@ -0,0 +1,171 @@ + this will do all constraint checks based on the projection in the open transaction (so it sees + * previously modified projection state which is not committed) + * - -> it will run the command handlers, buffer all emitted events in the InMemoryEventStore + * -> note to avoid full recursion the workspace command handler is not included in the bus + * - -> update the GraphProjection, but WITHOUT committing the transaction {@see ContentGraphProjectionInterface::inSimulation()} + * + * This is quite performant because we do not need to fork a new content stream. + * + * @internal + */ +final class CommandSimulator +{ + private CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase; + + private readonly InMemoryEventStore $inMemoryEventStore; + + public function __construct( + private readonly ContentGraphProjectionInterface $contentRepositoryProjection, + private readonly EventNormalizer $eventNormalizer, + private readonly CommandBus $commandBus, + private readonly WorkspaceName $workspaceNameToSimulateIn, + ) { + $this->inMemoryEventStore = new InMemoryEventStore(); + $this->commandsThatFailedDuringRebase = new CommandsThatFailedDuringRebase(); + } + + /** + * Start the simulation for the passed function which receives as argument the {@see handle} function. + * + * @template T + * @param callable(callable(RebaseableCommand): void): T $fn + * @return T the return value of $fn + */ + public function run(callable $fn): mixed + { + return $this->contentRepositoryProjection->inSimulation(fn () => $fn($this->handle(...))); + } + + /** + * Handle a command within a running simulation, otherwise throw. + * + * We will automatically copy given commands to the workspace this simulation + * is running in to ensure consistency in the simulations constraint checks. + */ + private function handle(RebaseableCommand $rebaseableCommand): void + { + // FIXME: Check if workspace already matches and skip this, e.g. $commandInWorkspace = $command->getWorkspaceName()->equals($this->workspaceNameToSimulateIn) ? $command : $command->createCopyForWorkspace($this->workspaceNameToSimulateIn); + // when https://github.com/neos/neos-development-collection/pull/5298 is merged + $commandInWorkspace = $rebaseableCommand->originalCommand->createCopyForWorkspace($this->workspaceNameToSimulateIn); + + try { + $eventsToPublish = $this->commandBus->handle($commandInWorkspace); + } catch (\Exception $exception) { + $this->commandsThatFailedDuringRebase = $this->commandsThatFailedDuringRebase->withAppended( + new CommandThatFailedDuringRebase( + $rebaseableCommand->originalCommand, + $exception, + $rebaseableCommand->originalSequenceNumber + ) + ); + + return; + } + + if (!$eventsToPublish instanceof EventsToPublish) { + throw new \RuntimeException(sprintf('%s expects an instance of %s to be returned. Got %s when handling %s', self::class, EventsToPublish::class, get_debug_type($eventsToPublish), $rebaseableCommand->originalCommand::class)); + } + + if ($eventsToPublish->events->isEmpty()) { + return; + } + + $normalizedEvents = Events::fromArray( + $eventsToPublish->events->map(function (EventInterface|DecoratedEvent $event) use ( + $rebaseableCommand + ) { + $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; + $decoratedEvent = DecoratedEvent::create($event, metadata: EventMetadata::fromArray( + array_merge($metadata, $rebaseableCommand->initiatingMetaData->value ?? []) + )); + return $this->eventNormalizer->normalize($decoratedEvent); + }) + ); + + $sequenceNumberBeforeCommit = $this->currentSequenceNumber(); + + // The version of the stream in the IN MEMORY event store does not matter to us, + // because this is only used in memory during the partial publish or rebase operation; so it cannot be written to + // concurrently. + // HINT: We cannot use $eventsToPublish->expectedVersion, because this is based on the PERSISTENT event stream (having different numbers) + $this->inMemoryEventStore->commit( + $eventsToPublish->streamName, + $normalizedEvents, + ExpectedVersion::ANY() + ); + + // fetch all events that were now committed. Plus one because the first sequence number is one too otherwise we get one event to many. + // (all elephants shall be placed shamefully placed on my head) + $eventStream = $this->eventStream()->withMinimumSequenceNumber( + $sequenceNumberBeforeCommit->next() + ); + + foreach ($eventStream as $eventEnvelope) { + $event = $this->eventNormalizer->denormalize($eventEnvelope->event); + + if (!$this->contentRepositoryProjection->canHandle($event)) { + continue; + } + + $this->contentRepositoryProjection->apply($event, $eventEnvelope); + } + } + + public function currentSequenceNumber(): SequenceNumber + { + foreach ($this->eventStream()->backwards()->limit(1) as $eventEnvelope) { + return $eventEnvelope->sequenceNumber; + } + return SequenceNumber::none(); + } + + public function eventStream(): EventStreamInterface + { + return $this->inMemoryEventStore->load(VirtualStreamName::all()); + } + + public function hasCommandsThatFailed(): bool + { + return !$this->commandsThatFailedDuringRebase->isEmpty(); + } + + public function getCommandsThatFailed(): CommandsThatFailedDuringRebase + { + return $this->commandsThatFailedDuringRebase; + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulatorFactory.php b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulatorFactory.php new file mode 100644 index 00000000000..602fd28b25f --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/CommandHandler/CommandSimulatorFactory.php @@ -0,0 +1,32 @@ +contentRepositoryProjection, + $this->eventNormalizer, + $this->commandBus, + $workspaceNameToSimulateIn, + ); + } +} diff --git a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php deleted file mode 100644 index 622af9c9a15..00000000000 --- a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php +++ /dev/null @@ -1,127 +0,0 @@ -value to ContentGraphInterface - * @var array - */ - private array $overriddenContentGraphInstances = []; - - public function __construct( - private readonly ContentRepository $contentRepository, - private readonly ContentGraphReadModelInterface $contentGraphReadModel - ) { - } - - public function handle(CommandInterface $command): void - { - $this->contentRepository->handle($command); - } - - public function getContentStreamVersion(ContentStreamId $contentStreamId): Version - { - $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); - if ($contentStream === null) { - throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902051); - } - return $contentStream->version; - } - - public function contentStreamExists(ContentStreamId $contentStreamId): bool - { - return $this->contentGraphReadModel->findContentStreamById($contentStreamId) !== null; - } - - public function getContentStreamStatus(ContentStreamId $contentStreamId): ContentStreamStatus - { - $contentStream = $this->contentGraphReadModel->findContentStreamById($contentStreamId); - if ($contentStream === null) { - throw new \InvalidArgumentException(sprintf('Failed to find content stream with id "%s"', $contentStreamId->value), 1716902219); - } - return $contentStream->status; - } - - public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace - { - return $this->contentGraphReadModel->findWorkspaceByName($workspaceName); - } - - /** - * @throws WorkspaceDoesNotExist if the workspace does not exist - */ - public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface - { - if (isset($this->overriddenContentGraphInstances[$workspaceName->value])) { - return $this->overriddenContentGraphInstances[$workspaceName->value]; - } - $workspace = $this->contentGraphReadModel->findWorkspaceByName($workspaceName); - if ($workspace === null) { - throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); - } - return $this->contentGraphReadModel->buildContentGraph($workspace->workspaceName, $workspace->currentContentStreamId); - } - - /** - * Stateful (dirty) override of the chosen ContentStreamId for a given workspace, it applies within the given closure. - * Implementations must ensure that requesting the contentStreamId for this workspace will resolve to the given - * override ContentStreamId and vice versa resolving the WorkspaceName from this ContentStreamId should result in the - * given WorkspaceName within the closure. - * - * @internal Used in write operations applying commands to a contentstream that will have WorkspaceName in the future - * but doesn't have one yet. - */ - public function overrideContentStreamId(WorkspaceName $workspaceName, ContentStreamId $contentStreamId, \Closure $fn): void - { - if (isset($this->overriddenContentGraphInstances[$workspaceName->value])) { - throw new \RuntimeException('Contentstream override for this workspace already in effect, nesting not allowed.', 1715170938); - } - - $contentGraph = $this->contentGraphReadModel->buildContentGraph($workspaceName, $contentStreamId); - $this->overriddenContentGraphInstances[$workspaceName->value] = $contentGraph; - - try { - $fn(); - } finally { - unset($this->overriddenContentGraphInstances[$workspaceName->value]); - } - } - - /** - * Fixme only required to build the possible catchup hooks - */ - public function getContentRepository(): ContentRepository - { - return $this->contentRepository; - } -} diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 696b2de305d..f474dda8191 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -18,19 +18,18 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; -use Neos\ContentRepository\Core\EventStore\DecoratedEvent; -use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventPersister; -use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUp; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpOptions; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; @@ -47,7 +46,6 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; use Neos\EventStore\EventStoreInterface; -use Neos\EventStore\Model\Event\EventMetadata; use Neos\EventStore\Model\EventEnvelope; use Neos\EventStore\Model\EventStream\VirtualStreamName; use Psr\Clock\ClockInterface; @@ -70,8 +68,6 @@ final class ContentRepository */ private array $projectionStateCache; - private CommandHandlingDependencies $commandHandlingDependencies; - /** * @internal use the {@see ContentRepositoryFactory::getOrBuild()} to instantiate */ @@ -89,7 +85,6 @@ public function __construct( private readonly ClockInterface $clock, private readonly ContentGraphReadModelInterface $contentGraphReadModel ) { - $this->commandHandlingDependencies = new CommandHandlingDependencies($this, $this->contentGraphReadModel); } /** @@ -101,38 +96,18 @@ public function handle(CommandInterface $command): void { // the commands only calculate which events they want to have published, but do not do the // publishing themselves - $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies); - - // TODO meaningful exception message - $initiatingUserId = $this->userIdProvider->getUserId(); - $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM); - - // Add "initiatingUserId" and "initiatingTimestamp" metadata to all events. - // This is done in order to keep information about the _original_ metadata when an - // event is re-applied during publishing/rebasing - // "initiatingUserId": The identifier of the user that originally triggered this event. This will never - // be overridden if it is set once. - // "initiatingTimestamp": The timestamp of the original event. The "recordedAt" timestamp will always be - // re-created and reflects the time an event was actually persisted in a stream, - // the "initiatingTimestamp" will be kept and is never overridden again. - // TODO: cleanup - $eventsToPublish = new EventsToPublish( - $eventsToPublish->streamName, - Events::fromArray( - $eventsToPublish->events->map(function (EventInterface|DecoratedEvent $event) use ( - $initiatingUserId, - $initiatingTimestamp - ) { - $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; - $metadata['initiatingUserId'] ??= $initiatingUserId; - $metadata['initiatingTimestamp'] ??= $initiatingTimestamp; - return DecoratedEvent::create($event, metadata: EventMetadata::fromArray($metadata)); - }) - ), - $eventsToPublish->expectedVersion, - ); - - $this->eventPersister->publishEvents($this, $eventsToPublish); + $eventsToPublishOrGenerator = $this->commandBus->handle($command); + + if ($eventsToPublishOrGenerator instanceof EventsToPublish) { + $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublishOrGenerator); + $this->eventPersister->publishEvents($this, $eventsToPublish); + } else { + foreach ($eventsToPublishOrGenerator as $eventsToPublish) { + assert($eventsToPublish instanceof EventsToPublish); // just for the ide + $eventsToPublish = $this->enrichEventsToPublishWithMetadata($eventsToPublish); + $this->eventPersister->publishEvents($this, $eventsToPublish); + } + } } @@ -172,7 +147,13 @@ public function catchUpProjection(string $projectionClassName, CatchUpOptions $o $projection = $this->projectionsAndCatchUpHooks->projections->get($projectionClassName); $catchUpHookFactory = $this->projectionsAndCatchUpHooks->getCatchUpHookFactoryForProjection($projection); - $catchUpHook = $catchUpHookFactory?->build($this); + $catchUpHook = $catchUpHookFactory?->build(new CatchUpHookFactoryDependencies( + $this->id, + $projection->getState(), + $this->nodeTypeManager, + $this->contentDimensionSource, + $this->variationGraph + )); // TODO allow custom stream name per projection $streamName = VirtualStreamName::all(); @@ -257,11 +238,7 @@ public function resetProjectionState(string $projectionClassName): void */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { - $workspace = $this->contentGraphReadModel->findWorkspaceByName($workspaceName); - if ($workspace === null) { - throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); - } - return $this->contentGraphReadModel->buildContentGraph($workspaceName, $workspace->currentContentStreamId); + return $this->contentGraphReadModel->getContentGraph($workspaceName); } /** @@ -305,4 +282,20 @@ public function getContentDimensionSource(): ContentDimensionSourceInterface { return $this->contentDimensionSource; } + + private function enrichEventsToPublishWithMetadata(EventsToPublish $eventsToPublish): EventsToPublish + { + $initiatingUserId = $this->userIdProvider->getUserId(); + $initiatingTimestamp = $this->clock->now(); + + return new EventsToPublish( + $eventsToPublish->streamName, + InitiatingEventMetadata::enrichEventsWithInitiatingMetadata( + $eventsToPublish->events, + $initiatingUserId, + $initiatingTimestamp + ), + $eventsToPublish->expectedVersion, + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php index 7c53549dac8..4909d50e661 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventPersister.php @@ -24,7 +24,6 @@ public function __construct( } /** - * @param EventsToPublish $eventsToPublish * @throws ConcurrencyException in case the expectedVersion does not match */ public function publishEvents(ContentRepository $contentRepository, EventsToPublish $eventsToPublish): void @@ -32,8 +31,6 @@ public function publishEvents(ContentRepository $contentRepository, EventsToPubl if ($eventsToPublish->events->isEmpty()) { return; } - // the following logic could also be done in an AppEventStore::commit method (being called - // directly from the individual Command Handlers). $normalizedEvents = Events::fromArray( $eventsToPublish->events->map($this->eventNormalizer->normalize(...)) ); diff --git a/Neos.ContentRepository.Core/Classes/EventStore/Events.php b/Neos.ContentRepository.Core/Classes/EventStore/Events.php index 69e58099661..872aab9a56d 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/Events.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/Events.php @@ -27,6 +27,11 @@ public static function with(EventInterface|DecoratedEvent $event): self return new self($event); } + public function withAppendedEvents(Events $events): self + { + return new self(...$this->events, ...$events->events); + } + /** * @param array $events * @return static diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php index 3dd0d2567dc..d7b4ab5bcfe 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/EventsToPublish.php @@ -33,4 +33,13 @@ public static function empty(): self ExpectedVersion::ANY() ); } + + public function withAppendedEvents(Events $events): self + { + return new self( + $this->streamName, + $this->events->withAppendedEvents($events), + $this->expectedVersion + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php new file mode 100644 index 00000000000..87be6d0b2cd --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php @@ -0,0 +1,69 @@ +format(\DateTimeInterface::ATOM); + + return Events::fromArray( + $events->map(function (EventInterface|DecoratedEvent $event) use ( + $initiatingUserId, + $initiatingTimestampFormatted + ) { + $metadata = $event instanceof DecoratedEvent ? $event->eventMetadata?->value ?? [] : []; + $metadata[self::INITIATING_USER_ID] ??= $initiatingUserId; + $metadata[self::INITIATING_TIMESTAMP] ??= $initiatingTimestampFormatted; + + return DecoratedEvent::create($event, metadata: EventMetadata::fromArray($metadata)); + }) + ); + } + + public static function getInitiatingTimestamp(EventMetadata $eventMetadata): ?\DateTimeImmutable + { + $rawTimestamp = $eventMetadata->get(self::INITIATING_TIMESTAMP); + if ($rawTimestamp === null) { + return null; + } + return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $rawTimestamp) ?: null; + } + + public static function extractInitiatingMetadata(EventMetadata $eventMetadata): EventMetadata + { + return EventMetadata::fromArray(array_filter([ + self::INITIATING_USER_ID => $eventMetadata->get(self::INITIATING_USER_ID), + self::INITIATING_TIMESTAMP => $eventMetadata->get(self::INITIATING_TIMESTAMP), + ])); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index c5ba2c24772..d424f39138e 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -15,13 +15,14 @@ namespace Neos\ContentRepository\Core\Factory; use Neos\ContentRepository\Core\CommandHandler\CommandBus; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventPersister; -use Neos\ContentRepository\Core\Feature\ContentStreamCommandHandler; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\DimensionSpaceCommandHandler; use Neos\ContentRepository\Core\Feature\NodeAggregateCommandHandler; use Neos\ContentRepository\Core\Feature\NodeDuplication\NodeDuplicationCommandHandler; @@ -75,7 +76,6 @@ public function __construct( // The following properties store "singleton" references of objects for this content repository private ?ContentRepository $contentRepository = null; - private ?CommandBus $commandBus = null; private ?EventPersister $eventPersister = null; /** @@ -86,23 +86,61 @@ public function __construct( */ public function getOrBuild(): ContentRepository { - if (!$this->contentRepository) { - $this->contentRepository = new ContentRepository( - $this->contentRepositoryId, - $this->buildCommandBus(), - $this->projectionFactoryDependencies->eventStore, - $this->projectionsAndCatchUpHooks, - $this->projectionFactoryDependencies->eventNormalizer, - $this->buildEventPersister(), + if ($this->contentRepository) { + return $this->contentRepository; + } + + $contentGraphReadModel = $this->projectionsAndCatchUpHooks->contentGraphProjection->getState(); + $commandHandlingDependencies = new CommandHandlingDependencies($contentGraphReadModel); + + // we dont need full recursion in rebase - e.g apply workspace commands - and thus we can use this set for simulation + $commandBusForRebaseableCommands = new CommandBus( + $commandHandlingDependencies, + new NodeAggregateCommandHandler( $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->contentDimensionZookeeper, $this->projectionFactoryDependencies->interDimensionalVariationGraph, - $this->projectionFactoryDependencies->contentDimensionSource, - $this->userIdProvider, - $this->clock, - $this->projectionsAndCatchUpHooks->contentGraphProjection->getState() - ); - } - return $this->contentRepository; + $this->projectionFactoryDependencies->propertyConverter, + ), + new DimensionSpaceCommandHandler( + $this->projectionFactoryDependencies->contentDimensionZookeeper, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + ), + new NodeDuplicationCommandHandler( + $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->contentDimensionZookeeper, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + ) + ); + + $commandSimulatorFactory = new CommandSimulatorFactory( + $this->projectionsAndCatchUpHooks->contentGraphProjection, + $this->projectionFactoryDependencies->eventNormalizer, + $commandBusForRebaseableCommands + ); + + $publicCommandBus = $commandBusForRebaseableCommands->withAdditionalHandlers( + new WorkspaceCommandHandler( + $commandSimulatorFactory, + $this->projectionFactoryDependencies->eventStore, + $this->projectionFactoryDependencies->eventNormalizer, + ) + ); + + return $this->contentRepository = new ContentRepository( + $this->contentRepositoryId, + $publicCommandBus, + $this->projectionFactoryDependencies->eventStore, + $this->projectionsAndCatchUpHooks, + $this->projectionFactoryDependencies->eventNormalizer, + $this->buildEventPersister(), + $this->projectionFactoryDependencies->nodeTypeManager, + $this->projectionFactoryDependencies->interDimensionalVariationGraph, + $this->projectionFactoryDependencies->contentDimensionSource, + $this->userIdProvider, + $this->clock, + $contentGraphReadModel + ); } /** @@ -129,37 +167,6 @@ public function buildService( return $serviceFactory->build($serviceFactoryDependencies); } - private function buildCommandBus(): CommandBus - { - if (!$this->commandBus) { - $this->commandBus = new CommandBus( - new ContentStreamCommandHandler( - ), - new WorkspaceCommandHandler( - $this->buildEventPersister(), - $this->projectionFactoryDependencies->eventStore, - $this->projectionFactoryDependencies->eventNormalizer, - ), - new NodeAggregateCommandHandler( - $this->projectionFactoryDependencies->nodeTypeManager, - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - $this->projectionFactoryDependencies->propertyConverter, - ), - new DimensionSpaceCommandHandler( - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - ), - new NodeDuplicationCommandHandler( - $this->projectionFactoryDependencies->nodeTypeManager, - $this->projectionFactoryDependencies->contentDimensionZookeeper, - $this->projectionFactoryDependencies->interDimensionalVariationGraph, - ) - ); - } - return $this->commandBus; - } - private function buildEventPersister(): EventPersister { if (!$this->eventPersister) { diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionsAndCatchUpHooksFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionsAndCatchUpHooksFactory.php index cd369a87a6e..f48ed1345c0 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionsAndCatchUpHooksFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionsAndCatchUpHooksFactory.php @@ -6,12 +6,12 @@ use Neos\ContentRepository\Core\Projection\CatchUpHookFactories; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\Projections; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; /** * @api for custom framework integrations, not for users of the CR @@ -19,7 +19,7 @@ final class ProjectionsAndCatchUpHooksFactory { /** - * @var array>, options: array, catchUpHooksFactories: array}> + * @var array>, options: array, catchUpHooksFactories: array>}> */ private array $factories = []; @@ -40,7 +40,7 @@ public function registerFactory(ProjectionFactoryInterface $factory, array $opti /** * @param ProjectionFactoryInterface> $factory - * @param CatchUpHookFactoryInterface $catchUpHookFactory + * @param CatchUpHookFactoryInterface $catchUpHookFactory * @return void * @api */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php index 428ac575ee8..2be9688f065 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/ConstraintChecks.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\Common; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; @@ -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; @@ -60,7 +61,6 @@ use Neos\ContentRepository\Core\SharedModel\Node\PropertyName; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\EventStore\Model\EventStream\ExpectedVersion; @@ -87,8 +87,8 @@ protected function requireContentStream( 1521386692 ); } - $state = $commandHandlingDependencies->getContentStreamStatus($contentStreamId); - if ($state === ContentStreamStatus::CLOSED) { + + if ($commandHandlingDependencies->isContentStreamClosed($contentStreamId)) { throw new ContentStreamIsClosed( 'Content stream "' . $contentStreamId->value . '" is closed.', 1710260081 @@ -194,6 +194,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); @@ -242,21 +266,23 @@ protected function requireNodeTypeToAllowNodesOfTypeInReference( } } - protected function requireNodeTypeToAllowNumberOfReferencesInReference(SerializedNodeReferences $nodeReferences, ReferenceName $referenceName, NodeTypeName $nodeTypeName): void + protected function requireNodeTypeToAllowNumberOfReferencesInReference(SerializedNodeReferences $nodeReferences, NodeTypeName $nodeTypeName): void { $nodeType = $this->requireNodeType($nodeTypeName); - $maxItems = $nodeType->getReferences()[$referenceName->value]['constraints']['maxItems'] ?? null; - if ($maxItems === null) { - return; - } + foreach ($nodeReferences->references as $referencesByName) { + $maxItems = $nodeType->getReferences()[$referencesByName->referenceName->value]['constraints']['maxItems'] ?? null; + if ($maxItems === null) { + continue; + } - if ($maxItems < count($nodeReferences)) { - throw ReferenceCannotBeSet::becauseTheItemsCountConstraintsAreNotMatched( - $referenceName, - $nodeTypeName, - count($nodeReferences) - ); + if ($maxItems < $referencesByName->count()) { + throw ReferenceCannotBeSet::becauseTheItemsCountConstraintsAreNotMatched( + $referencesByName->referenceName, + $nodeTypeName, + $referencesByName->count() + ); + } } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeReferencingInternals.php b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeReferencingInternals.php new file mode 100644 index 00000000000..6e39c1e0ebf --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/NodeReferencingInternals.php @@ -0,0 +1,58 @@ +references as $reference) { + $serializedReferences[] = SerializedNodeReference::fromTargetAndProperties( + $reference->targetNodeAggregateId, + $this->getPropertyConverter()->serializeReferencePropertyValues( + $reference->properties, + $this->requireNodeType($nodeTypeName), + $referencesByProperty->referenceName + ) + ); + } + + $serializedReferencesByProperty[] = SerializedNodeReferencesForName::fromSerializedReferences( + $referencesByProperty->referenceName, + $serializedReferences + ); + } + + return SerializedNodeReferences::fromArray($serializedReferencesByProperty); + } +} 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/PublishableToWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/PublishableToWorkspaceInterface.php index 2660b94b623..0d319209ac6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/PublishableToWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/PublishableToWorkspaceInterface.php @@ -14,6 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\Common; +use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -26,7 +27,7 @@ * * @internal used internally for the publishing mechanism of workspaces */ -interface PublishableToWorkspaceInterface +interface PublishableToWorkspaceInterface extends EventInterface { public function withWorkspaceNameAndContentStreamId(WorkspaceName $targetWorkspaceName, ContentStreamId $contentStreamId): self; } diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php index 4d2d5094818..c7b3d5cbc59 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php @@ -25,11 +25,11 @@ * * @internal used internally for the rebasing mechanism of content streams */ -interface RebasableToOtherWorkspaceInterface +interface RebasableToOtherWorkspaceInterface extends CommandInterface { public function createCopyForWorkspace( WorkspaceName $targetWorkspaceName, - ): CommandInterface; + ): self; /** * called during deserialization from metadata diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php b/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php index a8717cb2088..57b99797015 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/TetheredNodeInternals.php @@ -15,18 +15,31 @@ */ 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\NodeReferencing\Dto\SerializedNodeReferences; +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 @@ -104,6 +117,7 @@ protected function createEventsForMissingTetheredNode( $tetheredNodeTypeDefinition->name, $defaultProperties, NodeAggregateClassification::CLASSIFICATION_TETHERED, + SerializedNodeReferences::createEmpty() ); $creationOriginDimensionSpacePoint = $rootGeneralizationOrigin; } @@ -124,6 +138,7 @@ protected function createEventsForMissingTetheredNode( $tetheredNodeTypeDefinition->name, $defaultProperties, NodeAggregateClassification::CLASSIFICATION_TETHERED, + SerializedNodeReferences::createEmpty(), ) ); } @@ -148,4 +163,207 @@ 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, + SerializedNodeReferences::createEmpty(), + ); + + $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/ContentStreamClosing/Command/CloseContentStream.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Command/CloseContentStream.php deleted file mode 100644 index 16dfd711342..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Command/CloseContentStream.php +++ /dev/null @@ -1,53 +0,0 @@ - $array - * @internal only used for testcases - */ - public static function fromArray(array $array): self - { - return new self( - ContentStreamId::fromString($array['contentStreamId']), - ); - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Command/ReopenContentStream.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Command/ReopenContentStream.php deleted file mode 100644 index 10280ae9890..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Command/ReopenContentStream.php +++ /dev/null @@ -1,63 +0,0 @@ - $array - * @internal only used for testcases - */ - public static function fromArray(array $array): self - { - return new self( - ContentStreamId::fromString($array['contentStreamId']), - ContentStreamStatus::from($array['previousState']), - ); - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Event/ContentStreamWasReopened.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Event/ContentStreamWasReopened.php index 4b0248803f7..b783ee99fb5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Event/ContentStreamWasReopened.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamClosing/Event/ContentStreamWasReopened.php @@ -17,7 +17,6 @@ use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\Feature\Common\EmbedsContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; /** * @api events are the persistence-API of the content repository @@ -25,8 +24,7 @@ final readonly class ContentStreamWasReopened implements EventInterface, EmbedsContentStreamId { public function __construct( - public ContentStreamId $contentStreamId, - public ContentStreamStatus $previousState, + public ContentStreamId $contentStreamId ) { } @@ -38,16 +36,14 @@ public function getContentStreamId(): ContentStreamId public static function fromArray(array $values): self { return new self( - ContentStreamId::fromString($values['contentStreamId']), - ContentStreamStatus::from($values['previousState']), + ContentStreamId::fromString($values['contentStreamId']) ); } public function jsonSerialize(): array { return [ - 'contentStreamId' => $this->contentStreamId, - 'previousState' => $this->previousState, + 'contentStreamId' => $this->contentStreamId ]; } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCreation/Command/CreateContentStream.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCreation/Command/CreateContentStream.php deleted file mode 100644 index 8644048817e..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCreation/Command/CreateContentStream.php +++ /dev/null @@ -1,45 +0,0 @@ - $array - * @internal only used for testcases - */ - public static function fromArray(array $array): self - { - return new self( - ContentStreamId::fromString($array['contentStreamId']), - ContentStreamId::fromString($array['sourceContentStreamId']), - ); - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Event/ContentStreamWasForked.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Event/ContentStreamWasForked.php index 9c476787fc9..a2418445221 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Event/ContentStreamWasForked.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamForking/Event/ContentStreamWasForked.php @@ -15,13 +15,14 @@ */ use Neos\ContentRepository\Core\EventStore\EventInterface; +use Neos\ContentRepository\Core\Feature\Common\EmbedsContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\EventStore\Model\Event\Version; /** * @api events are the persistence-API of the content repository */ -final readonly class ContentStreamWasForked implements EventInterface +final readonly class ContentStreamWasForked implements EventInterface, EmbedsContentStreamId { public function __construct( /** @@ -33,6 +34,11 @@ public function __construct( ) { } + public function getContentStreamId(): ContentStreamId + { + return $this->newContentStreamId; + } + public static function fromArray(array $values): self { return new self( diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php similarity index 52% rename from Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php rename to Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php index 76d48439e76..c3027d71147 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamHandling.php @@ -1,153 +1,126 @@ getShortName()); - } - - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish - { - return match ($command::class) { - CreateContentStream::class => $this->handleCreateContentStream($command, $commandHandlingDependencies), - CloseContentStream::class => $this->handleCloseContentStream($command, $commandHandlingDependencies), - ReopenContentStream::class => $this->handleReopenContentStream($command, $commandHandlingDependencies), - ForkContentStream::class => $this->handleForkContentStream($command, $commandHandlingDependencies), - RemoveContentStream::class => $this->handleRemoveContentStream($command, $commandHandlingDependencies), - default => throw new \DomainException('Cannot handle commands of class ' . get_class($command), 1710408206), - }; - } - /** + * @param ContentStreamId $contentStreamId The id of the content stream to create * @throws ContentStreamAlreadyExists + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ - private function handleCreateContentStream( - CreateContentStream $command, + private function createContentStream( + ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies, ): EventsToPublish { - $this->requireContentStreamToNotExistYet($command->contentStreamId, $commandHandlingDependencies); - $streamName = ContentStreamEventStreamName::fromContentStreamId($command->contentStreamId) + $this->requireContentStreamToNotExistYet($contentStreamId, $commandHandlingDependencies); + $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId) ->getEventStreamName(); return new EventsToPublish( $streamName, Events::with( new ContentStreamWasCreated( - $command->contentStreamId, + $contentStreamId, ) ), ExpectedVersion::NO_STREAM() ); } - private function handleCloseContentStream( - CloseContentStream $command, + /** + * @param ContentStreamId $contentStreamId The id of the content stream to close + * @param CommandHandlingDependencies $commandHandlingDependencies + * @return EventsToPublish + * @phpstan-pure this method is pure, to persist the events they must be handled outside + */ + private function closeContentStream( + ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies, ): EventsToPublish { - $this->requireContentStreamToExist($command->contentStreamId, $commandHandlingDependencies); - $expectedVersion = $this->getExpectedVersionOfContentStream($command->contentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotBeClosed($command->contentStreamId, $commandHandlingDependencies); - $streamName = ContentStreamEventStreamName::fromContentStreamId($command->contentStreamId)->getEventStreamName(); + $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); + $expectedVersion = $this->getExpectedVersionOfContentStream($contentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToNotBeClosed($contentStreamId, $commandHandlingDependencies); + $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(); return new EventsToPublish( $streamName, Events::with( new ContentStreamWasClosed( - $command->contentStreamId, + $contentStreamId, ), ), $expectedVersion ); } - private function handleReopenContentStream( - ReopenContentStream $command, + /** + * @param ContentStreamId $contentStreamId The id of the content stream to reopen + * @phpstan-pure this method is pure, to persist the events they must be handled outside + */ + private function reopenContentStream( + ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies, ): EventsToPublish { - $this->requireContentStreamToExist($command->contentStreamId, $commandHandlingDependencies); - $expectedVersion = $this->getExpectedVersionOfContentStream($command->contentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToBeClosed($command->contentStreamId, $commandHandlingDependencies); - $streamName = ContentStreamEventStreamName::fromContentStreamId($command->contentStreamId)->getEventStreamName(); + $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToBeClosed($contentStreamId, $commandHandlingDependencies); + $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName(); return new EventsToPublish( $streamName, Events::with( new ContentStreamWasReopened( - $command->contentStreamId, - $command->previousState, + $contentStreamId ), ), - $expectedVersion + ExpectedVersion::ANY() ); } /** + * @param ContentStreamId $newContentStreamId The id of the new content stream + * @param ContentStreamId $sourceContentStreamId The id of the content stream to fork * @throws ContentStreamAlreadyExists * @throws ContentStreamDoesNotExistYet + * @phpstan-pure this method is pure, to persist the events they must be handled outside */ - private function handleForkContentStream( - ForkContentStream $command, + private function forkContentStream( + ContentStreamId $newContentStreamId, + ContentStreamId $sourceContentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): EventsToPublish { - $this->requireContentStreamToExist($command->sourceContentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotBeClosed($command->sourceContentStreamId, $commandHandlingDependencies); - $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToExist($sourceContentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToNotBeClosed($sourceContentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToNotExistYet($newContentStreamId, $commandHandlingDependencies); - $sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($command->sourceContentStreamId); + $sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($sourceContentStreamId); - $streamName = ContentStreamEventStreamName::fromContentStreamId($command->newContentStreamId) + $streamName = ContentStreamEventStreamName::fromContentStreamId($newContentStreamId) ->getEventStreamName(); return new EventsToPublish( $streamName, Events::with( new ContentStreamWasForked( - $command->newContentStreamId, - $command->sourceContentStreamId, + $newContentStreamId, + $sourceContentStreamId, $sourceContentStreamVersion, ), ), @@ -156,22 +129,26 @@ private function handleForkContentStream( ); } - private function handleRemoveContentStream( - RemoveContentStream $command, + /** + * @param ContentStreamId $contentStreamId The id of the content stream to remove + * @phpstan-pure this method is pure, to persist the events they must be handled outside + */ + private function removeContentStream( + ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): EventsToPublish { - $this->requireContentStreamToExist($command->contentStreamId, $commandHandlingDependencies); - $expectedVersion = $this->getExpectedVersionOfContentStream($command->contentStreamId, $commandHandlingDependencies); + $this->requireContentStreamToExist($contentStreamId, $commandHandlingDependencies); + $expectedVersion = $this->getExpectedVersionOfContentStream($contentStreamId, $commandHandlingDependencies); $streamName = ContentStreamEventStreamName::fromContentStreamId( - $command->contentStreamId + $contentStreamId )->getEventStreamName(); return new EventsToPublish( $streamName, Events::with( new ContentStreamWasRemoved( - $command->contentStreamId, + $contentStreamId, ), ), $expectedVersion @@ -183,7 +160,7 @@ private function handleRemoveContentStream( * @param CommandHandlingDependencies $commandHandlingDependencies * @throws ContentStreamAlreadyExists */ - protected function requireContentStreamToNotExistYet( + private function requireContentStreamToNotExistYet( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): void { @@ -200,7 +177,7 @@ protected function requireContentStreamToNotExistYet( * @param CommandHandlingDependencies $commandHandlingDependencies * @throws ContentStreamDoesNotExistYet */ - protected function requireContentStreamToExist( + private function requireContentStreamToExist( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): void { @@ -212,11 +189,11 @@ protected function requireContentStreamToExist( } } - protected function requireContentStreamToNotBeClosed( + private function requireContentStreamToNotBeClosed( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): void { - if ($commandHandlingDependencies->getContentStreamStatus($contentStreamId) === ContentStreamStatus::CLOSED) { + if ($commandHandlingDependencies->isContentStreamClosed($contentStreamId)) { throw new ContentStreamIsClosed( 'Content stream "' . $contentStreamId->value . '" is closed.', 1710260081 @@ -224,11 +201,11 @@ protected function requireContentStreamToNotBeClosed( } } - protected function requireContentStreamToBeClosed( + private function requireContentStreamToBeClosed( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): void { - if ($commandHandlingDependencies->getContentStreamStatus($contentStreamId) !== ContentStreamStatus::CLOSED) { + if (!$commandHandlingDependencies->isContentStreamClosed($contentStreamId)) { throw new ContentStreamIsNotClosed( 'Content stream "' . $contentStreamId->value . '" is not closed.', 1710405911 @@ -236,7 +213,7 @@ protected function requireContentStreamToBeClosed( } } - protected function getExpectedVersionOfContentStream( + private function getExpectedVersionOfContentStream( ContentStreamId $contentStreamId, CommandHandlingDependencies $commandHandlingDependencies ): ExpectedVersion { diff --git a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamRemoval/Command/RemoveContentStream.php b/Neos.ContentRepository.Core/Classes/Feature/ContentStreamRemoval/Command/RemoveContentStream.php deleted file mode 100644 index f79c0fe1da8..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/ContentStreamRemoval/Command/RemoveContentStream.php +++ /dev/null @@ -1,44 +0,0 @@ -getWorkspaceName(), - $contentGraph->getContentStreamId(), - $command->source, - $command->target - ), + RebaseableCommand::enrichWithCommand( + $command, + Events::with( + new DimensionSpacePointWasMoved( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $command->source, + $command->target + ), + ) ), ExpectedVersion::ANY() ); @@ -107,12 +111,15 @@ private function handleAddDimensionShineThrough( return new EventsToPublish( $streamName, - Events::with( - new DimensionShineThroughWasAdded( - $contentGraph->getWorkspaceName(), - $contentGraph->getContentStreamId(), - $command->source, - $command->target + RebaseableCommand::enrichWithCommand( + $command, + Events::with( + new DimensionShineThroughWasAdded( + $contentGraph->getWorkspaceName(), + $contentGraph->getContentStreamId(), + $command->source, + $command->target + ) ) ), ExpectedVersion::ANY() diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php index 3830cf78e29..eea2319dd3a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeAggregateCommandHandler.php @@ -16,7 +16,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php index 3616ceda798..4aac2bb8761 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNode.php @@ -18,6 +18,7 @@ use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -44,6 +45,7 @@ * @param NodeAggregateId|null $succeedingSiblingNodeAggregateId Node aggregate id of the node's succeeding sibling (optional). If not given, the node will be added as the parent's first child * @param NodeName|null $nodeName The node's optional name. Set if there is a meaningful relation to its parent that should be named. * @param NodeAggregateIdsByNodePaths $tetheredDescendantNodeAggregateIds Predefined aggregate ids of tethered child nodes per path. For any tethered node that has no matching entry in this set, the node aggregate id is generated randomly. Since tethered nodes may have tethered child nodes themselves, this works for multiple levels ({@see self::withTetheredDescendantNodeAggregateIds()}) + * @param NodeReferencesToWrite $references Initial references this node will have. If not given, no references are created */ private function __construct( public WorkspaceName $workspaceName, @@ -55,6 +57,7 @@ private function __construct( public ?NodeAggregateId $succeedingSiblingNodeAggregateId, public ?NodeName $nodeName, public NodeAggregateIdsByNodePaths $tetheredDescendantNodeAggregateIds, + public NodeReferencesToWrite $references, ) { } @@ -66,10 +69,11 @@ private function __construct( * @param NodeAggregateId $parentNodeAggregateId The id of the node aggregate underneath which the new node is added * @param NodeAggregateId|null $succeedingSiblingNodeAggregateId Node aggregate id of the node's succeeding sibling (optional). If not given, the node will be added as the parent's first child * @param PropertyValuesToWrite|null $initialPropertyValues The node's initial property values. Will be merged over the node type's default property values + * @param NodeReferencesToWrite|null $references Initial references this node will have (optional). If not given, no references are created */ - public static function create(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, NodeTypeName $nodeTypeName, OriginDimensionSpacePoint $originDimensionSpacePoint, NodeAggregateId $parentNodeAggregateId, ?NodeAggregateId $succeedingSiblingNodeAggregateId = null, ?PropertyValuesToWrite $initialPropertyValues = null): self + public static function create(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, NodeTypeName $nodeTypeName, OriginDimensionSpacePoint $originDimensionSpacePoint, NodeAggregateId $parentNodeAggregateId, ?NodeAggregateId $succeedingSiblingNodeAggregateId = null, ?PropertyValuesToWrite $initialPropertyValues = null, ?NodeReferencesToWrite $references = null): self { - return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?: PropertyValuesToWrite::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty()); + return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?: PropertyValuesToWrite::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty(), $references ?: NodeReferencesToWrite::createEmpty()); } public function withInitialPropertyValues(PropertyValuesToWrite $newInitialPropertyValues): self @@ -84,6 +88,7 @@ public function withInitialPropertyValues(PropertyValuesToWrite $newInitialPrope $this->succeedingSiblingNodeAggregateId, $this->nodeName, $this->tetheredDescendantNodeAggregateIds, + $this->references, ); } @@ -127,6 +132,7 @@ public function withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePat $this->succeedingSiblingNodeAggregateId, $this->nodeName, $tetheredDescendantNodeAggregateIds, + $this->references, ); } @@ -148,6 +154,26 @@ public function withNodeName(NodeName $nodeName): self $this->succeedingSiblingNodeAggregateId, $nodeName, $this->tetheredDescendantNodeAggregateIds, + $this->references, + ); + } + + /** + * Adds references to this creation command + */ + public function withReferences(NodeReferencesToWrite $references): self + { + return new self( + $this->workspaceName, + $this->nodeAggregateId, + $this->nodeTypeName, + $this->originDimensionSpacePoint, + $this->parentNodeAggregateId, + $this->initialPropertyValues, + $this->succeedingSiblingNodeAggregateId, + $this->nodeName, + $this->tetheredDescendantNodeAggregateIds, + $references, ); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php index f295cd6dabe..958b1a30590 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php @@ -20,6 +20,7 @@ use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -48,6 +49,7 @@ * @param NodeAggregateId|null $succeedingSiblingNodeAggregateId Node aggregate id of the node's succeeding sibling (optional). If not given, the node will be added as the parent's first child * @param NodeName|null $nodeName The node's optional name. Set if there is a meaningful relation to its parent that should be named. * @param NodeAggregateIdsByNodePaths $tetheredDescendantNodeAggregateIds Predefined aggregate ids of tethered child nodes per path. For any tethered node that has no matching entry in this set, the node aggregate id is generated randomly. Since tethered nodes may have tethered child nodes themselves, this works for multiple levels ({@see self::withTetheredDescendantNodeAggregateIds()}) + * @param SerializedNodeReferences $references The node's initial references (serialized). */ private function __construct( public WorkspaceName $workspaceName, @@ -58,7 +60,8 @@ private function __construct( public SerializedPropertyValues $initialPropertyValues, public ?NodeAggregateId $succeedingSiblingNodeAggregateId, public ?NodeName $nodeName, - public NodeAggregateIdsByNodePaths $tetheredDescendantNodeAggregateIds + public NodeAggregateIdsByNodePaths $tetheredDescendantNodeAggregateIds, + public SerializedNodeReferences $references, ) { } @@ -70,10 +73,11 @@ private function __construct( * @param NodeAggregateId $parentNodeAggregateId The id of the node aggregate underneath which the new node is added * @param NodeAggregateId|null $succeedingSiblingNodeAggregateId Node aggregate id of the node's succeeding sibling (optional). If not given, the node will be added as the parent's first child * @param SerializedPropertyValues|null $initialPropertyValues The node's initial property values (serialized). Will be merged over the node type's default property values + * @param SerializedNodeReferences|null $references The node's initial references (serialized). */ - public static function create(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, NodeTypeName $nodeTypeName, OriginDimensionSpacePoint $originDimensionSpacePoint, NodeAggregateId $parentNodeAggregateId, NodeAggregateId $succeedingSiblingNodeAggregateId = null, SerializedPropertyValues $initialPropertyValues = null): self + public static function create(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, NodeTypeName $nodeTypeName, OriginDimensionSpacePoint $originDimensionSpacePoint, NodeAggregateId $parentNodeAggregateId, NodeAggregateId $succeedingSiblingNodeAggregateId = null, SerializedPropertyValues $initialPropertyValues = null, SerializedNodeReferences $references = null): self { - return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?? SerializedPropertyValues::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty()); + return new self($workspaceName, $nodeAggregateId, $nodeTypeName, $originDimensionSpacePoint, $parentNodeAggregateId, $initialPropertyValues ?? SerializedPropertyValues::createEmpty(), $succeedingSiblingNodeAggregateId, null, NodeAggregateIdsByNodePaths::createEmpty(), $references ?: SerializedNodeReferences::createEmpty()); } /** @@ -98,7 +102,8 @@ public static function fromArray(array $array): self : null, isset($array['tetheredDescendantNodeAggregateIds']) ? NodeAggregateIdsByNodePaths::fromArray($array['tetheredDescendantNodeAggregateIds']) - : NodeAggregateIdsByNodePaths::createEmpty() + : NodeAggregateIdsByNodePaths::createEmpty(), + isset($array['references']) ? SerializedNodeReferences::fromArray($array['references']) : SerializedNodeReferences::createEmpty(), ); } @@ -120,7 +125,8 @@ public function withTetheredDescendantNodeAggregateIds( $this->initialPropertyValues, $this->succeedingSiblingNodeAggregateId, $this->nodeName, - $tetheredDescendantNodeAggregateIds + $tetheredDescendantNodeAggregateIds, + $this->references, ); } @@ -142,6 +148,7 @@ public function withNodeName(NodeName $nodeName): self $this->succeedingSiblingNodeAggregateId, $nodeName, $this->tetheredDescendantNodeAggregateIds, + $this->references, ); } @@ -157,7 +164,7 @@ public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return ( $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) - && $this->originDimensionSpacePoint->equals($nodeIdToPublish->dimensionSpacePoint) + && $nodeIdToPublish->dimensionSpacePoint?->equals($this->originDimensionSpacePoint) ); } @@ -173,7 +180,8 @@ public function createCopyForWorkspace( $this->initialPropertyValues, $this->succeedingSiblingNodeAggregateId, $this->nodeName, - $this->tetheredDescendantNodeAggregateIds + $this->tetheredDescendantNodeAggregateIds, + $this->references ); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreated.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreated.php index 9b1d63975b8..dd79e6e9263 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreated.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreated.php @@ -23,6 +23,7 @@ use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -53,6 +54,7 @@ public function __construct( public ?NodeName $nodeName, public SerializedPropertyValues $initialPropertyValues, public NodeAggregateClassification $nodeAggregateClassification, + public SerializedNodeReferences $nodeReferences, ) { } @@ -89,6 +91,7 @@ public function withWorkspaceNameAndContentStreamId(WorkspaceName $targetWorkspa $this->nodeName, $this->initialPropertyValues, $this->nodeAggregateClassification, + $this->nodeReferences, ); } @@ -112,6 +115,7 @@ public static function fromArray(array $values): self isset($values['nodeName']) ? NodeName::fromString($values['nodeName']) : null, SerializedPropertyValues::fromArray($values['initialPropertyValues']), NodeAggregateClassification::from($values['nodeAggregateClassification']), + isset($values['nodeReferences']) ? SerializedNodeReferences::fromArray($values['nodeReferences']) : SerializedNodeReferences::createEmpty(), ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php index a0533f52e97..b73ba4750f6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/NodeCreation.php @@ -14,14 +14,15 @@ namespace Neos\ContentRepository\Core\Feature\NodeCreation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeCreationInternals; +use Neos\ContentRepository\Core\Feature\Common\NodeReferencingInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; @@ -29,6 +30,7 @@ use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyType; use Neos\ContentRepository\Core\NodeType\NodeType; @@ -50,6 +52,7 @@ trait NodeCreation { use NodeCreationInternals; + use NodeReferencingInternals; abstract protected function getInterDimensionalVariationGraph(): DimensionSpace\InterDimensionalVariationGraph; @@ -86,7 +89,8 @@ private function handleCreateNodeAggregateWithNode( $this->getPropertyConverter()->serializePropertyValues( $command->initialPropertyValues->withoutUnsets(), $this->requireNodeType($command->nodeTypeName) - ) + ), + $this->mapNodeReferencesToSerializedNodeReferences($command->references, $command->nodeTypeName) ); if (!$command->tetheredDescendantNodeAggregateIds->isEmpty()) { $lowLevelCommand = $lowLevelCommand->withTetheredDescendantNodeAggregateIds($command->tetheredDescendantNodeAggregateIds); @@ -221,7 +225,7 @@ private function handleCreateNodeAggregateWithNodeAndSerializedProperties( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, Events::fromArray($events)), + RebaseableCommand::enrichWithCommand($command, Events::fromArray($events)), $expectedVersion ); } @@ -250,6 +254,7 @@ private function createRegularWithNode( $command->nodeName, $initialPropertyValues, NodeAggregateClassification::CLASSIFICATION_REGULAR, + $command->references ?? SerializedNodeReferences::createEmpty() ); } @@ -290,6 +295,7 @@ private function handleTetheredChildNodes( $tetheredNodeTypeDefinition->name, $initialPropertyValues, NodeAggregateClassification::CLASSIFICATION_TETHERED, + SerializedNodeReferences::createEmpty(), ); array_push($events, ...iterator_to_array($this->handleTetheredChildNodes( 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/NodeDisabling/NodeDisabling.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php index 4b4727757c0..c6e3820eb6e 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/NodeDisabling.php @@ -14,12 +14,12 @@ * source code. */ -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; @@ -82,7 +82,7 @@ private function handleDisableNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), @@ -137,7 +137,7 @@ public function handleEnableNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, $events), + RebaseableCommand::enrichWithCommand($command, $events), $expectedVersion ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/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/NodeDuplication/Dto/NodeReferenceSnapshot.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeReferenceSnapshot.php deleted file mode 100644 index 0e6aef7caab..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeReferenceSnapshot.php +++ /dev/null @@ -1,57 +0,0 @@ -values = $values; - } - - /** - * @param array $values - */ - public static function fromArray(array $values): self - { - return new self(SerializedPropertyValues::fromArray($values)); - } - - public function getValues(): SerializedPropertyValues - { - return $this->values; - } - - public function count(): int - { - return count($this->values); - } - - public function jsonSerialize(): SerializedPropertyValues - { - return $this->values; - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeReferencesSnapshot.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeReferencesSnapshot.php deleted file mode 100644 index 15c0a691136..00000000000 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeReferencesSnapshot.php +++ /dev/null @@ -1,103 +0,0 @@ - - * @internal todo not yet finished - */ -final class NodeReferencesSnapshot implements \IteratorAggregate, \Countable, \JsonSerializable -{ - /** - * @var array - */ - private array $references; - - /** - * @param array $references - */ - private function __construct(array $references) - { - $this->references = $references; - } - - public function merge(self $other): self - { - return new self(array_merge($this->references, $other->getReferences())); - } - - /** - * @return array - */ - public function getReferences(): array - { - return $this->references; - } - - /** - * @param array|NodeReferenceSnapshot> $nodeReferences - */ - public static function fromArray(array $nodeReferences): self - { - $values = []; - foreach ($nodeReferences as $nodeReferenceName => $nodeReferenceValue) { - if (is_array($nodeReferenceValue)) { - $values[$nodeReferenceName] = NodeReferenceSnapshot::fromArray($nodeReferenceValue); - } elseif ($nodeReferenceValue instanceof NodeReferenceSnapshot) { - $values[$nodeReferenceName] = $nodeReferenceValue; - } else { - /** @var mixed $nodeReferenceValue */ - throw new \InvalidArgumentException(sprintf( - 'Invalid nodeReferences value. Expected instance of %s, got: %s', - NodeReferenceSnapshot::class, - is_object($nodeReferenceValue) ? get_class($nodeReferenceValue) : gettype($nodeReferenceValue) - ), 1546524480); - } - } - - return new self($values); - } - - /** - * @todo what is this supposed to do? - * Good question. - */ - public static function fromReferences(References $nodeReferences): self - { - $values = []; - - return new self($values); - } - - public function getIterator(): \Traversable - { - yield from $this->references; - } - - public function count(): int - { - return count($this->references); - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - return $this->references; - } -} diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeSubtreeSnapshot.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeSubtreeSnapshot.php index 2ca897bc6fc..1a724f5dbef 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeSubtreeSnapshot.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Dto/NodeSubtreeSnapshot.php @@ -5,14 +5,19 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication\Dto; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReference; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferencesForName; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindReferencesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; +use Neos\ContentRepository\Core\Projection\ContentGraph\References; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; +use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** * Implementation detail of {@see CopyNodesRecursively} @@ -30,7 +35,7 @@ private function __construct( public ?NodeName $nodeName, public NodeAggregateClassification $nodeAggregateClassification, public SerializedPropertyValues $propertyValues, - public NodeReferencesSnapshot $nodeReferences, + public SerializedNodeReferences $nodeReferences, public array $childNodes ) { foreach ($childNodes as $childNode) { @@ -58,7 +63,7 @@ public static function fromSubgraphAndStartNode(ContentSubgraphInterface $subgra $sourceNode->name, $sourceNode->classification, $properties->serialized(), - NodeReferencesSnapshot::fromReferences( + self::serializeProjectedReferences( $subgraph->findReferences($sourceNode->aggregateId, FindReferencesFilter::create()) ), $childNodes @@ -105,8 +110,26 @@ public static function fromArray(array $array): self isset($array['nodeName']) ? NodeName::fromString($array['nodeName']) : null, NodeAggregateClassification::from($array['nodeAggregateClassification']), SerializedPropertyValues::fromArray($array['propertyValues']), - NodeReferencesSnapshot::fromArray($array['nodeReferences']), + SerializedNodeReferences::fromArray($array['nodeReferences']), $childNodes ); } + + private static function serializeProjectedReferences(References $references): SerializedNodeReferences + { + $serializedReferences = []; + $serializedReferencesByName = []; + foreach ($references as $reference) { + if (!isset($serializedReferencesByName[$reference->name->value])) { + $serializedReferencesByName[$reference->name->value] = []; + } + $serializedReferencesByName[$reference->name->value][] = SerializedNodeReference::fromTargetAndProperties($reference->node->aggregateId, $reference->properties ? $reference->properties->serialized() : SerializedPropertyValues::createEmpty()); + } + + foreach ($serializedReferencesByName as $name => $referenceObjects) { + $serializedReferences[] = SerializedNodeReferencesForName::fromSerializedReferences(ReferenceName::fromString($name), $referenceObjects); + } + + return SerializedNodeReferences::fromArray($serializedReferences); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php index 7ef51daad82..edffe38ad5c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/NodeDuplicationCommandHandler.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\NodeDuplication; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; @@ -27,7 +27,7 @@ use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeCreationInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; @@ -160,7 +160,7 @@ private function handleCopyNodesRecursively( ContentStreamEventStreamName::fromContentStreamId( $contentGraph->getContentStreamId() )->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), @@ -214,6 +214,7 @@ private function createEventsForNodeToInsert( $targetNodeName, $nodeToInsert->propertyValues, $nodeToInsert->nodeAggregateClassification, + $nodeToInsert->nodeReferences, ); foreach ($nodeToInsert->childNodes as $childNodeToInsert) { 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/NodeModification/Dto/PropertyScope.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/PropertyScope.php index 6d94bba7cfb..3ccfb60a4b6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/PropertyScope.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Dto/PropertyScope.php @@ -17,6 +17,8 @@ use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; use Neos\ContentRepository\Core\NodeType\NodeType; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\SharedModel\Node\PropertyName; @@ -25,7 +27,7 @@ * The property scope to be used in NodeType property declarations. * Will affect node operations on properties in that they decide which of the node's variants will be modified as well. * - * @api used as part of commands + * @internal implementation of the {@see SetSerializedNodeProperties} and {@see SetSerializedNodeReferences} handling */ enum PropertyScope: string implements \JsonSerializable { diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php index e4633ca163d..7608fef3c50 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/NodeModification.php @@ -14,10 +14,10 @@ namespace Neos\ContentRepository\Core\Feature\NodeModification; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; @@ -132,7 +132,7 @@ private function handleSetSerializedNodeProperties( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php index 6dc6c947dc9..5dfae05275c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/NodeMove.php @@ -14,7 +14,7 @@ namespace Neos\ContentRepository\Core\Feature\NodeMove; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; @@ -23,7 +23,7 @@ use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSibling; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeMove\Dto\RelationDistributionStrategy; @@ -207,7 +207,7 @@ private function handleMoveNodeAggregate( return new EventsToPublish( $contentStreamEventStreamName->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php index 4e893eff1a1..70da56b2c2d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetNodeReferences.php @@ -27,14 +27,12 @@ * @param WorkspaceName $workspaceName The workspace in which the create operation is to be performed * @param NodeAggregateId $sourceNodeAggregateId The identifier of the node aggregate to set references * @param OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint The dimension space for which the references should be set - * @param ReferenceName $referenceName Name of the reference to set * @param NodeReferencesToWrite $references Unserialized reference(s) to set */ private function __construct( public WorkspaceName $workspaceName, public NodeAggregateId $sourceNodeAggregateId, public OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, - public ReferenceName $referenceName, public NodeReferencesToWrite $references, ) { } @@ -43,11 +41,10 @@ private function __construct( * @param WorkspaceName $workspaceName The workspace in which the create operation is to be performed * @param NodeAggregateId $sourceNodeAggregateId The identifier of the node aggregate to set references * @param OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint The dimension space for which the references should be set - * @param ReferenceName $referenceName Name of the reference to set * @param NodeReferencesToWrite $references Unserialized reference(s) to set */ - public static function create(WorkspaceName $workspaceName, NodeAggregateId $sourceNodeAggregateId, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, ReferenceName $referenceName, NodeReferencesToWrite $references): self + public static function create(WorkspaceName $workspaceName, NodeAggregateId $sourceNodeAggregateId, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, NodeReferencesToWrite $references): self { - return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $referenceName, $references); + return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $references); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php index 3f635af08e0..ae96cc5af13 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php @@ -21,8 +21,6 @@ use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -42,14 +40,12 @@ * @param WorkspaceName $workspaceName The workspace in which the create operation is to be performed * @param NodeAggregateId $sourceNodeAggregateId The identifier of the node aggregate to set references * @param OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint The dimension space for which the references should be set - * @param ReferenceName $referenceName Name of the reference to set * @param SerializedNodeReferences $references Serialized reference(s) to set */ private function __construct( public WorkspaceName $workspaceName, public NodeAggregateId $sourceNodeAggregateId, public OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, - public ReferenceName $referenceName, public SerializedNodeReferences $references, ) { } @@ -58,12 +54,11 @@ private function __construct( * @param WorkspaceName $workspaceName The workspace in which the create operation is to be performed * @param NodeAggregateId $sourceNodeAggregateId The identifier of the node aggregate to set references * @param OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint The dimension space for which the references should be set - * @param ReferenceName $referenceName Name of the reference to set * @param SerializedNodeReferences $references Serialized reference(s) to set */ - public static function create(WorkspaceName $workspaceName, NodeAggregateId $sourceNodeAggregateId, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, ReferenceName $referenceName, SerializedNodeReferences $references): self + public static function create(WorkspaceName $workspaceName, NodeAggregateId $sourceNodeAggregateId, OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, SerializedNodeReferences $references): self { - return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $referenceName, $references); + return new self($workspaceName, $sourceNodeAggregateId, $sourceOriginDimensionSpacePoint, $references); } /** @@ -75,7 +70,6 @@ public static function fromArray(array $array): self WorkspaceName::fromString($array['workspaceName']), NodeAggregateId::fromString($array['sourceNodeAggregateId']), OriginDimensionSpacePoint::fromArray($array['sourceOriginDimensionSpacePoint']), - ReferenceName::fromString($array['referenceName']), SerializedNodeReferences::fromArray($array['references']), ); } @@ -91,7 +85,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) ); } @@ -103,7 +97,6 @@ public function createCopyForWorkspace( $targetWorkspaceName, $this->sourceNodeAggregateId, $this->sourceOriginDimensionSpacePoint, - $this->referenceName, $this->references, ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferenceToWrite.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferenceToWrite.php index 65f3744606a..ca19593f6dc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferenceToWrite.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferenceToWrite.php @@ -18,41 +18,39 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; /** - * A single node references to write, supports arbitrary objects as reference property values - * by using {@see PropertyValuesToWrite}. - * Will be then converted to {@see SerializedNodeReferences} inside the events and persisted commands. + * A single node references to write + * + * Simple: + * Just a node aggregate id as target {@see fromTarget} + * + * With properties: + * Additionally to the target also properties can be specified to be set on the references by using {@see PropertyValuesToWrite} in {@see fromTargetAndProperties}. + * We expect the value types to match the configured types of the NodeType + * + * Will be converted to {@see SerializedNodeReferences} inside the events and persisted commands. * - * We expect the property value types to match the NodeType's property types - * (this is validated in the command handler). * @api used as part of commands */ -final readonly class NodeReferenceToWrite implements \JsonSerializable +final readonly class NodeReferenceToWrite { - public function __construct( + private function __construct( public NodeAggregateId $targetNodeAggregateId, - public ?PropertyValuesToWrite $properties + public PropertyValuesToWrite $properties ) { } /** - * @param array $array + * The node aggregate id as target of the reference to write + * + * To set a collection of node aggregate ids as targets see {@see NodeReferencesForName::fromTargets()} as utility */ - public static function fromArray(array $array): self + public static function fromTarget(NodeAggregateId $target): self { - return new self( - NodeAggregateId::fromString($array['targetNodeAggregateId']), - isset($array['properties']) ? PropertyValuesToWrite::fromArray($array['properties']) : null - ); + return new self($target, PropertyValuesToWrite::createEmpty()); } - /** - * @return array - */ - public function jsonSerialize(): array + public static function fromTargetAndProperties(NodeAggregateId $target, PropertyValuesToWrite $properties): self { - return [ - 'targetNodeAggregateId' => $this->targetNodeAggregateId, - 'properties' => $this->properties - ]; + return new self($target, $properties); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesForName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesForName.php new file mode 100644 index 00000000000..4c0200cf7e3 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesForName.php @@ -0,0 +1,70 @@ + + */ + public array $references; + + private function __construct( + public ReferenceName $referenceName, + NodeReferenceToWrite ...$references + ) { + $referencesByTarget = []; + foreach ($references as $reference) { + if (isset($referencesByTarget[$reference->targetNodeAggregateId->value])) { + throw new \InvalidArgumentException(sprintf('Duplicate entry in references to write. Target "%s" already exists in collection.', $reference->targetNodeAggregateId->value), 1730365958); + } + $referencesByTarget[$reference->targetNodeAggregateId->value] = true; + } + $this->references = $references; + } + + /** + * As the previously set references will be replaced by writing new references specifying + * no references for a name will delete the previous ones + */ + public static function createEmpty(ReferenceName $name): self + { + return new self($name, ...[]); + } + + public static function fromTargets(ReferenceName $name, NodeAggregateIds $nodeAggregateIds): self + { + $references = array_map(NodeReferenceToWrite::fromTarget(...), iterator_to_array($nodeAggregateIds)); + return new self($name, ...$references); + } + + /** + * @param NodeReferenceToWrite[] $references + */ + public static function fromReferences(ReferenceName $name, array $references): self + { + return new self($name, ...$references); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesToWrite.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesToWrite.php index df476935075..fae2924e38b 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesToWrite.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/NodeReferencesToWrite.php @@ -14,91 +14,71 @@ namespace Neos\ContentRepository\Core\Feature\NodeReferencing\Dto; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; - /** - * Node references to write, supports arbitrary objects as reference property values. - * Will be then converted to {@see SerializedNodeReferences} inside the events and persisted commands. + * Node references to write * - * We expect the value types to match the NodeType's property types (this is validated in the command handler). + * As we support property values on the reference this definition object structure will be converted to its Serialized* counterparts. + * These serializable objects will then be part of the events and persisted commands. See {@see SerializedNodeReferences} * - * @implements \IteratorAggregate + * @implements \IteratorAggregate * @api used as part of commands */ -final readonly class NodeReferencesToWrite implements \IteratorAggregate, \Countable, \JsonSerializable +final readonly class NodeReferencesToWrite implements \IteratorAggregate { /** - * @var array + * @var array */ - public array $references; + public array $referencesForName; - private function __construct(NodeReferenceToWrite ...$references) + private function __construct(NodeReferencesForName ...$items) { - $this->references = $references; + $referencesForName = []; + foreach ($items as $item) { + if (isset($referencesForName[$item->referenceName->value])) { + throw new \InvalidArgumentException(sprintf('NodeReferencesToWrite does not accept references for the same name %s multiple times.', $item->referenceName->value), 1718193720); + } + $referencesForName[$item->referenceName->value] = $item; + } + $this->referencesForName = $referencesForName; } - /** - * @param array $references - */ - public static function fromReferences(array $references): self + public static function createEmpty(): self { - return new self(...$references); + return new self(); } - /** - * @param array> $values - */ - public static function fromArray(array $values): self + public static function create(NodeReferencesForName ...$referencesForName): self { - return new self(...array_map( - fn (array $serializedReference): NodeReferenceToWrite - => NodeReferenceToWrite::fromArray($serializedReference), - $values - )); + return new self(...$referencesForName); } /** - * Unset all references for this reference name. + * @param array $references */ - public static function createEmpty(): self + public static function fromArray(array $references): self { - return new self(); + return new self(...$references); } - public static function fromNodeAggregateIds(NodeAggregateIds $nodeAggregateIds): self + public function withReference(NodeReferencesForName $referencesForName): self { - return new self(...array_map( - fn (NodeAggregateId $nodeAggregateId): NodeReferenceToWrite - => new NodeReferenceToWrite($nodeAggregateId, null), - iterator_to_array($nodeAggregateIds) - )); + $references = $this->referencesForName; + $references[$referencesForName->referenceName->value] = $referencesForName; + return new self(...$references); } - public static function fromJsonString(string $jsonString): self + public function merge(NodeReferencesToWrite $other): self { - try { - return self::fromArray(\json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR)); - } catch (\JsonException $e) { - throw new \RuntimeException(sprintf('Failed to JSON-decode "%s": %s', $jsonString, $e->getMessage()), 1723032146, $e); - } + return new self(...array_merge($this->referencesForName, $other->referencesForName)); } public function getIterator(): \Traversable { - yield from $this->references; - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - return $this->references; + yield from array_values($this->referencesForName); } - public function count(): int + public function isEmpty(): bool { - return count($this->references); + return count($this->referencesForName) === 0; } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReference.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReference.php index 2efe13b1778..e4b248ccf83 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReference.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReference.php @@ -20,35 +20,46 @@ /** * "Raw" / Serialized node reference as saved in the event log // in projections. * - * @internal + * @api used in commands and events {@see SerializedNodeReferences} */ final readonly class SerializedNodeReference implements \JsonSerializable { - public function __construct( + private function __construct( public NodeAggregateId $targetNodeAggregateId, - public ?SerializedPropertyValues $properties + public SerializedPropertyValues $properties ) { } + public static function fromTargetAndProperties(NodeAggregateId $targetNodeAggregateId, SerializedPropertyValues $properties): self + { + return new self($targetNodeAggregateId, $properties); + } + + public static function fromTarget(NodeAggregateId $targetNodeAggregateId): self + { + return new self($targetNodeAggregateId, SerializedPropertyValues::createEmpty()); + } + /** - * @param array $array + * @param array{"target": string, "properties"?: array} $array */ public static function fromArray(array $array): self { return new self( - NodeAggregateId::fromString($array['targetNodeAggregateId']), - $array['properties'] ? SerializedPropertyValues::fromArray($array['properties']) : null + NodeAggregateId::fromString($array['target']), + isset($array['properties']) ? SerializedPropertyValues::fromArray($array['properties']) : SerializedPropertyValues::createEmpty() ); } /** - * @return array + * @return array{"target": NodeAggregateId, "properties"?: SerializedPropertyValues} */ public function jsonSerialize(): array { - return [ - 'targetNodeAggregateId' => $this->targetNodeAggregateId, - 'properties' => $this->properties - ]; + $result = ['target' => $this->targetNodeAggregateId]; + if (count($this->properties) > 0) { + $result['properties'] = $this->properties; + } + return $result; } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferences.php index 1ce64f94fd9..bb8d2323703 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferences.php @@ -14,70 +14,55 @@ namespace Neos\ContentRepository\Core\Feature\NodeReferencing\Dto; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; +use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; /** - * A collection of SerializedNodeReference objects, to be used when creating reference relations. + * A collection of SerializedNodeReferencesForName objects, to be used when creating reference relations. * - * @implements \IteratorAggregate - * @internal + * @implements \IteratorAggregate + * @api used in commands and events */ -final readonly class SerializedNodeReferences implements \IteratorAggregate, \Countable, \JsonSerializable +final readonly class SerializedNodeReferences implements \JsonSerializable, \IteratorAggregate { /** - * @var array + * @var array */ public array $references; - private function __construct(SerializedNodeReference ...$references) + private function __construct(SerializedNodeReferencesForName ...$references) { - $existingTargets = []; + $seenNames = []; foreach ($references as $reference) { - if (isset($existingTargets[$reference->targetNodeAggregateId->value])) { - throw new \InvalidArgumentException(sprintf('Duplicate entry in references to write. Target "%s" already exists in collection.', $reference->targetNodeAggregateId->value), 1700150910); + $referenceNameExists = isset($seenNames[$reference->referenceName->value]); + if ($referenceNameExists) { + throw new \InvalidArgumentException(sprintf('You cannot set references for the same ReferenceName %s multiple times.', $reference->referenceName->value), 1718193720); } - $existingTargets[$reference->targetNodeAggregateId->value] = true; + $seenNames[$reference->referenceName->value] = true; } $this->references = $references; } - /** - * @param array $references - */ - public static function fromReferences(array $references): self + public static function createEmpty(): self { - return new self(...$references); + return new self(); } /** - * @param array> $referenceData + * @param array}> $namesAndReferences */ - public static function fromArray(array $referenceData): self + public static function fromArray(array $namesAndReferences): self { - return new self(...array_map( - fn (array $referenceDatum): SerializedNodeReference => SerializedNodeReference::fromArray($referenceDatum), - $referenceData - )); - } + $result = []; + foreach ($namesAndReferences as $referencesByProperty) { + $result[] = $referencesByProperty instanceof SerializedNodeReferencesForName ? $referencesByProperty : SerializedNodeReferencesForName::fromArray($referencesByProperty); + } - public static function fromNodeAggregateIds(NodeAggregateIds $nodeAggregateIds): self - { - return new self(...array_map( - static fn (NodeAggregateId $nodeAggregateId): SerializedNodeReference - => new SerializedNodeReference($nodeAggregateId, null), - iterator_to_array($nodeAggregateIds) - )); + return new self(...$result); } public static function fromJsonString(string $jsonString): self { - return self::fromArray(\json_decode($jsonString, true)); - } - - public function merge(self $other): self - { - return new self(...array_merge($this->references, $other->references)); + return self::fromArray(\json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR)); } public function getIterator(): \Traversable @@ -85,15 +70,20 @@ public function getIterator(): \Traversable yield from $this->references; } - public function count(): int + public function isEmpty(): bool { - return count($this->references); + return count($this->references) === 0; } /** - * @return array + * @return ReferenceName[] */ - public function jsonSerialize(): array + public function getReferenceNames(): array + { + return array_map(static fn(SerializedNodeReferencesForName $referencesForProperty) => $referencesForProperty->referenceName, $this->references); + } + + public function jsonSerialize(): mixed { return $this->references; } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferencesForName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferencesForName.php new file mode 100644 index 00000000000..0d2fc326bf9 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Dto/SerializedNodeReferencesForName.php @@ -0,0 +1,85 @@ + + */ + public array $references; + + private function __construct( + public ReferenceName $referenceName, + SerializedNodeReference ...$items + ) { + $referencesByTarget = []; + foreach ($items as $item) { + if (isset($referencesByTarget[$item->targetNodeAggregateId->value])) { + throw new \InvalidArgumentException(sprintf('Duplicate entry in references to write. Target "%s" already exists in collection.', $item->targetNodeAggregateId->value), 1700150910); + } + $referencesByTarget[$item->targetNodeAggregateId->value] = true; + } + $this->references = array_values($items); + } + + /** + * @param ReferenceName $referenceName + * @param SerializedNodeReference[] $references + * @return self + */ + public static function fromSerializedReferences(ReferenceName $referenceName, array $references): self + { + return new self($referenceName, ...$references); + } + + public static function fromTargets(ReferenceName $referenceName, NodeAggregateIds $nodeAggregateIds): self + { + $references = array_map(SerializedNodeReference::fromTarget(...), iterator_to_array($nodeAggregateIds)); + return new self($referenceName, ...$references); + } + + /** + * @param array{"referenceName": string, "references": array}>} $array + */ + public static function fromArray(array $array): self + { + return new self( + ReferenceName::fromString($array['referenceName']), + ...array_map(static fn(array $reference) => SerializedNodeReference::fromArray($reference), array_values($array['references'])) + ); + } + + public function jsonSerialize(): mixed + { + return [ + "referenceName" => $this->referenceName, + "references" => $this->references + ]; + } + + public function count(): int + { + return count($this->references); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Event/NodeReferencesWereSet.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Event/NodeReferencesWereSet.php index 21440d1a659..f7a0390ba6a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Event/NodeReferencesWereSet.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Event/NodeReferencesWereSet.php @@ -12,7 +12,6 @@ use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -41,7 +40,6 @@ public function __construct( * declared for the given reference in the node aggregate's type */ public OriginDimensionSpacePointSet $affectedSourceOriginDimensionSpacePoints, - public ReferenceName $referenceName, public SerializedNodeReferences $references, ) { } @@ -68,7 +66,6 @@ public function withWorkspaceNameAndContentStreamId(WorkspaceName $targetWorkspa $contentStreamId, $this->nodeAggregateId, $this->affectedSourceOriginDimensionSpacePoints, - $this->referenceName, $this->references, ); } @@ -82,7 +79,6 @@ public static function fromArray(array $values): self ? NodeAggregateId::fromString($values['sourceNodeAggregateId']) : NodeAggregateId::fromString($values['nodeAggregateId']), OriginDimensionSpacePointSet::fromArray($values['affectedSourceOriginDimensionSpacePoints']), - ReferenceName::fromString($values['referenceName']), SerializedNodeReferences::fromArray($values['references']), ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php index 6749780c5ed..cbfb71679d5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/NodeReferencing.php @@ -14,18 +14,19 @@ namespace Neos\ContentRepository\Core\Feature\NodeReferencing; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\Common\NodeReferencingInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyScope; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferenceToWrite; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReference; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; +use Neos\ContentRepository\Core\NodeType\NodeType; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; @@ -37,6 +38,7 @@ trait NodeReferencing { use ConstraintChecks; + use NodeReferencingInternals; abstract protected function requireProjectedNodeAggregate( ContentGraphInterface $contentGraph, @@ -58,13 +60,15 @@ private function handleSetNodeReferences( $this->requireNodeAggregateToNotBeRoot($sourceNodeAggregate); $nodeTypeName = $sourceNodeAggregate->nodeTypeName; - foreach ($command->references as $reference) { - if ($reference->properties) { - $this->validateReferenceProperties( - $command->referenceName, - $reference->properties, - $nodeTypeName - ); + foreach ($command->references as $referencesByProperty) { + foreach ($referencesByProperty->references as $reference) { + if ($reference->properties->values !== []) { + $this->validateReferenceProperties( + $referencesByProperty->referenceName, + $reference->properties, + $nodeTypeName + ); + } } } @@ -72,20 +76,7 @@ private function handleSetNodeReferences( $command->workspaceName, $command->sourceNodeAggregateId, $command->sourceOriginDimensionSpacePoint, - $command->referenceName, - Dto\SerializedNodeReferences::fromReferences(array_map( - fn (NodeReferenceToWrite $reference): SerializedNodeReference => new SerializedNodeReference( - $reference->targetNodeAggregateId, - $reference->properties - ? $this->getPropertyConverter()->serializeReferencePropertyValues( - $reference->properties, - $this->requireNodeType($nodeTypeName), - $command->referenceName - ) - : null - ), - $command->references->references - )), + $this->mapNodeReferencesToSerializedNodeReferences($command->references, $nodeTypeName), ); return $this->handleSetSerializedNodeReferences($lowLevelCommand, $commandHandlingDependencies); @@ -112,61 +103,79 @@ private function handleSetSerializedNodeReferences( $sourceNodeAggregate, $command->sourceOriginDimensionSpacePoint ); - $this->requireNodeTypeToDeclareReference($sourceNodeAggregate->nodeTypeName, $command->referenceName); + + $sourceNodeType = $this->requireNodeType($sourceNodeAggregate->nodeTypeName); + $events = []; $this->requireNodeTypeToAllowNumberOfReferencesInReference( $command->references, - $command->referenceName, $sourceNodeAggregate->nodeTypeName ); - foreach ($command->references as $reference) { - assert($reference instanceof SerializedNodeReference); - $destinationNodeAggregate = $this->requireProjectedNodeAggregate( - $contentGraph, - $reference->targetNodeAggregateId - ); - $this->requireNodeAggregateToNotBeRoot($destinationNodeAggregate); - $this->requireNodeAggregateToCoverDimensionSpacePoint( - $destinationNodeAggregate, - $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint() - ); - $this->requireNodeTypeToAllowNodesOfTypeInReference( - $sourceNodeAggregate->nodeTypeName, - $command->referenceName, - $destinationNodeAggregate->nodeTypeName - ); + foreach ($command->references as $referencesForName) { + $this->requireNodeTypeToDeclareReference($sourceNodeAggregate->nodeTypeName, $referencesForName->referenceName); + foreach ($referencesForName->references as $reference) { + $destinationNodeAggregate = $this->requireProjectedNodeAggregate( + $contentGraph, + $reference->targetNodeAggregateId + ); + $this->requireNodeAggregateToNotBeRoot($destinationNodeAggregate); + $this->requireNodeAggregateToCoverDimensionSpacePoint( + $destinationNodeAggregate, + $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint() + ); + $this->requireNodeTypeToAllowNodesOfTypeInReference( + $sourceNodeAggregate->nodeTypeName, + $referencesForName->referenceName, + $destinationNodeAggregate->nodeTypeName + ); + } } - $sourceNodeType = $this->requireNodeType($sourceNodeAggregate->nodeTypeName); - $scopeDeclaration = $sourceNodeType->getReferences()[$command->referenceName->value]['scope'] ?? ''; - $scope = PropertyScope::tryFrom($scopeDeclaration) ?: PropertyScope::SCOPE_NODE; - - $affectedOrigins = $scope->resolveAffectedOrigins( - $command->sourceOriginDimensionSpacePoint, - $sourceNodeAggregate, - $this->interDimensionalVariationGraph - ); - - $events = Events::with( - new NodeReferencesWereSet( + foreach (self::splitReferencesByScope($command->references, $sourceNodeType) as $rawScope => $references) { + $scope = PropertyScope::from($rawScope); + $affectedOrigins = $scope->resolveAffectedOrigins( + $command->sourceOriginDimensionSpacePoint, + $sourceNodeAggregate, + $this->interDimensionalVariationGraph + ); + $events[] = new NodeReferencesWereSet( $contentGraph->getWorkspaceName(), $contentGraph->getContentStreamId(), $command->sourceNodeAggregateId, $affectedOrigins, - $command->referenceName, - $command->references, - ) - ); + $references, + ); + } + + $events = Events::fromArray($events); return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), $expectedVersion ); } + + /** + * @return array + */ + private static function splitReferencesByScope(SerializedNodeReferences $nodeReferences, NodeType $nodeType): array + { + $referencesByScope = []; + foreach ($nodeReferences as $nodeReferenceForName) { + $scopeDeclaration = $nodeType->getReferences()[$nodeReferenceForName->referenceName->value]['scope'] ?? ''; + $scope = PropertyScope::tryFrom($scopeDeclaration) ?: PropertyScope::SCOPE_NODE; + $referencesByScope[$scope->value][] = $nodeReferenceForName; + } + + return array_map( + SerializedNodeReferences::fromArray(...), + $referencesByScope + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php index e6b62d986b1..92dee4b597d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/NodeRemoval.php @@ -14,12 +14,12 @@ namespace Neos\ContentRepository\Core\Feature\NodeRemoval; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRemoval\Event\NodeAggregateWasRemoved; @@ -91,7 +91,7 @@ private function handleRemoveNodeAggregate( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php index ab2af2b4bb6..f3531340345 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/NodeRenaming.php @@ -14,11 +14,11 @@ namespace Neos\ContentRepository\Core\Feature\NodeRenaming; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\Feature\NodeRenaming\Event\NodeAggregateNameWasChanged; @@ -61,7 +61,7 @@ private function handleChangeNodeAggregateName(ChangeNodeAggregateName $command, return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php index 0398767e585..5436b048236 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/NodeTypeChange.php @@ -14,42 +14,45 @@ namespace Neos\ContentRepository\Core\Feature\NodeTypeChange; -use Neos\ContentRepository\Core\CommandHandlingDependencies; -use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; +use Neos\ContentRepository\Core\Feature\Common\NodeTypeChangeInternals; +use Neos\ContentRepository\Core\Feature\Common\TetheredNodeInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -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,54 +194,92 @@ 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 + ))); } } return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events), ), @@ -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/NodeVariation/NodeVariation.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php index fc9a1864444..1e01e281265 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/NodeVariation.php @@ -14,11 +14,11 @@ namespace Neos\ContentRepository\Core\Feature\NodeVariation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\Exception\DimensionSpacePointNotFound; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\Common\NodeVariationInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; @@ -84,7 +84,7 @@ private function handleCreateNodeVariant( return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php similarity index 56% rename from Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php rename to Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php index d92b5f892c0..210f91b8b8a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/NodeAggregateEventPublisher.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommand.php @@ -2,34 +2,61 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\Feature\Common; +namespace Neos\ContentRepository\Core\Feature; -/* - * This file is part of the Neos.ContentRepository package. - * - * (c) Contributors of the Neos Project - www.neos.io - * - * This package is Open Source Software. For the full copyright and license - * information, please view the LICENSE file which was distributed with this - * source code. - */ - -use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\EventStore\DecoratedEvent; use Neos\ContentRepository\Core\EventStore\Events; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; +use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\EventStore\Model\Event\EventId; use Neos\EventStore\Model\Event\EventMetadata; +use Neos\EventStore\Model\Event\SequenceNumber; /** - * Stores the command in the event's metadata for events on a content stream. This is an important prerequisite - * for the rebase functionality- - * * @internal */ -final class NodeAggregateEventPublisher +final readonly class RebaseableCommand { + public function __construct( + public RebasableToOtherWorkspaceInterface $originalCommand, + public EventMetadata $initiatingMetaData, + public SequenceNumber $originalSequenceNumber + ) { + } + + public static function extractFromEventMetaData(EventMetadata $eventMetadata, SequenceNumber $sequenceNumber): self + { + $commandToRebaseClass = $eventMetadata->value['commandClass'] ?? null; + $commandToRebasePayload = $eventMetadata->value['commandPayload'] ?? null; + + if ($commandToRebaseClass === null || $commandToRebasePayload === null) { + throw new \RuntimeException('Command cannot be extracted from metadata, missing commandClass or commandPayload.', 1729847804); + } + + if (!in_array(RebasableToOtherWorkspaceInterface::class, class_implements($commandToRebaseClass) ?: [], true)) { + throw new \RuntimeException(sprintf( + 'Command "%s" can\'t be rebased because it does not implement %s', + $commandToRebaseClass, + RebasableToOtherWorkspaceInterface::class + ), 1547815341); + } + /** @var class-string $commandToRebaseClass */ + /** @var RebasableToOtherWorkspaceInterface $commandInstance */ + $commandInstance = $commandToRebaseClass::fromArray($commandToRebasePayload); + return new self( + $commandInstance, + InitiatingEventMetadata::extractInitiatingMetadata($eventMetadata), + $sequenceNumber + ); + } + + /** + * Stores the command in the event's metadata for events on a content stream. This is an important prerequisite + * for the rebase functionality- + */ public static function enrichWithCommand( - CommandInterface $command, + RebasableToOtherWorkspaceInterface $command, Events $events, ): Events { $processedEvents = []; @@ -54,7 +81,7 @@ public static function enrichWithCommand( if ($i === 0) { if (!$command instanceof \JsonSerializable) { throw new \RuntimeException(sprintf( - 'Command %s must be JSON Serializable to be used with NodeAggregateEventPublisher.', + 'Command %s must be JSON Serializable to be rebase able.', get_class($command) )); } @@ -81,7 +108,6 @@ public static function enrichWithCommand( $i++; } - return Events::fromArray($processedEvents); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php new file mode 100644 index 00000000000..5f4146321fe --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/RebaseableCommands.php @@ -0,0 +1,91 @@ + + */ +class RebaseableCommands implements \IteratorAggregate +{ + /** + * @var array + */ + private array $items; + + public function __construct( + RebaseableCommand ...$items + ) { + $this->items = $items; + } + + public static function extractFromEventStream(EventStreamInterface $eventStream): self + { + $commands = []; + foreach ($eventStream as $eventEnvelope) { + if ($eventEnvelope->event->metadata && isset($eventEnvelope->event->metadata?->value['commandClass'])) { + $commands[] = RebaseableCommand::extractFromEventMetaData($eventEnvelope->event->metadata, $eventEnvelope->sequenceNumber); + } + } + + return new RebaseableCommands(...$commands); + } + + /** + * @return array{RebaseableCommands,RebaseableCommands} + */ + public function separateMatchingAndRemainingCommands( + NodeIdsToPublishOrDiscard $nodeIdsToPublishOrDiscard + ): array { + $matchingCommands = []; + $remainingCommands = []; + foreach ($this->items as $extractedCommand) { + $originalCommand = $extractedCommand->originalCommand; + if (!$originalCommand instanceof MatchableWithNodeIdToPublishOrDiscardInterface) { + throw new \Exception( + 'Command class ' . get_class($originalCommand) . ' does not implement ' + . MatchableWithNodeIdToPublishOrDiscardInterface::class, + 1645393655 + ); + } + if (self::commandMatchesAtLeastOneNode($originalCommand, $nodeIdsToPublishOrDiscard)) { + $matchingCommands[] = $extractedCommand; + } else { + $remainingCommands[] = $extractedCommand; + } + } + return [ + new RebaseableCommands(...$matchingCommands), + new RebaseableCommands(...$remainingCommands) + ]; + } + + private static function commandMatchesAtLeastOneNode( + MatchableWithNodeIdToPublishOrDiscardInterface $command, + NodeIdsToPublishOrDiscard $nodeIds, + ): bool { + foreach ($nodeIds as $nodeId) { + if ($command->matchesNodeId($nodeId)) { + return true; + } + } + + return false; + } + + public function isEmpty(): bool + { + return $this->items === []; + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } +} diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php index f09fc214aaf..1ed9b90f862 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/RootNodeHandling.php @@ -14,17 +14,18 @@ namespace Neos\ContentRepository\Core\Feature\RootNodeCreation; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeCreation\Dto\NodeAggregateIdsByNodePaths; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateDimensionsWereUpdated; @@ -118,7 +119,7 @@ private function handleCreateRootNodeAggregateWithNode( $contentStreamEventStream = ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()); return new EventsToPublish( $contentStreamEventStream->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, Events::fromArray($events) ), @@ -173,7 +174,7 @@ private function handleUpdateRootNodeAggregateDimensions( ); return new EventsToPublish( $contentStreamEventStream->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), @@ -254,6 +255,7 @@ private function createTetheredWithNodeForRoot( $nodeName, $initialPropertyValues, NodeAggregateClassification::CLASSIFICATION_TETHERED, + SerializedNodeReferences::createEmpty(), ); } } 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/SubtreeTagging/SubtreeTagging.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php index d26f8c30118..b134d44cfdc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/SubtreeTagging.php @@ -14,12 +14,12 @@ * source code. */ -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\DimensionSpace; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\ConstraintChecks; -use Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher; +use Neos\ContentRepository\Core\Feature\RebaseableCommand; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; @@ -71,7 +71,7 @@ private function handleTagSubtree(TagSubtree $command, CommandHandlingDependenci return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId()) ->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand( + RebaseableCommand::enrichWithCommand( $command, $events ), @@ -116,7 +116,7 @@ public function handleUntagSubtree(UntagSubtree $command, CommandHandlingDepende return new EventsToPublish( ContentStreamEventStreamName::fromContentStreamId($contentGraph->getContentStreamId())->getEventStreamName(), - NodeAggregateEventPublisher::enrichWithCommand($command, $events), + RebaseableCommand::enrichWithCommand($command, $events), ExpectedVersion::ANY() ); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php index 1851a976560..20143536272 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php @@ -15,24 +15,19 @@ namespace Neos\ContentRepository\Core\Feature; use Neos\ContentRepository\Core\CommandHandler\CommandHandlerInterface; +use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\DecoratedEvent; use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\EventStore\EventNormalizer; -use Neos\ContentRepository\Core\EventStore\EventPersister; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\Common\MatchableWithNodeIdToPublishOrDiscardInterface; use Neos\ContentRepository\Core\Feature\Common\PublishableToWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\CloseContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\ReopenContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Command\CreateContentStream; -use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; +use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasClosed; +use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened; use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked; -use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Command\RemoveContentStream; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; @@ -50,29 +45,27 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasDiscarded; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyDiscarded; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyPublished; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPublished; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Exception\BaseWorkspaceHasBeenModifiedInTheMeantime; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandThatFailedDuringRebase; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; -use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamAlreadyExists; use Neos\ContentRepository\Core\SharedModel\Exception\ContentStreamDoesNotExistYet; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; use Neos\EventStore\EventStoreInterface; -use Neos\EventStore\Exception\ConcurrencyException; use Neos\EventStore\Model\Event\EventType; -use Neos\EventStore\Model\EventEnvelope; +use Neos\EventStore\Model\Event\SequenceNumber; +use Neos\EventStore\Model\Event\Version; +use Neos\EventStore\Model\EventStream\EventStreamInterface; use Neos\EventStore\Model\EventStream\ExpectedVersion; /** @@ -80,8 +73,10 @@ */ final readonly class WorkspaceCommandHandler implements CommandHandlerInterface { + use ContentStreamHandling; + public function __construct( - private EventPersister $eventPersister, + private CommandSimulatorFactory $commandSimulatorFactory, private EventStoreInterface $eventStore, private EventNormalizer $eventNormalizer, ) { @@ -92,7 +87,7 @@ public function canHandle(CommandInterface $command): bool return method_exists($this, 'handle' . (new \ReflectionClass($command))->getShortName()); } - public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): EventsToPublish + public function handle(CommandInterface $command, CommandHandlingDependencies $commandHandlingDependencies): \Generator { /** @phpstan-ignore-next-line */ return match ($command::class) { @@ -117,9 +112,11 @@ public function handle(CommandInterface $command, CommandHandlingDependencies $c private function handleCreateWorkspace( CreateWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $this->requireWorkspaceToNotExist($command->workspaceName, $commandHandlingDependencies); - if ($commandHandlingDependencies->findWorkspaceByName($command->baseWorkspaceName) === null) { + $baseWorkspace = $commandHandlingDependencies->findWorkspaceByName($command->baseWorkspaceName); + + if ($baseWorkspace === null) { throw new BaseWorkspaceDoesNotExist(sprintf( 'The workspace %s (base workspace of %s) does not exist', $command->baseWorkspaceName->value, @@ -127,185 +124,205 @@ private function handleCreateWorkspace( ), 1513890708); } - $baseWorkspaceContentGraph = $commandHandlingDependencies->getContentGraph($command->baseWorkspaceName); // When the workspace is created, we first have to fork the content stream - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->newContentStreamId, - $baseWorkspaceContentGraph->getContentStreamId(), - ) - ); - - $events = Events::with( - new WorkspaceWasCreated( - $command->workspaceName, - $command->baseWorkspaceName, - $command->newContentStreamId, - ) + yield $this->forkContentStream( + $command->newContentStreamId, + $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies ); - return new EventsToPublish( + yield new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), - $events, + Events::with( + new WorkspaceWasCreated( + $command->workspaceName, + $command->baseWorkspaceName, + $command->newContentStreamId, + ) + ), ExpectedVersion::ANY() ); } /** * @param CreateRootWorkspace $command - * @return EventsToPublish * @throws WorkspaceAlreadyExists * @throws ContentStreamAlreadyExists */ private function handleCreateRootWorkspace( CreateRootWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $this->requireWorkspaceToNotExist($command->workspaceName, $commandHandlingDependencies); $newContentStreamId = $command->newContentStreamId; - $commandHandlingDependencies->handle( - CreateContentStream::create( - $newContentStreamId, - ) + yield $this->createContentStream( + $newContentStreamId, + $commandHandlingDependencies ); - $events = Events::with( - new RootWorkspaceWasCreated( - $command->workspaceName, - $newContentStreamId - ) - ); - - return new EventsToPublish( + yield new EventsToPublish( WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), - $events, + Events::with( + new RootWorkspaceWasCreated( + $command->workspaceName, + $newContentStreamId + ) + ), ExpectedVersion::ANY() ); } - /** - * @throws BaseWorkspaceDoesNotExist - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime - * @throws ContentStreamAlreadyExists - * @throws ContentStreamDoesNotExistYet - * @throws WorkspaceDoesNotExist - * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface - * @throws WorkspaceHasNoBaseWorkspaceName - */ private function handlePublishWorkspace( PublishWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + if (!$workspace->hasPublishableChanges()) { + // no-op + return; + } + + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot publish nodes on a workspace with a stateless content stream', 1729711258); + } + $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); + $baseContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); - $this->publishContentStream( - $commandHandlingDependencies, + yield $this->closeContentStream( $workspace->currentContentStreamId, - $baseWorkspace->workspaceName, - $baseWorkspace->currentContentStreamId + $commandHandlingDependencies ); - // After publishing a workspace, we need to again fork from Base. - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->newContentStreamId, - $baseWorkspace->currentContentStreamId, + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - $events = Events::with( - new WorkspaceWasPublished( - $command->workspaceName, - $baseWorkspace->workspaceName, + try { + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, $command->newContentStreamId, + $baseContentStreamVersion, + $rebaseableCommands, + $commandHandlingDependencies + ); + } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { + yield $this->reopenContentStream( $workspace->currentContentStreamId, - ) + $commandHandlingDependencies + ); + throw $workspaceRebaseFailed; + } + } + + private function publishWorkspace( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStreamId, + Version $baseContentStreamVersion, + RebaseableCommands $rebaseableCommands, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); + + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); + } + } ); - // if we got so far without an Exception, we can switch the Workspace's active Content stream. - return new EventsToPublish( - $streamName, - $events, + if ($commandSimulator->hasCommandsThatFailed()) { + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); + } + + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream(), + ), + ExpectedVersion::fromVersion($baseContentStreamVersion) + ); + + yield $this->forkContentStream( + $newContentStreamId, + $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies + ); + + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasPublished( + $workspace->workspaceName, + $baseWorkspace->workspaceName, + $newContentStreamId, + $workspace->currentContentStreamId, + ) + ), ExpectedVersion::ANY() ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } - /** - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime - * @throws \Exception - */ - private function publishContentStream( + private function rebaseWorkspaceWithoutChanges( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStreamId, CommandHandlingDependencies $commandHandlingDependencies, - ContentStreamId $contentStreamId, - WorkspaceName $baseWorkspaceName, - ContentStreamId $baseContentStreamId, - ): void { - $baseWorkspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $baseContentStreamId - ); - - // TODO: please check the code below in-depth. it does: - // - copy all events from the "user" content stream which implement @see{}"PublishableToOtherContentStreamsInterface" - // - extract the initial ContentStreamWasForked event, - // to read the version of the source content stream when the fork occurred - // - ensure that no other changes have been done in the meantime in the base content stream - - $workspaceContentStream = iterator_to_array($this->eventStore->load( - ContentStreamEventStreamName::fromContentStreamId($contentStreamId)->getEventStreamName() - )); - /** @var array $workspaceContentStream */ + ): \Generator { + yield $this->forkContentStream( + $newContentStreamId, + $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies + ); + + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasRebased( + $workspace->workspaceName, + $newContentStreamId, + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::ANY() + ); + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); + } + + /** + * Copy all events from the passed event stream which implement the {@see PublishableToOtherContentStreamsInterface} + */ + private function getCopiedEventsOfEventStream( + WorkspaceName $targetWorkspaceName, + ContentStreamId $targetContentStreamId, + EventStreamInterface $eventStream + ): Events { $events = []; - $contentStreamWasForkedEvent = null; - foreach ($workspaceContentStream as $eventEnvelope) { + foreach ($eventStream as $eventEnvelope) { $event = $this->eventNormalizer->denormalize($eventEnvelope->event); - if ($event instanceof ContentStreamWasForked) { - if ($contentStreamWasForkedEvent !== null) { - throw new \RuntimeException( - 'Invariant violation: The content stream "' . $contentStreamId->value - . '" has two forked events.', - 1658740373 - ); - } - $contentStreamWasForkedEvent = $event; - } elseif ($event instanceof PublishableToWorkspaceInterface) { - /** @var EventInterface $copiedEvent */ - $copiedEvent = $event->withWorkspaceNameAndContentStreamId($baseWorkspaceName, $baseContentStreamId); + if ($event instanceof PublishableToWorkspaceInterface) { + $copiedEvent = $event->withWorkspaceNameAndContentStreamId($targetWorkspaceName, $targetContentStreamId); // We need to add the event metadata here for rebasing in nested workspace situations // (and for exporting) $events[] = DecoratedEvent::create($copiedEvent, metadata: $eventEnvelope->event->metadata, causationId: $eventEnvelope->event->causationId, correlationId: $eventEnvelope->event->correlationId); } } - if ($contentStreamWasForkedEvent === null) { - throw new \RuntimeException('Invariant violation: The content stream "' . $contentStreamId->value - . '" has NO forked event.', 1658740407); - } - - if (count($events) === 0) { - return; - } - try { - $this->eventPersister->publishEvents( - $commandHandlingDependencies->getContentRepository(), - new EventsToPublish( - $baseWorkspaceContentStreamName->getEventStreamName(), - Events::fromArray($events), - ExpectedVersion::fromVersion($contentStreamWasForkedEvent->versionOfSourceContentStream) - ) - ); - } catch (ConcurrencyException $e) { - throw new BaseWorkspaceHasBeenModifiedInTheMeantime(sprintf( - 'The base workspace has been modified in the meantime; please rebase.' - . ' Expected version %d of source content stream %s', - $contentStreamWasForkedEvent->versionOfSourceContentStream->value, - $baseContentStreamId->value - )); - } + return Events::fromArray($events); } /** @@ -316,131 +333,97 @@ private function publishContentStream( private function handleRebaseWorkspace( RebaseWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $workspace->currentContentStreamId; - if (!$commandHandlingDependencies->contentStreamExists($oldWorkspaceContentStreamId)) { - throw new \DomainException('Cannot rebase a workspace with a stateless content stream', 1711718314); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot rebase a workspace with a stateless content stream', 1711718314); } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($oldWorkspaceContentStreamId); - // 0) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($oldWorkspaceContentStreamId) + if ( + $workspace->status === WorkspaceStatus::UP_TO_DATE + && $command->rebaseErrorHandlingStrategy !== RebaseErrorHandlingStrategy::STRATEGY_FORCE + ) { + // no-op if workspace is not outdated and not forcing it + return; + } + + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - // 1) fork a new content stream - $rebasedContentStreamId = $command->rebasedContentStreamId; - $commandHandlingDependencies->handle( - ForkContentStream::create( + if (!$workspace->hasPublishableChanges()) { + // if we have no changes in the workspace we can fork from the base directly + yield from $this->rebaseWorkspaceWithoutChanges( + $workspace, + $baseWorkspace, $command->rebasedContentStreamId, - $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies + ); + return; + } + + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - $workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $workspace->currentContentStreamId - ); + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); - // 2) extract the commands from the to-be-rebased content stream; and applies them on the new content stream - $originalCommands = $this->extractCommandsFromContentStreamMetadata($workspaceContentStreamName); - $commandsThatFailed = new CommandsThatFailedDuringRebase(); - $commandHandlingDependencies->overrideContentStreamId( - $command->workspaceName, - $command->rebasedContentStreamId, - function () use ($originalCommands, $commandHandlingDependencies, &$commandsThatFailed): void { - foreach ($originalCommands as $sequenceNumber => $originalCommand) { - // We no longer need to adjust commands as the workspace stays the same - try { - $commandHandlingDependencies->handle($originalCommand); - // if we came this far, we know the command was applied successfully. - } catch (\Exception $e) { - $commandsThatFailed = $commandsThatFailed->add( - new CommandThatFailedDuringRebase( - $sequenceNumber, - $originalCommand, - $e - ) - ); - } + $commandSimulator->run( + static function ($handle) use ($rebaseableCommands): void { + foreach ($rebaseableCommands as $rebaseableCommand) { + $handle($rebaseableCommand); } } ); - // 3) if we got so far without an exception (or if we don't care), we can switch the workspace's active content stream. - if ($command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FORCE || $commandsThatFailed->isEmpty()) { - $events = Events::with( - new WorkspaceWasRebased( - $command->workspaceName, - $rebasedContentStreamId, - $workspace->currentContentStreamId, - ), + if ( + $command->rebaseErrorHandlingStrategy === RebaseErrorHandlingStrategy::STRATEGY_FAIL + && $commandSimulator->hasCommandsThatFailed() + ) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - return new EventsToPublish( - $workspaceStreamName, - $events, - ExpectedVersion::ANY() - ); + // throw an exception that contains all the information about what exactly failed + throw WorkspaceRebaseFailed::duringRebase($commandSimulator->getCommandsThatFailed()); } - // 3.E) In case of an exception, reopen the old content stream... - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) + // if we got so far without an exception (or if we don't care), we can switch the workspace's active content stream. + yield from $this->forkNewContentStreamAndApplyEvents( + $command->rebasedContentStreamId, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasRebased( + $command->workspaceName, + $command->rebasedContentStreamId, + $workspace->currentContentStreamId, + ), + ), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->rebasedContentStreamId, + $commandSimulator->eventStream(), + ), + $commandHandlingDependencies ); - // ... remove the newly created one... - $commandHandlingDependencies->handle(RemoveContentStream::create( - $rebasedContentStreamId - )); - - // ...and throw an exception that contains all the information about what exactly failed - throw new WorkspaceRebaseFailed($commandsThatFailed, 'Rebase failed', 1711713880); - } - - /** - * @return array - */ - private function extractCommandsFromContentStreamMetadata( - ContentStreamEventStreamName $workspaceContentStreamName, - ): array { - $workspaceContentStream = $this->eventStore->load($workspaceContentStreamName->getEventStreamName()); - - $commands = []; - foreach ($workspaceContentStream as $eventEnvelope) { - $metadata = $eventEnvelope->event->metadata?->value ?? []; - // TODO: Add this logic to the NodeAggregateCommandHandler; - // so that we can be sure these can be parsed again. - if (isset($metadata['commandClass'])) { - $commandToRebaseClass = $metadata['commandClass']; - $commandToRebasePayload = $metadata['commandPayload']; - if (!method_exists($commandToRebaseClass, 'fromArray')) { - throw new \RuntimeException(sprintf( - 'Command "%s" can\'t be rebased because it does not implement a static "fromArray" constructor', - $commandToRebaseClass - ), 1547815341); - } - /** - * The "fromArray" might be declared via {@see RebasableToOtherWorkspaceInterface::fromArray()} - * or any other command can just implement it. - */ - $commands[$eventEnvelope->sequenceNumber->value] = $commandToRebaseClass::fromArray($commandToRebasePayload); - } - } - - return $commands; + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** * This method is like a combined Rebase and Publish! * * @throws BaseWorkspaceDoesNotExist - * @throws BaseWorkspaceHasBeenModifiedInTheMeantime * @throws ContentStreamAlreadyExists * @throws ContentStreamDoesNotExistYet * @throws WorkspaceDoesNotExist @@ -449,133 +432,126 @@ private function extractCommandsFromContentStreamMetadata( private function handlePublishIndividualNodesFromWorkspace( PublishIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { - $contentGraph = $commandHandlingDependencies->getContentGraph($command->workspaceName); + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $workspace->currentContentStreamId; - if (!$commandHandlingDependencies->contentStreamExists($oldWorkspaceContentStreamId)) { - throw new \DomainException('Cannot publish nodes on a workspace with a stateless content stream', 1710410114); - } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($oldWorkspaceContentStreamId); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + if ($command->nodesToPublish->isEmpty() || !$workspace->hasPublishableChanges()) { + // noop + return; + } - // 1) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($contentGraph->getContentStreamId()) - ); + // todo check that fetching workspace throws if there is no content stream id for it + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot publish nodes on a workspace with a stateless content stream', 1710410114); + } + $this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies); + $baseContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId); - // 2) separate commands in two parts - the ones MATCHING the nodes from the command, and the REST - /** @var RebasableToOtherWorkspaceInterface[] $matchingCommands */ - $matchingCommands = []; - $remainingCommands = []; - $this->separateMatchingAndRemainingCommands($command, $workspace, $matchingCommands, $remainingCommands); - /** @var array $matchingCommands */ - /** @var array $remainingCommands */ + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies + ); - // 3) fork a new contentStream, based on the base WS, and apply MATCHING - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->contentStreamIdForMatchingPart, - $baseWorkspace->currentContentStreamId, + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); - try { - // 4) using the new content stream, apply the matching commands - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, - $command->contentStreamIdForMatchingPart, - function () use ($matchingCommands, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($matchingCommands as $matchingCommand) { - if (!($matchingCommand instanceof RebasableToOtherWorkspaceInterface)) { - throw new \RuntimeException( - 'ERROR: The command ' . get_class($matchingCommand) - . ' does not implement ' . RebasableToOtherWorkspaceInterface::class . '; but it should!' - ); - } - - $commandHandlingDependencies->handle($matchingCommand->createCopyForWorkspace( - $baseWorkspace->workspaceName, - )); - } - } - ); + [$matchingCommands, $remainingCommands] = $rebaseableCommands->separateMatchingAndRemainingCommands($command->nodesToPublish); - // 5) take EVENTS(MATCHING) and apply them to base WS. - $this->publishContentStream( - $commandHandlingDependencies, - $command->contentStreamIdForMatchingPart, - $baseWorkspace->workspaceName, - $baseWorkspace->currentContentStreamId + if ($matchingCommands->isEmpty()) { + // almost a noop (e.g. random node ids were specified) ;) + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); + return; + } - // 6) fork a new content stream, based on the base WS, and apply REST - $commandHandlingDependencies->handle( - ForkContentStream::create( + if ($remainingCommands->isEmpty()) { + try { + // do a full publish, this is simpler for the projections to handle + yield from $this->publishWorkspace( + $workspace, + $baseWorkspace, $command->contentStreamIdForRemainingPart, - $baseWorkspace->currentContentStreamId - ) - ); + $baseContentStreamVersion, + $matchingCommands, + $commandHandlingDependencies + ); + return; + } catch (WorkspaceRebaseFailed $workspaceRebaseFailed) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies + ); + throw $workspaceRebaseFailed; + } + } + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); - // 7) apply REMAINING commands to the workspace's new content stream - $commandHandlingDependencies->overrideContentStreamId( - $command->workspaceName, - $command->contentStreamIdForRemainingPart, - function () use ($commandHandlingDependencies, $remainingCommands) { - foreach ($remainingCommands as $remainingCommand) { - $commandHandlingDependencies->handle($remainingCommand); - } + $highestSequenceNumberForMatching = $commandSimulator->run( + static function ($handle) use ($commandSimulator, $matchingCommands, $remainingCommands): SequenceNumber { + foreach ($matchingCommands as $matchingCommand) { + $handle($matchingCommand); } - ); - } catch (\Exception $exception) { - // 4.E) In case of an exception, reopen the old content stream and remove the newly created - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) - ); - - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForMatchingPart - )); - - try { - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForRemainingPart - )); - } catch (ContentStreamDoesNotExistYet $contentStreamDoesNotExistYet) { - // in case the exception was thrown before 6), this does not exist + $highestSequenceNumberForMatching = $commandSimulator->currentSequenceNumber(); + foreach ($remainingCommands as $remainingCommand) { + $handle($remainingCommand); + } + return $highestSequenceNumberForMatching; } + ); - throw $exception; - } - - // 8) to avoid dangling content streams, we need to remove our temporary content stream (whose events - // have already been published) as well as the old one - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->contentStreamIdForMatchingPart - )); - $commandHandlingDependencies->handle(RemoveContentStream::create( - $oldWorkspaceContentStreamId - )); + if ($commandSimulator->hasCommandsThatFailed()) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies + ); - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); + throw WorkspaceRebaseFailed::duringPublish($commandSimulator->getCommandsThatFailed()); + } - return new EventsToPublish( - $streamName, - Events::fromArray([ - new WorkspaceWasPartiallyPublished( - $command->workspaceName, - $baseWorkspace->workspaceName, - $command->contentStreamIdForRemainingPart, - $oldWorkspaceContentStreamId, - $command->nodesToPublish - ) - ]), - ExpectedVersion::ANY() + // this could be a no-op for the rare case when a command returns empty events e.g. the node was already tagged with this subtree tag, meaning we actually just rebase + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($baseWorkspace->currentContentStreamId) + ->getEventStreamName(), + $this->getCopiedEventsOfEventStream( + $baseWorkspace->workspaceName, + $baseWorkspace->currentContentStreamId, + $commandSimulator->eventStream()->withMaximumSequenceNumber($highestSequenceNumberForMatching), + ), + ExpectedVersion::fromVersion($baseContentStreamVersion) + ); + + yield from $this->forkNewContentStreamAndApplyEvents( + $command->contentStreamIdForRemainingPart, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::fromArray([ + new WorkspaceWasPartiallyPublished( + $command->workspaceName, + $baseWorkspace->workspaceName, + $command->contentStreamIdForRemainingPart, + $workspace->currentContentStreamId, + $command->nodesToPublish + ) + ]), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->contentStreamIdForRemainingPart, + $commandSimulator->eventStream()->withMinimumSequenceNumber($highestSequenceNumberForMatching->next()) + ), + $commandHandlingDependencies ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -591,141 +567,95 @@ function () use ($commandHandlingDependencies, $remainingCommands) { private function handleDiscardIndividualNodesFromWorkspace( DiscardIndividualNodesFromWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { - $contentGraph = $commandHandlingDependencies->getContentGraph($command->workspaceName); + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $oldWorkspaceContentStreamId = $contentGraph->getContentStreamId(); - if (!$commandHandlingDependencies->contentStreamExists($contentGraph->getContentStreamId())) { - throw new \DomainException('Cannot discard nodes on a workspace with a stateless content stream', 1710408112); - } - $oldWorkspaceContentStreamIdState = $commandHandlingDependencies->getContentStreamStatus($contentGraph->getContentStreamId()); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - // 1) close old content stream - $commandHandlingDependencies->handle( - CloseContentStream::create($oldWorkspaceContentStreamId) - ); + if ($command->nodesToDiscard->isEmpty() || !$workspace->hasPublishableChanges()) { + // noop + return; + } - // 2) filter commands, only keeping the ones NOT MATCHING the nodes from the command - // (i.e. the modifications we want to keep) - /** @var array $commandsToDiscard */ - $commandsToDiscard = []; - /** @var array $commandsToKeep */ - $commandsToKeep = []; - $this->separateMatchingAndRemainingCommands($command, $workspace, $commandsToDiscard, $commandsToKeep); + if (!$commandHandlingDependencies->contentStreamExists($workspace->currentContentStreamId)) { + throw new \RuntimeException('Cannot discard nodes on a workspace with a stateless content stream', 1710408112); + } - // 3) fork a new contentStream, based on the base WS, and apply the commands to keep - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->newContentStreamId, - $baseWorkspace->currentContentStreamId, + yield $this->closeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies + ); + + // filter commands, only keeping the ones NOT MATCHING the nodes from the command (i.e. the modifications we want to keep) + $rebaseableCommands = RebaseableCommands::extractFromEventStream( + $this->eventStore->load( + ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId) + ->getEventStreamName() ) ); + [$commandsToDiscard, $commandsToKeep] = $rebaseableCommands->separateMatchingAndRemainingCommands($command->nodesToDiscard); - // 4) using the new content stream, apply the commands to keep - try { - $commandHandlingDependencies->overrideContentStreamId( - $baseWorkspace->workspaceName, - $command->newContentStreamId, - function () use ($commandsToKeep, $commandHandlingDependencies, $baseWorkspace): void { - foreach ($commandsToKeep 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, - )); - } - } - ); - } catch (\Exception $exception) { - // 4.E) In case of an exception, reopen the old content stream and remove the newly created - $commandHandlingDependencies->handle( - ReopenContentStream::create( - $oldWorkspaceContentStreamId, - $oldWorkspaceContentStreamIdState, - ) + if ($commandsToDiscard->isEmpty()) { + // if we have nothing to discard, we can just keep all. (e.g. random node ids were specified) It's almost a noop ;) + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - - $commandHandlingDependencies->handle(RemoveContentStream::create( - $command->newContentStreamId - )); - - throw $exception; + return; } - // 5) If everything worked, to avoid dangling content streams, we need to remove the old content stream - $commandHandlingDependencies->handle(RemoveContentStream::create( - $oldWorkspaceContentStreamId - )); - - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - - return new EventsToPublish( - $streamName, - Events::with( - new WorkspaceWasPartiallyDiscarded( - $command->workspaceName, - $command->newContentStreamId, - $workspace->currentContentStreamId, - $command->nodesToDiscard, - ) - ), - ExpectedVersion::ANY() - ); - } + if ($commandsToKeep->isEmpty()) { + // quick path everything was discarded + yield from $this->discardWorkspace( + $workspace, + $baseWorkspace, + $command->newContentStreamId, + $commandHandlingDependencies + ); + return; + } + $commandSimulator = $this->commandSimulatorFactory->createSimulatorForWorkspace($baseWorkspace->workspaceName); - /** - * @param array &$matchingCommands - * @param array &$remainingCommands - */ - private function separateMatchingAndRemainingCommands( - PublishIndividualNodesFromWorkspace|DiscardIndividualNodesFromWorkspace $command, - Workspace $workspace, - array &$matchingCommands, - array &$remainingCommands - ): void { - $workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $workspace->currentContentStreamId + $commandSimulator->run( + static function ($handle) use ($commandsToKeep): void { + foreach ($commandsToKeep as $matchingCommand) { + $handle($matchingCommand); + } + } ); - $originalCommands = $this->extractCommandsFromContentStreamMetadata($workspaceContentStreamName); - - foreach ($originalCommands as $originalCommand) { - if (!$originalCommand instanceof MatchableWithNodeIdToPublishOrDiscardInterface) { - throw new \Exception( - 'Command class ' . get_class($originalCommand) . ' does not implement ' - . MatchableWithNodeIdToPublishOrDiscardInterface::class, - 1645393655 - ); - } - $nodeIds = $command instanceof PublishIndividualNodesFromWorkspace - ? $command->nodesToPublish - : $command->nodesToDiscard; - if ($this->commandMatchesAtLeastOneNode($originalCommand, $nodeIds)) { - $matchingCommands[] = $originalCommand; - } else { - $remainingCommands[] = $originalCommand; - } + if ($commandSimulator->hasCommandsThatFailed()) { + yield $this->reopenContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies + ); + throw WorkspaceRebaseFailed::duringDiscard($commandSimulator->getCommandsThatFailed()); } - } - private function commandMatchesAtLeastOneNode( - MatchableWithNodeIdToPublishOrDiscardInterface $command, - NodeIdsToPublishOrDiscard $nodeIds, - ): bool { - foreach ($nodeIds as $nodeId) { - if ($command->matchesNodeId($nodeId)) { - return true; - } - } + yield from $this->forkNewContentStreamAndApplyEvents( + $command->newContentStreamId, + $baseWorkspace->currentContentStreamId, + new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasPartiallyDiscarded( + $command->workspaceName, + $command->newContentStreamId, + $workspace->currentContentStreamId, + $command->nodesToDiscard, + ) + ), + ExpectedVersion::ANY() + ), + $this->getCopiedEventsOfEventStream( + $command->workspaceName, + $command->newContentStreamId, + $commandSimulator->eventStream(), + ), + $commandHandlingDependencies + ); - return false; + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -736,33 +666,54 @@ private function commandMatchesAtLeastOneNode( private function handleDiscardWorkspace( DiscardWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $newContentStream = $command->newContentStreamId; - $commandHandlingDependencies->handle( - ForkContentStream::create( - $newContentStream, - $baseWorkspace->currentContentStreamId, - ) - ); + if (!$workspace->hasPublishableChanges()) { + return; + } - // if we got so far without an Exception, we can switch the Workspace's active Content stream. - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - $events = Events::with( - new WorkspaceWasDiscarded( - $command->workspaceName, - $newContentStream, - $workspace->currentContentStreamId, - ) + yield from $this->discardWorkspace( + $workspace, + $baseWorkspace, + $command->newContentStreamId, + $commandHandlingDependencies ); + } - return new EventsToPublish( - $streamName, - $events, + /** + * @param Workspace $workspace + * @param Workspace $baseWorkspace + * @param ContentStreamId $newContentStream + * @param CommandHandlingDependencies $commandHandlingDependencies + * @phpstan-pure this method is pure, to persist the events they must be handled outside + */ + private function discardWorkspace( + Workspace $workspace, + Workspace $baseWorkspace, + ContentStreamId $newContentStream, + CommandHandlingDependencies $commandHandlingDependencies + ): \Generator { + yield $this->forkContentStream( + $newContentStream, + $baseWorkspace->currentContentStreamId, + $commandHandlingDependencies + ); + + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasDiscarded( + $workspace->workspaceName, + $newContentStream, + $workspace->currentContentStreamId, + ) + ), ExpectedVersion::ANY() ); + + yield $this->removeContentStream($workspace->currentContentStreamId, $commandHandlingDependencies); } /** @@ -776,33 +727,35 @@ private function handleDiscardWorkspace( private function handleChangeBaseWorkspace( ChangeBaseWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $this->requireEmptyWorkspace($workspace); - $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); + $currentBaseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies); - $this->requireNonCircularRelationBetweenWorkspaces($workspace, $baseWorkspace, $commandHandlingDependencies); + if ($currentBaseWorkspace->workspaceName->equals($command->baseWorkspaceName)) { + // no-op + return; + } - $commandHandlingDependencies->handle( - ForkContentStream::create( - $command->newContentStreamId, - $baseWorkspace->currentContentStreamId, - ) - ); + $this->requireEmptyWorkspace($workspace); + $newBaseWorkspace = $this->requireWorkspace($command->baseWorkspaceName, $commandHandlingDependencies); - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - $events = Events::with( - new WorkspaceBaseWorkspaceWasChanged( - $command->workspaceName, - $command->baseWorkspaceName, - $command->newContentStreamId, - ) + $this->requireNonCircularRelationBetweenWorkspaces($workspace, $newBaseWorkspace, $commandHandlingDependencies); + + yield $this->forkContentStream( + $command->newContentStreamId, + $newBaseWorkspace->currentContentStreamId, + $commandHandlingDependencies ); - return new EventsToPublish( - $streamName, - $events, + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceBaseWorkspaceWasChanged( + $command->workspaceName, + $command->baseWorkspaceName, + $command->newContentStreamId, + ) + ), ExpectedVersion::ANY() ); } @@ -813,35 +766,61 @@ private function handleChangeBaseWorkspace( private function handleDeleteWorkspace( DeleteWorkspace $command, CommandHandlingDependencies $commandHandlingDependencies, - ): EventsToPublish { + ): \Generator { $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies); - $commandHandlingDependencies->handle( - RemoveContentStream::create( - $workspace->currentContentStreamId - ) + yield $this->removeContentStream( + $workspace->currentContentStreamId, + $commandHandlingDependencies ); - $events = Events::with( - new WorkspaceWasRemoved( - $command->workspaceName, - ) + yield new EventsToPublish( + WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(), + Events::with( + new WorkspaceWasRemoved( + $command->workspaceName, + ) + ), + ExpectedVersion::ANY() ); + } - $streamName = WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(); - return new EventsToPublish( - $streamName, - $events, - ExpectedVersion::ANY() + private function forkNewContentStreamAndApplyEvents( + ContentStreamId $newContentStreamId, + ContentStreamId $sourceContentStreamId, + EventsToPublish $pointWorkspaceToNewContentStream, + Events $eventsToApplyOnNewContentStream, + CommandHandlingDependencies $commandHandlingDependencies, + ): \Generator { + yield $this->forkContentStream( + $newContentStreamId, + $sourceContentStreamId, + $commandHandlingDependencies + )->withAppendedEvents(Events::with( + new ContentStreamWasClosed( + $newContentStreamId + ) + )); + + yield $pointWorkspaceToNewContentStream; + + yield new EventsToPublish( + ContentStreamEventStreamName::fromContentStreamId($newContentStreamId) + ->getEventStreamName(), + $eventsToApplyOnNewContentStream->withAppendedEvents( + Events::with( + new ContentStreamWasReopened( + $newContentStreamId + ) + ) + ), + ExpectedVersion::fromVersion(Version::first()->next()) ); } private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void { - try { - $commandHandlingDependencies->getContentGraph($workspaceName); - } catch (WorkspaceDoesNotExist) { - // Desired outcome + if ($commandHandlingDependencies->findWorkspaceByName($workspaceName) === null) { return; } @@ -903,32 +882,8 @@ private function requireNonCircularRelationBetweenWorkspaces(Workspace $workspac */ private function requireEmptyWorkspace(Workspace $workspace): void { - $workspaceContentStreamName = ContentStreamEventStreamName::fromContentStreamId( - $workspace->currentContentStreamId - ); - if ($this->hasEventsInContentStreamExceptForking($workspaceContentStreamName)) { + if ($workspace->hasPublishableChanges()) { 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; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceEventStreamName.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceEventStreamName.php index 8d873a2a96a..1a14bf70895 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceEventStreamName.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceEventStreamName.php @@ -14,7 +14,7 @@ */ final readonly class WorkspaceEventStreamName { - private const EVENT_STREAM_NAME_PREFIX = 'Workspace:'; + public const EVENT_STREAM_NAME_PREFIX = 'Workspace:'; private function __construct( public string $eventStreamName, diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php index b0215dc2c7a..dd833312b5f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeBaseWorkspace.php @@ -35,4 +35,15 @@ public static function create(WorkspaceName $workspaceName, WorkspaceName $baseW { return new self($workspaceName, $baseWorkspaceName, ContentStreamId::create()); } + + /** + * During the publish process, we create a new content stream. + * + * This method adds its ID, so that the command + * can run fully deterministic - we need this for the test cases. + */ + public function withNewContentStreamId(ContentStreamId $newContentStreamId): self + { + return new self($this->workspaceName, $this->baseWorkspaceName, $newContentStreamId); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php index 8771ec296d8..5a5b0355945 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/DiscardWorkspace.php @@ -27,7 +27,7 @@ { /** * @param WorkspaceName $workspaceName Name of the affected workspace - * @param ContentStreamId $newContentStreamId The id of the newly created content stream that will contain the remaining changes that were not discarded + * @param ContentStreamId $newContentStreamId The id of the newly forked content stream with no changes */ private function __construct( public WorkspaceName $workspaceName, diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php index 452eeeeec54..2394c80ab99 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Command/PublishIndividualNodesFromWorkspace.php @@ -29,13 +29,11 @@ /** * @param WorkspaceName $workspaceName Name of the affected workspace * @param NodeIdsToPublishOrDiscard $nodesToPublish Ids of the nodes to publish or discard - * @param ContentStreamId $contentStreamIdForMatchingPart The id of the new content stream that will contain all events to be published {@see self::withContentStreamIdForMatchingPart()} * @param ContentStreamId $contentStreamIdForRemainingPart The id of the new content stream that will contain all remaining events {@see self::withContentStreamIdForRemainingPart()} */ private function __construct( public WorkspaceName $workspaceName, public NodeIdsToPublishOrDiscard $nodesToPublish, - public ContentStreamId $contentStreamIdForMatchingPart, public ContentStreamId $contentStreamIdForRemainingPart ) { } @@ -49,34 +47,18 @@ public static function create(WorkspaceName $workspaceName, NodeIdsToPublishOrDi return new self( $workspaceName, $nodesToPublish, - ContentStreamId::create(), ContentStreamId::create() ); } /** - * During the publish process, we sort the events so that the events we want to publish - * come first. In this process, two new content streams are generated: - * - the first one contains all events which we want to publish - * - the second one is based on the first one, and contains all the remaining events (which we want to keep - * in the user workspace). + * The id of the new content stream that will contain all remaining events * - * This method adds the ID of the first content stream, so that the command - * can run fully deterministic - we need this for the test cases. - */ - public function withContentStreamIdForMatchingPart(ContentStreamId $contentStreamIdForMatchingPart): self - { - return new self($this->workspaceName, $this->nodesToPublish, $contentStreamIdForMatchingPart, $this->contentStreamIdForRemainingPart); - } - - /** - * See the description of {@see self::withContentStreamIdForMatchingPart()}. - * - * This property adds the ID of the second content stream, so that the command + * This method adds its ID, so that the command * can run fully deterministic - we need this for the test cases. */ public function withContentStreamIdForRemainingPart(ContentStreamId $contentStreamIdForRemainingPart): self { - return new self($this->workspaceName, $this->nodesToPublish, $this->contentStreamIdForMatchingPart, $contentStreamIdForRemainingPart); + return new self($this->workspaceName, $this->nodesToPublish, $contentStreamIdForRemainingPart); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/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/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php index db373f0151a..53aa7a73363 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Dto/NodeIdsToPublishOrDiscard.php @@ -56,6 +56,11 @@ public function getIterator(): \Traversable yield from $this->nodeIds; } + public function isEmpty(): bool + { + return $this->nodeIds === []; + } + public function count(): int { return count($this->nodeIds); diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php index 42e499af079..bcfd9627256 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandThatFailedDuringRebase.php @@ -15,21 +15,32 @@ namespace Neos\ContentRepository\Core\Feature\WorkspaceRebase; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\EventStore\Model\Event\SequenceNumber; /** - * @internal implementation detail of WorkspaceCommandHandler + * @api part of the exception exposed when rebasing failed */ final readonly class CommandThatFailedDuringRebase { /** - * @param int $sequenceNumber the event store sequence number of the event containing the command to be rebased * @param CommandInterface $command the command that failed * @param \Throwable $exception how the command failed + * @param SequenceNumber $sequenceNumber the event store sequence number of the event containing the command to be rebased */ public function __construct( - public int $sequenceNumber, public CommandInterface $command, - public \Throwable $exception + public \Throwable $exception, + private SequenceNumber $sequenceNumber, ) { } + + /** + * The event store sequence number of the event containing the command to be rebased + * + * @internal exposed for testing + */ + public function getSequenceNumber(): SequenceNumber + { + return $this->sequenceNumber; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php index b9f6e792b2d..24f1087ee81 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/CommandsThatFailedDuringRebase.php @@ -19,7 +19,7 @@ * * @api part of the exception exposed when rebasing failed */ -final readonly class CommandsThatFailedDuringRebase implements \IteratorAggregate +final readonly class CommandsThatFailedDuringRebase implements \IteratorAggregate, \Countable { /** * @var array @@ -31,12 +31,14 @@ public function __construct(CommandThatFailedDuringRebase ...$items) $this->items = array_values($items); } - public function add(CommandThatFailedDuringRebase $item): self + public function withAppended(CommandThatFailedDuringRebase $item): self { - $items = $this->items; - $items[] = $item; + return new self(...[...$this->items, $item]); + } - return new self(...$items); + public function first(): ?CommandThatFailedDuringRebase + { + return $this->items[0] ?? null; } public function isEmpty(): bool @@ -48,4 +50,9 @@ public function getIterator(): \Traversable { yield from $this->items; } + + public function count(): int + { + return count($this->items); + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php index 52b1bdd8d5d..b1bae671156 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceRebase/Exception/WorkspaceRebaseFailed.php @@ -21,12 +21,48 @@ */ final class WorkspaceRebaseFailed extends \Exception { - public function __construct( + private function __construct( public readonly CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase, - string $message = "", - int $code = 0, - ?\Throwable $previous = null + string $message, + int $code, + ?\Throwable $previous, ) { parent::__construct($message, $code, $previous); } + + public static function duringRebase(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Rebase failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974936, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + public static function duringPublish(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Publication failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974980, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + public static function duringDiscard(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): self + { + return new self( + $commandsThatFailedDuringRebase, + sprintf('Discard failed: %s', self::renderMessage($commandsThatFailedDuringRebase)), + 1729974982, + $commandsThatFailedDuringRebase->first()?->exception + ); + } + + private static function renderMessage(CommandsThatFailedDuringRebase $commandsThatFailedDuringRebase): string + { + $firstFailure = $commandsThatFailedDuringRebase->first(); + return sprintf('"%s" and %d further failures', $firstFailure?->exception->getMessage(), count($commandsThatFailedDuringRebase) - 1); + } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactories.php b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactories.php index deabf53477b..efa364124ba 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactories.php +++ b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactories.php @@ -4,18 +4,20 @@ namespace Neos\ContentRepository\Core\Projection; -use Neos\ContentRepository\Core\ContentRepository; - /** + * @implements CatchUpHookFactoryInterface * @internal */ final class CatchUpHookFactories implements CatchUpHookFactoryInterface { /** - * @var array + * @var array> */ private array $catchUpHookFactories; + /** + * @param CatchUpHookFactoryInterface ...$catchUpHookFactories + */ private function __construct(CatchUpHookFactoryInterface ...$catchUpHookFactories) { $this->catchUpHookFactories = $catchUpHookFactories; @@ -26,6 +28,10 @@ public static function create(): self return new self(); } + /** + * @param CatchUpHookFactoryInterface $catchUpHookFactory + * @return self + */ public function with(CatchUpHookFactoryInterface $catchUpHookFactory): self { if ($this->has($catchUpHookFactory::class)) { @@ -44,9 +50,9 @@ private function has(string $catchUpHookFactoryClassName): bool return array_key_exists($catchUpHookFactoryClassName, $this->catchUpHookFactories); } - public function build(ContentRepository $contentRepository): CatchUpHookInterface + public function build(CatchUpHookFactoryDependencies $dependencies): CatchUpHookInterface { - $catchUpHooks = array_map(static fn(CatchUpHookFactoryInterface $catchUpHookFactory) => $catchUpHookFactory->build($contentRepository), $this->catchUpHookFactories); + $catchUpHooks = array_map(static fn(CatchUpHookFactoryInterface $catchUpHookFactory) => $catchUpHookFactory->build($dependencies), $this->catchUpHookFactories); return new DelegatingCatchUpHook(...$catchUpHooks); } } diff --git a/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php new file mode 100644 index 00000000000..037e4164150 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Projection/CatchUpHookFactoryDependencies.php @@ -0,0 +1,41 @@ + $dependencies available dependencies to intialise the catchup hook + * @return CatchUpHookInterface + */ + public function build(CatchUpHookFactoryDependencies $dependencies): CatchUpHookInterface; } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php index fca250fd797..3830c431cc3 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphProjectionInterface.php @@ -13,4 +13,24 @@ interface ContentGraphProjectionInterface extends ProjectionInterface { public function getState(): ContentGraphReadModelInterface; + + /** + * Dedicated method for simulated rebasing + * + * The implementation must ensure that the function passed is invoked + * and that any changes via {@see ContentGraphProjectionInterface::apply()} + * are executed "in simulation" e.g. NOT persisted after returning. + * + * The projection state {@see ContentGraphReadModelInterface} must reflect the + * current changes of the simulation as well during the execution of the function. + * + * This is generally done by leveraging a transaction and rollback. + * + * Used to simulate commands for publishing: {@see \Neos\ContentRepository\Core\CommandHandler\CommandSimulator} + * + * @template T + * @param \Closure(): T $fn + * @return T the return value of $fn + */ + public function inSimulation(\Closure $fn): mixed; } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php index 5902b8aba00..5f157f8d708 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphReadModelInterface.php @@ -15,6 +15,7 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; @@ -23,11 +24,19 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; /** - * @api for creating a custom content repository graph projection implementation, **not for users of the CR** + * This low level interface gives access to the content graph and workspaces + * + * Generally this is not accessible for users of the CR, except for registering a catchup-hook on the content graph + * + * @api as dependency in catchup hooks and for creating a custom content repository graph projection implementation */ interface ContentGraphReadModelInterface extends ProjectionStateInterface { - public function buildContentGraph(WorkspaceName $workspaceName, ContentStreamId $contentStreamId): ContentGraphInterface; + /** + * @throws WorkspaceDoesNotExist if the workspace does not exist + * todo cache instances to reduce queries (revert https://github.com/neos/neos-development-collection/pull/5246) + */ + public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface; public function findWorkspaceByName(WorkspaceName $workspaceName): ?Workspace; diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php index c949bac0f57..934f0c62d18 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeAggregate.php @@ -38,7 +38,7 @@ * The system guarantees the following invariants: * * - Inside a NodeAggregate, each DimensionSpacePoint has at most one Node which covers it. - * To check this, the ReadableNodeAggregateInterface is used (mainly in constraint checks). + * To check this, this class is used (mainly in constraint checks). * - The NodeType is always the same for all Nodes in a NodeAggregate * - all Nodes inside the NodeAggregate always have the same NodeName. * - all nodes inside a NodeAggregate are all of the same *classification*, which can be: @@ -46,9 +46,6 @@ * - *tethered*: for nodes "attached" to the parent node (i.e. the old "AutoCreatedChildNodes") * - *regular*: for all other nodes. * - * This interface is called *Readable* because it exposes read operations on the set of nodes inside - * a single NodeAggregate; often used for constraint checks (in command handlers). - * * @api Note: The constructor is not part of the public API */ final readonly class NodeAggregate @@ -77,7 +74,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/Projection/ProjectionsAndCatchUpHooks.php b/Neos.ContentRepository.Core/Classes/Projection/ProjectionsAndCatchUpHooks.php index 255e59b7600..d7b674babdb 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ProjectionsAndCatchUpHooks.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ProjectionsAndCatchUpHooks.php @@ -26,6 +26,7 @@ public function __construct( /** * @param ProjectionInterface $projection + * @return ?CatchUpHookFactoryInterface */ public function getCatchUpHookFactoryForProjection(ProjectionInterface $projection): ?CatchUpHookFactoryInterface { diff --git a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php index 15d7667ae5e..5afbcddc590 100644 --- a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php +++ b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner.php @@ -5,17 +5,34 @@ namespace Neos\ContentRepository\Core\Service; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; +use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Event\ContentStreamWasCreated; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; -use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Command\RemoveContentStream; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; +use Neos\ContentRepository\Core\Feature\ContentStreamForking\Event\ContentStreamWasForked; +use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated; +use Neos\ContentRepository\Core\Feature\WorkspaceEventStreamName; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasDiscarded; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyDiscarded; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyPublished; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPublished; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceRebaseFailed; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased; +use Neos\ContentRepository\Core\Service\ContentStreamPruner\ContentStreamForPruning; +use Neos\ContentRepository\Core\Service\ContentStreamPruner\ContentStreamStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\EventStore\EventStoreInterface; +use Neos\EventStore\Model\Event\EventType; +use Neos\EventStore\Model\Event\EventTypes; +use Neos\EventStore\Model\Event\StreamName; +use Neos\EventStore\Model\EventStream\EventStreamFilter; +use Neos\EventStore\Model\EventStream\ExpectedVersion; +use Neos\EventStore\Model\EventStream\VirtualStreamName; /** - * For implementation details of the content stream states and removed state, see {@see ContentStream}. + * For implementation details of the content stream states and removed state, see {@see ContentStreamForPruning}. * * @api */ @@ -24,83 +41,194 @@ class ContentStreamPruner implements ContentRepositoryServiceInterface public function __construct( private readonly ContentRepository $contentRepository, private readonly EventStoreInterface $eventStore, + private readonly EventNormalizer $eventNormalizer ) { } /** - * Remove all content streams which are not needed anymore from the projections. + * Detects if dangling content streams exists and which content streams could be pruned from the event stream * - * NOTE: This still **keeps** the event stream as is; so it would be possible to re-construct the content stream - * at a later point in time (though we currently do not provide any API for it). + * Dangling content streams + * ------------------------ * - * To remove the deleted Content Streams, - * call {@see ContentStreamPruner::pruneRemovedFromEventStream()} afterwards. + * Content streams that are not removed via the event ContentStreamWasRemoved and are not in use by a workspace + * (not a current's workspace content stream). * - * By default, only content streams in STATE_NO_LONGER_IN_USE and STATE_REBASE_ERROR will be removed. - * If you also call with $removeTemporary=true, will delete ALL content streams which are currently not assigned - * to a workspace (f.e. dangling ones in FORKED or CREATED.). + * Previously before Neos 9 beta 15 (#5301), dangling content streams were not removed during publishing, discard or rebase. * - * @param bool $removeTemporary if TRUE, will delete ALL content streams not bound to a workspace - * @return iterable the identifiers of the removed content streams + * {@see removeDanglingContentStreams} + * + * Pruneable content streams + * ------------------------- + * + * Content streams that were removed ContentStreamWasRemoved e.g. after publishing, and are not required for a full + * replay to reconstruct the current projections state. The ability to reconstitute a previous state will be lost. + * + * {@see pruneRemovedFromEventStream} + * + * @return bool false if dangling content streams exist because they should not */ - public function prune(bool $removeTemporary = false): iterable + public function outputStatus(\Closure $outputFn): bool { - $status = [ContentStreamStatus::NO_LONGER_IN_USE, ContentStreamStatus::REBASE_ERROR]; - if ($removeTemporary) { - $status[] = ContentStreamStatus::CREATED; - $status[] = ContentStreamStatus::FORKED; + $allContentStreams = $this->findAllContentStreams(); + + $danglingContentStreamPresent = false; + foreach ($allContentStreams as $contentStream) { + if (!$contentStream->isDangling()) { + continue; + } + if ($danglingContentStreamPresent === false) { + $outputFn(sprintf('Dangling content streams that are not removed (ContentStreamWasRemoved) and not %s:', ContentStreamStatus::IN_USE_BY_WORKSPACE->value)); + } + + if ($contentStream->status->isTemporary()) { + $outputFn(sprintf(' id: %s temporary %s at %s', $contentStream->id->value, $contentStream->status->value, $contentStream->created->format('Y-m-d H:i'))); + } else { + $outputFn(sprintf(' id: %s %s', $contentStream->id->value, $contentStream->status->value)); + } + + $danglingContentStreamPresent = true; } - $unusedContentStreams = $this->contentRepository->findContentStreams()->filter( - static fn (ContentStream $contentStream) => in_array($contentStream->status, $status, true), - ); - $unusedContentStreamIds = []; - foreach ($unusedContentStreams as $contentStream) { - $this->contentRepository->handle( - RemoveContentStream::create($contentStream->id) - ); - $unusedContentStreamIds[] = $contentStream->id; + + if ($danglingContentStreamPresent === true) { + $outputFn('To remove the dangling streams from the projections please run ./flow contentStream:removeDangling'); + $outputFn('Then they are ready for removal from the event stream'); + $outputFn(); + } else { + $outputFn('Okay. No dangling streams found'); + $outputFn(); + } + + $pruneableContentStreams = $this->findRemovedContentStreamsThatAreUnused($allContentStreams); + + $pruneableContentStreamPresent = false; + foreach ($pruneableContentStreams as $pruneableContentStream) { + if ($pruneableContentStreamPresent === false) { + $outputFn('Removed content streams that can be pruned from the event stream'); + } + $pruneableContentStreamPresent = true; + $outputFn(sprintf(' id: %s previous state: %s', $pruneableContentStream->id->value, $pruneableContentStream->status->value)); } - return $unusedContentStreamIds; + if ($pruneableContentStreamPresent === true) { + $outputFn('To prune the removed streams from the event stream run ./flow contentStream:pruneRemovedFromEventstream'); + } else { + $outputFn('Okay. No pruneable streams in the event stream'); + } + + return !$danglingContentStreamPresent; } /** - * Remove unused and deleted content streams from the event stream; effectively REMOVING information completely. + * Removes all nodes, hierarchy relations and content stream entries which are not needed anymore from the projections. * - * This is not so easy for nested workspaces / content streams: - * - As long as content streams are used as basis for others which are IN_USE_BY_WORKSPACE, - * these dependent Content Streams are not allowed to be removed in the event store. + * NOTE: This still **keeps** the event stream as is; so it would be possible to re-construct the content stream at a later point in time. * - * - Otherwise, we cannot replay the other content streams correctly (if the base content streams are missing). + * To prune the removed content streams from the event stream, call {@see ContentStreamPruner::pruneRemovedFromEventStream()} afterwards. * - * @return ContentStreams the removed content streams + * @param \DateTimeImmutable $removeTemporaryBefore includes all temporary content streams like FORKED or CREATED older than that in the removal */ - public function pruneRemovedFromEventStream(): ContentStreams + public function removeDanglingContentStreams(\Closure $outputFn, \DateTimeImmutable $removeTemporaryBefore): void { - $removedContentStreams = $this->findUnusedAndRemovedContentStreams(); - foreach ($removedContentStreams as $removedContentStream) { - $streamName = ContentStreamEventStreamName::fromContentStreamId($removedContentStream->id) - ->getEventStreamName(); - $this->eventStore->deleteStream($streamName); + $allContentStreams = $this->findAllContentStreams(); + + $danglingContentStreamsPresent = false; + foreach ($allContentStreams as $contentStream) { + if (!$contentStream->isDangling()) { + continue; + } + if ( + $contentStream->status->isTemporary() + && $removeTemporaryBefore < $contentStream->created + ) { + $outputFn(sprintf('Did not remove %s temporary %s at %s', $contentStream->id->value, $contentStream->status->value, $contentStream->created->format('Y-m-d H:i'))); + continue; + } + + $this->eventStore->commit( + ContentStreamEventStreamName::fromContentStreamId($contentStream->id)->getEventStreamName(), + $this->eventNormalizer->normalize( + new ContentStreamWasRemoved( + $contentStream->id + ) + ), + ExpectedVersion::STREAM_EXISTS() + ); + + $outputFn(sprintf('Removed %s with status %s', $contentStream->id, $contentStream->status->value)); + + $danglingContentStreamsPresent = true; + } + + if ($danglingContentStreamsPresent) { + try { + $this->contentRepository->catchUpProjections(); + } catch (\Throwable $e) { + $outputFn(sprintf('Could not catchup after removing unused content streams: %s. You might need to use ./flow contentstream:pruneremovedfromeventstream and replay.', $e->getMessage())); + } + } else { + $outputFn('Okay. No pruneable streams in the event stream'); } - return $removedContentStreams; } - public function pruneAll(): void + /** + * Prune removed content streams that are unused from the event stream; effectively REMOVING information completely. + * + * Note that replaying to only a previous point in time would not be possible anymore as workspace would reference non-existing content streams. + * + * @see findRemovedContentStreamsThatAreUnused for implementation + */ + public function pruneRemovedFromEventStream(\Closure $outputFn): void { - foreach ($this->contentRepository->findContentStreams() as $contentStream) { - $streamName = ContentStreamEventStreamName::fromContentStreamId($contentStream->id)->getEventStreamName(); - $this->eventStore->deleteStream($streamName); + $allContentStreams = $this->findAllContentStreams(); + + $pruneableContentStreams = $this->findRemovedContentStreamsThatAreUnused($allContentStreams); + + $pruneableContentStreamsPresent = false; + foreach ($pruneableContentStreams as $pruneableContentStream) { + $this->eventStore->deleteStream( + ContentStreamEventStreamName::fromContentStreamId( + $pruneableContentStream->id + )->getEventStreamName() + ); + $pruneableContentStreamsPresent = true; + $outputFn(sprintf('Removed events for %s', $pruneableContentStream->id->value)); + } + + if ($pruneableContentStreamsPresent === false) { + $outputFn('Okay. There are no pruneable content streams.'); } } - private function findUnusedAndRemovedContentStreams(): ContentStreams + public function pruneAllWorkspacesAndContentStreamsFromEventStream(): void { - $allContentStreams = $this->contentRepository->findContentStreams(); + foreach ($this->findAllContentStreamStreamNames() as $contentStreamStreamName) { + $this->eventStore->deleteStream($contentStreamStreamName); + } + foreach ($this->findAllWorkspaceStreamNames() as $workspaceStreamName) { + $this->eventStore->deleteStream($workspaceStreamName); + } + } + /** + * Find all removed content streams that are unused in the event stream + * + * This is not so easy for nested workspaces / content streams: + * - As long as content streams are used as basis for others which are IN_USE_BY_WORKSPACE, + * these dependent Content Streams are not allowed to be removed in the event stream. + * - Otherwise, we cannot replay the other content streams correctly (if the base content streams are missing). + * + * @param array $allContentStreams + * @return list + */ + private function findRemovedContentStreamsThatAreUnused(array $allContentStreams): array + { /** @var array $transitiveUsedStreams */ $transitiveUsedStreams = []; - /** @var list $contentStreamIdsStack */ + /** + * Collection of content streams we iterate through to build up all streams that are in use transitively (by being a source content stream) or because it is in use + * @var list $contentStreamIdsStack + */ $contentStreamIdsStack = []; // Step 1: Find all content streams currently in direct use by a workspace @@ -129,13 +257,202 @@ private function findUnusedAndRemovedContentStreams(): ContentStreams } // Step 3: Check for removed content streams which we do not need anymore transitively - $removedContentStreams = []; + $removedContentStreamsThatAreUnused = []; foreach ($allContentStreams as $contentStream) { if ($contentStream->removed && !array_key_exists($contentStream->id->value, $transitiveUsedStreams)) { - $removedContentStreams[] = $contentStream; + $removedContentStreamsThatAreUnused[] = $contentStream; + } + } + + return $removedContentStreamsThatAreUnused; + } + + /** + * @return array + */ + private function findAllContentStreams(): array + { + $events = $this->eventStore->load( + VirtualStreamName::forCategory(ContentStreamEventStreamName::EVENT_STREAM_NAME_PREFIX), + EventStreamFilter::create( + EventTypes::create( + EventType::fromString('ContentStreamWasCreated'), + EventType::fromString('ContentStreamWasForked'), + EventType::fromString('ContentStreamWasRemoved'), + ) + ) + ); + + /** @var array $cs */ + $cs = []; + foreach ($events as $eventEnvelope) { + $domainEvent = $this->eventNormalizer->denormalize($eventEnvelope->event); + + switch ($domainEvent::class) { + case ContentStreamWasCreated::class: + $cs[$domainEvent->contentStreamId->value] = ContentStreamForPruning::create( + $domainEvent->contentStreamId, + ContentStreamStatus::CREATED, + null, + $eventEnvelope->recordedAt + ); + break; + case ContentStreamWasForked::class: + $cs[$domainEvent->newContentStreamId->value] = ContentStreamForPruning::create( + $domainEvent->newContentStreamId, + ContentStreamStatus::FORKED, + $domainEvent->sourceContentStreamId, + $eventEnvelope->recordedAt + ); + break; + case ContentStreamWasRemoved::class: + if (isset($cs[$domainEvent->contentStreamId->value])) { + $cs[$domainEvent->contentStreamId->value] = $cs[$domainEvent->contentStreamId->value] + ->withRemoved(); + } + break; + default: + throw new \RuntimeException(sprintf('Unhandled event %s', $eventEnvelope->event->type->value)); + } + } + + $workspaceEvents = $this->eventStore->load( + VirtualStreamName::forCategory(WorkspaceEventStreamName::EVENT_STREAM_NAME_PREFIX), + EventStreamFilter::create( + EventTypes::create( + EventType::fromString('RootWorkspaceWasCreated'), + EventType::fromString('WorkspaceWasCreated'), + EventType::fromString('WorkspaceWasDiscarded'), + EventType::fromString('WorkspaceWasPartiallyDiscarded'), + EventType::fromString('WorkspaceWasPartiallyPublished'), + EventType::fromString('WorkspaceWasPublished'), + EventType::fromString('WorkspaceWasRebased'), + EventType::fromString('WorkspaceRebaseFailed'), + // we don't need to track WorkspaceWasRemoved as a ContentStreamWasRemoved event would be emitted before + ) + ) + ); + foreach ($workspaceEvents as $eventEnvelope) { + $domainEvent = $this->eventNormalizer->denormalize($eventEnvelope->event); + + switch ($domainEvent::class) { + case RootWorkspaceWasCreated::class: + if (isset($cs[$domainEvent->newContentStreamId->value])) { + $cs[$domainEvent->newContentStreamId->value] = $cs[$domainEvent->newContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + break; + case WorkspaceWasCreated::class: + if (isset($cs[$domainEvent->newContentStreamId->value])) { + $cs[$domainEvent->newContentStreamId->value] = $cs[$domainEvent->newContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + break; + case WorkspaceWasDiscarded::class: + if (isset($cs[$domainEvent->newContentStreamId->value])) { + $cs[$domainEvent->newContentStreamId->value] = $cs[$domainEvent->newContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + if (isset($cs[$domainEvent->previousContentStreamId->value])) { + $cs[$domainEvent->previousContentStreamId->value] = $cs[$domainEvent->previousContentStreamId->value] + ->withStatus(ContentStreamStatus::NO_LONGER_IN_USE); + } + break; + case WorkspaceWasPartiallyDiscarded::class: + if (isset($cs[$domainEvent->newContentStreamId->value])) { + $cs[$domainEvent->newContentStreamId->value] = $cs[$domainEvent->newContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + if (isset($cs[$domainEvent->previousContentStreamId->value])) { + $cs[$domainEvent->previousContentStreamId->value] = $cs[$domainEvent->previousContentStreamId->value] + ->withStatus(ContentStreamStatus::NO_LONGER_IN_USE); + } + break; + case WorkspaceWasPartiallyPublished::class: + if (isset($cs[$domainEvent->newSourceContentStreamId->value])) { + $cs[$domainEvent->newSourceContentStreamId->value] = $cs[$domainEvent->newSourceContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + if (isset($cs[$domainEvent->previousSourceContentStreamId->value])) { + $cs[$domainEvent->previousSourceContentStreamId->value] = $cs[$domainEvent->previousSourceContentStreamId->value] + ->withStatus(ContentStreamStatus::NO_LONGER_IN_USE); + } + break; + case WorkspaceWasPublished::class: + if (isset($cs[$domainEvent->newSourceContentStreamId->value])) { + $cs[$domainEvent->newSourceContentStreamId->value] = $cs[$domainEvent->newSourceContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + if (isset($cs[$domainEvent->previousSourceContentStreamId->value])) { + $cs[$domainEvent->previousSourceContentStreamId->value] = $cs[$domainEvent->previousSourceContentStreamId->value] + ->withStatus(ContentStreamStatus::NO_LONGER_IN_USE); + } + break; + case WorkspaceWasRebased::class: + if (isset($cs[$domainEvent->newContentStreamId->value])) { + $cs[$domainEvent->newContentStreamId->value] = $cs[$domainEvent->newContentStreamId->value] + ->withStatus(ContentStreamStatus::IN_USE_BY_WORKSPACE); + } + if (isset($cs[$domainEvent->previousContentStreamId->value])) { + $cs[$domainEvent->previousContentStreamId->value] = $cs[$domainEvent->previousContentStreamId->value] + ->withStatus(ContentStreamStatus::NO_LONGER_IN_USE); + } + break; + case WorkspaceRebaseFailed::class: + // legacy handling, as we previously kept failed candidateContentStreamId we make it behave like a ContentStreamWasRemoved event to clean up: + if (isset($cs[$domainEvent->candidateContentStreamId->value])) { + $cs[$domainEvent->candidateContentStreamId->value] = $cs[$domainEvent->candidateContentStreamId->value] + ->withRemoved(); + } + break; + default: + throw new \RuntimeException(sprintf('Unhandled event %s', $eventEnvelope->event->type->value)); } } + return $cs; + } - return ContentStreams::fromArray($removedContentStreams); + /** + * @return list + */ + private function findAllContentStreamStreamNames(): array + { + $events = $this->eventStore->load( + VirtualStreamName::forCategory(ContentStreamEventStreamName::EVENT_STREAM_NAME_PREFIX), + EventStreamFilter::create( + EventTypes::create( + // we are only interested in the creation events to limit the amount of events to fetch + EventType::fromString('ContentStreamWasCreated'), + EventType::fromString('ContentStreamWasForked') + ) + ) + ); + $allStreamNames = []; + foreach ($events as $eventEnvelope) { + $allStreamNames[] = $eventEnvelope->streamName; + } + return array_unique($allStreamNames, SORT_REGULAR); + } + + /** + * @return list + */ + private function findAllWorkspaceStreamNames(): array + { + $events = $this->eventStore->load( + VirtualStreamName::forCategory(WorkspaceEventStreamName::EVENT_STREAM_NAME_PREFIX), + EventStreamFilter::create( + EventTypes::create( + // we are only interested in the creation events to limit the amount of events to fetch + EventType::fromString('RootWorkspaceWasCreated'), + EventType::fromString('WorkspaceWasCreated') + ) + ) + ); + $allStreamNames = []; + foreach ($events as $eventEnvelope) { + $allStreamNames[] = $eventEnvelope->streamName; + } + return array_unique($allStreamNames, SORT_REGULAR); } } diff --git a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamForPruning.php b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamForPruning.php new file mode 100644 index 00000000000..9264fc8b9f7 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamForPruning.php @@ -0,0 +1,111 @@ + removed from content graph │◀─┘ + * └────────────────────────────────────────┘ Cleanup + * │ + * ▼ + * ┌────────────────────────────────────────┐ + * │ completely deleted from event stream │ + * └────────────────────────────────────────┘ + * + * @internal + */ +final readonly class ContentStreamForPruning +{ + private function __construct( + public ContentStreamId $id, + public ContentStreamStatus $status, + public ?ContentStreamId $sourceContentStreamId, + public \DateTimeImmutable $created, + public bool $removed, + ) { + } + + public static function create( + ContentStreamId $id, + ContentStreamStatus $status, + ?ContentStreamId $sourceContentStreamId, + \DateTimeImmutable $create, + ): self { + return new self( + $id, + $status, + $sourceContentStreamId, + $create, + false + ); + } + + public function withStatus(ContentStreamStatus $status): self + { + return new self( + $this->id, + $status, + $this->sourceContentStreamId, + $this->created, + $this->removed + ); + } + + public function withRemoved(): self + { + return new self( + $this->id, + $this->status, + $this->sourceContentStreamId, + $this->created, + true + ); + } + + public function isDangling(): bool + { + return !$this->removed && $this->status !== ContentStreamStatus::IN_USE_BY_WORKSPACE; + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/ContentStream.monopic b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamStatus.monopic similarity index 100% rename from Neos.ContentRepository.Core/Classes/SharedModel/Workspace/ContentStream.monopic rename to Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamStatus.monopic diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/ContentStreamStatus.php b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamStatus.php similarity index 52% rename from Neos.ContentRepository.Core/Classes/SharedModel/Workspace/ContentStreamStatus.php rename to Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamStatus.php index ac21aeaa4cc..1c96d2f62e0 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/ContentStreamStatus.php +++ b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPruner/ContentStreamStatus.php @@ -12,19 +12,19 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\Workspace; +namespace Neos\ContentRepository\Core\Service\ContentStreamPruner; /** * @api */ -enum ContentStreamStatus: string implements \JsonSerializable +enum ContentStreamStatus: string { /** * the content stream was created, but not yet assigned to a workspace. * * **temporary state** which should not appear if the system is idle (for content streams which are used with workspaces). */ - case CREATED = 'CREATED'; + case CREATED = 'created'; /** * FORKED means the content stream was forked from an existing content stream, but not yet assigned @@ -32,38 +32,23 @@ enum ContentStreamStatus: string implements \JsonSerializable * * **temporary state** which should not appear if the system is idle (for content streams which are used with workspaces). */ - case FORKED = 'FORKED'; + case FORKED = 'forked'; /** * the content stream is currently referenced as the "active" content stream by a workspace. */ - case IN_USE_BY_WORKSPACE = 'IN_USE_BY_WORKSPACE'; - - /** - * a workspace was tried to be rebased, and during the rebase an error occured. This is the content stream - * which contains the errored state - so that we can recover content from it (probably manually) - * - * @deprecated legacy status, FIXME clean up! https://github.com/neos/neos-development-collection/issues/5101 - */ - case REBASE_ERROR = 'REBASE_ERROR'; - - /** - * the content stream was closed and must no longer accept new events - */ - case CLOSED = 'CLOSED'; + case IN_USE_BY_WORKSPACE = 'in use by workspace'; /** * the content stream is not used anymore, and can be removed. */ - case NO_LONGER_IN_USE = 'NO_LONGER_IN_USE'; - - public static function fromString(string $value): self - { - return self::from($value); - } + case NO_LONGER_IN_USE = 'no longer in use'; - public function jsonSerialize(): string + public function isTemporary(): bool { - return $this->value; + return match ($this) { + self::CREATED, self::FORKED => true, + default => false + }; } } diff --git a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPrunerFactory.php b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPrunerFactory.php index 66acf840a9e..f9940f8f56a 100644 --- a/Neos.ContentRepository.Core/Classes/Service/ContentStreamPrunerFactory.php +++ b/Neos.ContentRepository.Core/Classes/Service/ContentStreamPrunerFactory.php @@ -18,6 +18,7 @@ public function build(ContentRepositoryServiceFactoryDependencies $serviceFactor return new ContentStreamPruner( $serviceFactoryDependencies->contentRepository, $serviceFactoryDependencies->eventStore, + $serviceFactoryDependencies->eventNormalizer ); } } diff --git a/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceService.php b/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceService.php index 0c2ee14feab..b166f0d1271 100644 --- a/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceService.php +++ b/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceService.php @@ -6,13 +6,11 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; -use Neos\ContentRepository\Core\Feature\WorkspaceEventStreamName; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus; -use Neos\EventStore\EventStoreInterface; /** * @api @@ -20,8 +18,7 @@ class WorkspaceMaintenanceService implements ContentRepositoryServiceInterface { public function __construct( - private readonly ContentRepository $contentRepository, - private readonly EventStoreInterface $eventStore, + private readonly ContentRepository $contentRepository ) { } @@ -33,7 +30,7 @@ public function rebaseOutdatedWorkspaces(?RebaseErrorHandlingStrategy $strategy $outdatedWorkspaces = $this->contentRepository->findWorkspaces()->filter( fn (Workspace $workspace) => $workspace->status === WorkspaceStatus::OUTDATED ); - /** @var Workspace $workspace */ + // todo we need to loop through the workspaces from root level first foreach ($outdatedWorkspaces as $workspace) { if ($workspace->status !== WorkspaceStatus::OUTDATED) { continue; @@ -49,12 +46,4 @@ public function rebaseOutdatedWorkspaces(?RebaseErrorHandlingStrategy $strategy return $outdatedWorkspaces; } - - public function pruneAll(): void - { - foreach ($this->contentRepository->findWorkspaces() as $workspace) { - $streamName = WorkspaceEventStreamName::fromWorkspaceName($workspace->workspaceName)->getEventStreamName(); - $this->eventStore->deleteStream($streamName); - } - } } diff --git a/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceServiceFactory.php b/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceServiceFactory.php index eef7d991618..1e2b9c2f9ff 100644 --- a/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceServiceFactory.php +++ b/Neos.ContentRepository.Core/Classes/Service/WorkspaceMaintenanceServiceFactory.php @@ -17,8 +17,7 @@ public function build( ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies ): WorkspaceMaintenanceService { return new WorkspaceMaintenanceService( - $serviceFactoryDependencies->contentRepository, - $serviceFactoryDependencies->eventStore, + $serviceFactoryDependencies->contentRepository ); } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php similarity index 52% rename from Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php rename to Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php index a590fc50546..fe0688363cb 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspacePublication/Exception/BaseWorkspaceHasBeenModifiedInTheMeantime.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/NodeAggregateIsUntethered.php @@ -1,5 +1,9 @@ removed from content graph │◀─┘ - * └────────────────────────────────────────┘ Cleanup - * │ - * ▼ - * ┌────────────────────────────────────────┐ - * │ completely deleted from event stream │ - * └────────────────────────────────────────┘ - * - * @api + * @api Note: The constructor is not part of the public API */ final readonly class ContentStream { - /** - * @internal - */ - public function __construct( + private function __construct( public ContentStreamId $id, public ?ContentStreamId $sourceContentStreamId, - public ContentStreamStatus $status, public Version $version, - public bool $removed + public bool $isClosed ) { } + + /** + * @internal + */ + public static function create( + ContentStreamId $id, + ?ContentStreamId $sourceContentStreamId, + Version $version, + bool $isClosed + ): self { + return new self( + $id, + $sourceContentStreamId, + $version, + $isClosed + ); + } } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php index 7f51c161123..5a9a9985e2e 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php @@ -17,43 +17,47 @@ /** * Workspace Read Model * - * @api + * @api Note: The constructor is not part of the public API */ final readonly class Workspace { /** - * @var WorkspaceName Workspace identifier, unique within one Content Repository instance + * @param WorkspaceName $workspaceName Workspace identifier, unique within one Content Repository instance + * @param WorkspaceName|null $baseWorkspaceName Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace + * @param ContentStreamId $currentContentStreamId The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace + * @param WorkspaceStatus $status The current status of this workspace */ - public WorkspaceName $workspaceName; - - /** - * @var WorkspaceName|null Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace - */ - public ?WorkspaceName $baseWorkspaceName; - - /** - * The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace - */ - public ContentStreamId $currentContentStreamId; - - /** - * The current status of this workspace - */ - public WorkspaceStatus $status; + private function __construct( + public WorkspaceName $workspaceName, + public ?WorkspaceName $baseWorkspaceName, + public ContentStreamId $currentContentStreamId, + public WorkspaceStatus $status, + private bool $hasPublishableChanges + ) { + if ($this->isRootWorkspace() && $this->hasPublishableChanges) { + throw new \InvalidArgumentException('Root workspaces cannot have changes', 1730371566); + } + } /** * @internal */ - public function __construct( + public static function create( WorkspaceName $workspaceName, ?WorkspaceName $baseWorkspaceName, ContentStreamId $currentContentStreamId, WorkspaceStatus $status, - ) { - $this->workspaceName = $workspaceName; - $this->baseWorkspaceName = $baseWorkspaceName; - $this->currentContentStreamId = $currentContentStreamId; - $this->status = $status; + bool $hasPublishableChanges + ): self { + return new self($workspaceName, $baseWorkspaceName, $currentContentStreamId, $status, $hasPublishableChanges); + } + + /** + * Indicates if the workspace contains changed to be published + */ + public function hasPublishableChanges(): bool + { + return $this->hasPublishableChanges; } /** diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php index 7c8d25067ff..5ef187bb1db 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php @@ -47,18 +47,6 @@ enum WorkspaceStatus: string implements \JsonSerializable */ case OUTDATED = 'OUTDATED'; - /** - * CONFLICT Example: - * - * CONFLICT is a special case of OUTDATED, but then an error happens during the rebasing. - * - * Workspace Review <----------------------------------- Workspace User-Foo - * | . | - * Content Stream A2 (current) <-- Content Stream B2 (rebasing) | - * Content Stream B1 - */ - case OUTDATED_CONFLICT = 'OUTDATED_CONFLICT'; - public function equals(self $other): bool { return $this->value === $other->value; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspaces.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspaces.php index f6af24a86ec..be0969b591d 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspaces.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspaces.php @@ -21,7 +21,6 @@ * * @api */ - final class Workspaces implements \IteratorAggregate, \Countable { /** diff --git a/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreatedTest.php b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreatedTest.php index 677ed3fb971..eed23e704d9 100644 --- a/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreatedTest.php +++ b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeCreation/Event/NodeAggregateWithNodeWasCreatedTest.php @@ -20,6 +20,7 @@ use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; use Neos\ContentRepository\Core\Feature\NodeCreation\Event\NodeAggregateWithNodeWasCreated; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateClassification; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -87,7 +88,8 @@ public static function eventPayloadProviderWithLegacySucceedingNodeAggregateIdFo NodeAggregateId::fromString('6b6e1251-4346-494f-ac56-526a30a5741d'), null, SerializedPropertyValues::createEmpty(), - NodeAggregateClassification::CLASSIFICATION_REGULAR + NodeAggregateClassification::CLASSIFICATION_REGULAR, + SerializedNodeReferences::createEmpty(), ) ]; @@ -128,7 +130,8 @@ public static function eventPayloadProviderWithLegacySucceedingNodeAggregateIdFo NodeAggregateId::fromString('6b6e1251-4346-494f-ac56-526a30a5741d'), null, SerializedPropertyValues::createEmpty(), - NodeAggregateClassification::CLASSIFICATION_REGULAR + NodeAggregateClassification::CLASSIFICATION_REGULAR, + SerializedNodeReferences::createEmpty(), ) ]; } diff --git a/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/Event/NodeReferencesWereSetTest.php b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/Event/NodeReferencesWereSetTest.php index 8567d7b088a..7abdb32b829 100644 --- a/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/Event/NodeReferencesWereSetTest.php +++ b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/Event/NodeReferencesWereSetTest.php @@ -18,7 +18,6 @@ use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use PHPUnit\Framework\TestCase; @@ -69,8 +68,7 @@ public static function eventPayloadProviderWithLegacySourceNodeAggregateIdFormat 'language' => 'en_US', ], ]), - ReferenceName::fromString('blogs'), - SerializedNodeReferences::fromArray([]) + SerializedNodeReferences::createEmpty() ) ]; } diff --git a/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/NodeReferencesToWriteTest.php b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/NodeReferencesToWriteTest.php new file mode 100644 index 00000000000..9b76142793a --- /dev/null +++ b/Neos.ContentRepository.Core/Tests/Unit/Feature/NodeReferencing/NodeReferencesToWriteTest.php @@ -0,0 +1,92 @@ +merge($b); + + self::assertEquals( + iterator_to_array(NodeReferencesToWrite::create( + NodeReferencesForName::createEmpty(ReferenceName::fromString('foo')), + NodeReferencesForName::fromTargets(ReferenceName::fromString('bar'), NodeAggregateIds::fromArray(['fooo'])), + NodeReferencesForName::fromTargets(ReferenceName::fromString('new'), NodeAggregateIds::fromArray(['la-li-lu'])), + )), + iterator_to_array($c) + ); + } + + public function testAppendOverridesPrevious(): void + { + $a = NodeReferencesToWrite::create( + NodeReferencesForName::fromTargets(ReferenceName::fromString('foo'), NodeAggregateIds::fromArray(['abc'])), + NodeReferencesForName::fromTargets(ReferenceName::fromString('bar'), NodeAggregateIds::fromArray(['fooo'])), + ); + + $b = NodeReferencesForName::fromTargets(ReferenceName::fromString('bar'), NodeAggregateIds::fromArray(['la-li-lu', 'second'])); + + $c = $a->withReference($b); + + self::assertEquals( + iterator_to_array(NodeReferencesToWrite::create( + NodeReferencesForName::fromTargets(ReferenceName::fromString('foo'), NodeAggregateIds::fromArray(['abc'])), + NodeReferencesForName::fromTargets(ReferenceName::fromString('bar'), NodeAggregateIds::fromArray(['la-li-lu', 'second'])) + )), + iterator_to_array($c) + ); + } + + public function testSameTargetsAreNotAllowedMultipleTimes(): void + { + self::expectException(\InvalidArgumentException::class); + + NodeReferencesForName::fromReferences(ReferenceName::fromString('bar'), [ + NodeReferenceToWrite::fromTarget(NodeAggregateId::fromString('node1')), + NodeReferenceToWrite::fromTarget(NodeAggregateId::fromString('node-other')), + NodeReferenceToWrite::fromTarget(NodeAggregateId::fromString('node1')), + ]); + } +} diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php index 0aa571b20ea..e603c12fd04 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php @@ -18,6 +18,7 @@ use Behat\Gherkin\Node\TableNode; use League\Flysystem\Filesystem; use League\Flysystem\InMemory\InMemoryFilesystemAdapter; +use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -154,12 +155,11 @@ public function iExpectTheFollowingJsonL(PyStringNode $string): void $eventsWithoutRandomIds = []; foreach ($exportedEvents as $exportedEvent) { - // we have to remove the event id in \Neos\ContentRepository\Core\Feature\Common\NodeAggregateEventPublisher::enrichWithCommand - // and the initiatingTimestamp to make the events diff able + // we have to remove the event id and initiatingTimestamp to make the events diff able $eventsWithoutRandomIds[] = $exportedEvent ->withIdentifier('random-event-uuid') ->processMetadata(function (array $metadata) { - $metadata['initiatingTimestamp'] = 'random-time'; + $metadata[InitiatingEventMetadata::INITIATING_TIMESTAMP] = 'random-time'; return $metadata; }); } diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature b/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature index 99ecc22b627..06c9273ad54 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature @@ -38,6 +38,6 @@ Feature: As a user of the CR I want to export the event stream Then I expect the following jsonl: """ {"identifier":"random-event-uuid","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","coveredDimensionSpacePoints":[{"language":"de"},{"language":"gsw"},{"language":"fr"}],"nodeAggregateClassification":"root"},"metadata":{"commandClass":"Neos\\ContentRepository\\Core\\Feature\\RootNodeCreation\\Command\\CreateRootNodeAggregateWithNode","commandPayload":{"workspaceName":"live","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","tetheredDescendantNodeAggregateIds":[]},"initiatingUserId":"system","initiatingTimestamp":"random-time"}} - {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular"},"metadata":{"initiatingTimestamp":"random-time"}} + {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular","nodeReferences":[]},"metadata":{"initiatingTimestamp":"random-time"}} """ diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SerializedPropertyValuesAndReferences.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SerializedPropertyValuesAndReferences.php index f564aec7cdd..df60f560766 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SerializedPropertyValuesAndReferences.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SerializedPropertyValuesAndReferences.php @@ -3,7 +3,7 @@ namespace Neos\ContentRepository\LegacyNodeMigration\Helpers; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\SerializedPropertyValues; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; use Neos\Flow\Annotations as Flow; /** @@ -11,12 +11,8 @@ */ final readonly class SerializedPropertyValuesAndReferences { - - /** - * @param array $references - */ public function __construct( public SerializedPropertyValues $serializedPropertyValues, - public array $references, + public SerializedNodeReferences $references, ) {} } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php index 9379206d5ee..894cfa441e9 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php @@ -22,6 +22,7 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; use Neos\ContentRepository\Core\Feature\NodeMove\Event\NodeAggregateWasMoved; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\SerializedNodeReferencesForName; use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodeGeneralizationVariantWasCreated; use Neos\ContentRepository\Core\Feature\NodeVariation\Event\NodePeerVariantWasCreated; @@ -295,6 +296,7 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ $nodeName, $serializedPropertyValuesAndReferences->serializedPropertyValues, NodeAggregateClassification::CLASSIFICATION_TETHERED, + $serializedPropertyValuesAndReferences->references, ) ); } elseif ($this->visitedNodes->containsNodeAggregate($nodeAggregateId)) { @@ -318,6 +320,7 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ $nodeName, $serializedPropertyValuesAndReferences->serializedPropertyValues, NodeAggregateClassification::CLASSIFICATION_REGULAR, + $serializedPropertyValuesAndReferences->references, ) ); } @@ -325,8 +328,9 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ if ($this->isNodeHidden($nodeDataRow)) { $this->exportEvent(new SubtreeWasTagged($this->workspaceName, $this->contentStreamId, $nodeAggregateId, $this->interDimensionalVariationGraph->getSpecializationSet($originDimensionSpacePoint->toDimensionSpacePoint(), true, $this->visitedNodes->alreadyVisitedOriginDimensionSpacePoints($nodeAggregateId)->toDimensionSpacePointSet()), SubtreeTag::disabled())); } - foreach ($serializedPropertyValuesAndReferences->references as $referencePropertyName => $destinationNodeAggregateIds) { - $this->nodeReferencesWereSetEvents[] = new NodeReferencesWereSet($this->workspaceName, $this->contentStreamId, $nodeAggregateId, new OriginDimensionSpacePointSet([$originDimensionSpacePoint]), ReferenceName::fromString($referencePropertyName), SerializedNodeReferences::fromNodeAggregateIds($destinationNodeAggregateIds)); + + if (!$serializedPropertyValuesAndReferences->references->isEmpty()) { + $this->nodeReferencesWereSetEvents[] = new NodeReferencesWereSet($this->workspaceName, $this->contentStreamId, $nodeAggregateId, new OriginDimensionSpacePointSet([$originDimensionSpacePoint]), $serializedPropertyValuesAndReferences->references); } $this->visitedNodes->add($nodeAggregateId, new DimensionSpacePointSet([$originDimensionSpacePoint->toDimensionSpacePoint()]), $nodeTypeName, $nodePath, $parentNodeAggregate->nodeAggregateId); @@ -356,7 +360,10 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType if (!is_array($propertyValue)) { $propertyValue = [$propertyValue]; } - $references[$propertyName] = NodeAggregateIds::fromArray(array_map(static fn (string $identifier) => NodeAggregateId::fromString($identifier), $propertyValue)); + $references[] = SerializedNodeReferencesForName::fromTargets( + ReferenceName::fromString($propertyName), + NodeAggregateIds::fromArray($propertyValue) + ); } continue; } @@ -401,7 +408,7 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } } - return new SerializedPropertyValuesAndReferences($this->propertyConverter->serializePropertyValues(PropertyValuesToWrite::fromArray($properties)->withoutUnsets(), $nodeType), $references); + return new SerializedPropertyValuesAndReferences($this->propertyConverter->serializePropertyValues(PropertyValuesToWrite::fromArray($properties)->withoutUnsets(), $nodeType), SerializedNodeReferences::fromArray($references)); } /** @@ -474,6 +481,9 @@ private function createNodeVariant(NodeAggregateId $nodeAggregateId, OriginDimen ) ); } + + // TODO: We should also set references here, shouldn't we? + // When we specialize/generalize, we create a node variant at exactly the same tree location as the source node // If the parent node aggregate id differs, we need to move the just created variant to the new location $nodeAggregate = $this->visitedNodes->getByNodeAggregateId($nodeAggregateId); diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/References.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/References.feature index 43b37d74ac4..042bac31225 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/References.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/References.feature @@ -42,8 +42,8 @@ Feature: Migrations that contain nodes with "reference" or "references propertie | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "a"} | | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "b"} | | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "c"} | - | NodeReferencesWereSet | {"nodeAggregateId":"a","affectedSourceOriginDimensionSpacePoints":[[]],"referenceName":"ref","references":{"b":{"targetNodeAggregateId":"b","properties":null}}} | - | NodeReferencesWereSet | {"nodeAggregateId":"c","affectedSourceOriginDimensionSpacePoints":[[]],"referenceName":"refs","references":{"a":{"targetNodeAggregateId":"a","properties":null},"b":{"targetNodeAggregateId":"b","properties":null}}} | + | NodeReferencesWereSet | {"nodeAggregateId":"a","affectedSourceOriginDimensionSpacePoints":[[]],"references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} | + | NodeReferencesWereSet | {"nodeAggregateId":"c","affectedSourceOriginDimensionSpacePoints":[[]],"references":[{"referenceName": "refs", "references": [{"target":"a"},{"target":"b"}]}]} | Scenario: Node with references in one dimension @@ -67,9 +67,9 @@ Feature: Migrations that contain nodes with "reference" or "references propertie | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "a"} | | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "b"} | | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "c"} | - | NodeReferencesWereSet | {"nodeAggregateId":"a","affectedSourceOriginDimensionSpacePoints":[{"language": "en"}],"referenceName":"ref","references":{"b":{"targetNodeAggregateId":"b","properties":null}}} | - | NodeReferencesWereSet | {"nodeAggregateId":"b","affectedSourceOriginDimensionSpacePoints":[{"language": "de"}],"referenceName":"ref","references":{"a":{"targetNodeAggregateId":"a","properties":null}}} | - | NodeReferencesWereSet | {"nodeAggregateId":"c","affectedSourceOriginDimensionSpacePoints":[{"language": "ch"}],"referenceName":"refs","references":{"a":{"targetNodeAggregateId":"a","properties":null},"b":{"targetNodeAggregateId":"b","properties":null}}} | + | NodeReferencesWereSet | {"nodeAggregateId":"a","affectedSourceOriginDimensionSpacePoints":[{"language": "en"}],"references":[{"referenceName":"ref","references":[{"target":"b"}]}]} | + | NodeReferencesWereSet | {"nodeAggregateId":"b","affectedSourceOriginDimensionSpacePoints":[{"language": "de"}],"references":[{"referenceName":"ref","references":[{"target":"a"}]}]} | + | NodeReferencesWereSet | {"nodeAggregateId":"c","affectedSourceOriginDimensionSpacePoints":[{"language": "ch"}],"references":[{"referenceName":"refs","references":[{"target":"a"},{"target":"b"}]}]} | Scenario: Nodes with properties that are not part of the node type schema (see https://github.com/neos/neos-development-collection/issues/4804) When I have the following node data rows: diff --git a/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php b/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php index 826460a31e0..61401187d24 100644 --- a/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php +++ b/Neos.ContentRepository.NodeMigration/src/NodeMigrationService.php @@ -16,7 +16,6 @@ use Neos\ContentRepository\NodeMigration\Filter\FiltersFactory; use Neos\ContentRepository\NodeMigration\Filter\InvalidMigrationFilterSpecified; use Neos\ContentRepository\NodeMigration\Transformation\TransformationsFactory; -use Neos\Neos\PendingChangesProjection\ChangeFinder; /** * Node Migrations are manually written adjustments to the Node tree; @@ -68,7 +67,7 @@ public function executeMigration(ExecuteMigration $command): void $targetWorkspaceWasCreated = false; if ($targetWorkspace = $this->contentRepository->findWorkspaceByName($command->targetWorkspaceName)) { - if (!$this->workspaceIsEmpty($targetWorkspace)) { + if ($targetWorkspace->hasPublishableChanges()) { throw new MigrationException(sprintf('Target workspace "%s" already exists an is not empty. Please clear the workspace before.', $targetWorkspace->workspaceName->value)); } @@ -196,11 +195,4 @@ protected function executeSubMigration( } } } - - private function workspaceIsEmpty(Workspace $workspace): bool - { - return $this->contentRepository - ->projectionState(ChangeFinder::class) - ->countByContentStreamId($workspace->currentContentStreamId) === 0; - } } diff --git a/Neos.ContentRepository.StructureAdjustment/src/Adjustment/TetheredNodeAdjustments.php b/Neos.ContentRepository.StructureAdjustment/src/Adjustment/TetheredNodeAdjustments.php index 95a7e5336a5..9d985cc2399 100644 --- a/Neos.ContentRepository.StructureAdjustment/src/Adjustment/TetheredNodeAdjustments.php +++ b/Neos.ContentRepository.StructureAdjustment/src/Adjustment/TetheredNodeAdjustments.php @@ -5,21 +5,25 @@ namespace Neos\ContentRepository\StructureAdjustment\Adjustment; use Neos\ContentRepository\Core\DimensionSpace; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSibling; use Neos\ContentRepository\Core\Feature\Common\InterdimensionalSiblings; +use Neos\ContentRepository\Core\Feature\Common\NodeTypeChangeInternals; use Neos\ContentRepository\Core\Feature\Common\NodeVariationInternals; use Neos\ContentRepository\Core\Feature\Common\TetheredNodeInternals; use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeMove\Event\NodeAggregateWasMoved; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; +use Neos\ContentRepository\Core\NodeType\NodeType; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindChildNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFound; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\EventStore\Model\EventStream\ExpectedVersion; @@ -29,6 +33,7 @@ class TetheredNodeAdjustments use NodeVariationInternals; use RemoveNodeAggregateTrait; use TetheredNodeInternals; + use NodeTypeChangeInternals; public function __construct( private readonly ContentGraphInterface $contentGraph, @@ -189,6 +194,14 @@ private function ensureNodeIsOfType(Node $node, NodeTypeName $expectedNodeTypeNa } } + protected function requireNodeType(NodeTypeName $nodeTypeName): NodeType + { + return $this->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.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index 0384232e778..18fddc56f68 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -15,27 +15,24 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap; use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\CatchUpOptions; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindSubtreeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\NodeType\NodeTypeCriteria; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; -use Neos\ContentRepository\Core\Service\ContentStreamPruner; use Neos\ContentRepository\Core\Service\ContentStreamPrunerFactory; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamStatus; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\ContentStreamClosing; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\ContentStreamForking; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeCopying; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeCreation; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features\NodeDisabling; @@ -66,7 +63,6 @@ trait CRTestSuiteTrait use ProjectedNodeTrait; use GenericCommandExecutionAndEventPublication; - use ContentStreamForking; use ContentStreamClosing; use NodeCreation; @@ -131,24 +127,37 @@ protected function readPayloadTable(TableNode $payloadTable): array return $eventPayload; } + /** + * @Then /^I expect the content stream "([^"]*)" to exist$/ + */ + public function iExpectTheContentStreamToExist(string $rawContentStreamId): void + { + $contentStream = $this->currentContentRepository->findContentStreamById(ContentStreamId::fromString($rawContentStreamId)); + Assert::assertNotNull($contentStream, sprintf('Content stream "%s" was expected to exist, but it does not', $rawContentStreamId)); + } + /** * @Then /^I expect the content stream "([^"]*)" to not exist$/ */ - public function iExpectTheContentStreamToNotExist(string $rawContentStreamId): void + public function iExpectTheContentStreamToNotExist(string $rawContentStreamId, string $not = ''): void { $contentStream = $this->currentContentRepository->findContentStreamById(ContentStreamId::fromString($rawContentStreamId)); - $contentStreamExists = $contentStream !== null && !$contentStream->removed; - Assert::assertFalse($contentStreamExists, sprintf('Content stream "%s" was not expected to exist, but it does', $rawContentStreamId)); + Assert::assertNull($contentStream, sprintf('Content stream "%s" was not expected to exist, but it does', $rawContentStreamId)); } /** - * @Then /^workspace ([^"]*) has status ([^"]*)$/ + * @Then /^workspace(?:s)? ([^"]*) ha(?:s|ve) status ([^"]*)$/ */ - public function workspaceHasStatus(string $rawWorkspaceName, string $status): void + public function workspaceStatusMatchesExpected(string $rawWorkspaceNames, string $status): void { - $workspace = $this->currentContentRepository->findWorkspaceByName(WorkspaceName::fromString($rawWorkspaceName)); + $rawWorkspaceNames = explode(',', $rawWorkspaceNames); + Assert::assertNotEmpty($rawWorkspaceNames); - Assert::assertSame($status, $workspace?->status->value); + foreach ($rawWorkspaceNames as $rawWorkspaceName) { + $workspace = $this->currentContentRepository->findWorkspaceByName(WorkspaceName::fromString($rawWorkspaceName)); + Assert::assertNotNull($workspace, "Workspace $rawWorkspaceName does not exist."); + Assert::assertEquals($status, $workspace->status->value, "Workspace '$rawWorkspaceName' has unexpected status."); + } } /** @@ -242,47 +251,26 @@ protected function getRootNodeAggregateId(): ?NodeAggregateId } /** - * @Then the content stream :contentStreamId has status :expectedState - */ - public function theContentStreamHasStatus(string $contentStreamId, string $expectedStatus): void - { - $contentStream = $this->currentContentRepository->findContentStreamById(ContentStreamId::fromString($contentStreamId)); - if ($contentStream === null) { - Assert::fail(sprintf('Expected content stream "%s" to have status "%s" but it does not exist', $contentStreamId, $expectedStatus)); - } - Assert::assertSame(ContentStreamStatus::tryFrom($expectedStatus), $contentStream->status); - } - - /** - * @Then the current content stream has status :expectedStatus + * @When I prune removed content streams from the event stream */ - public function theCurrentContentStreamHasStatus(string $expectedStatus): void + public function iPruneRemovedContentStreamsFromTheEventStream(): void { - $this->theContentStreamHasStatus( - $this->currentContentRepository - ->findWorkspaceByName($this->currentWorkspaceName) - ->currentContentStreamId->value, - $expectedStatus - ); + $this->getContentRepositoryService(new ContentStreamPrunerFactory())->pruneRemovedFromEventStream(fn () => null); } /** - * @When I prune unused content streams + * @When I expect the content stream pruner status output: */ - public function iPruneUnusedContentStreams(): void + public function iExpectTheContentStreamStatus(PyStringNode $pyStringNode): void { - /** @var ContentStreamPruner $contentStreamPruner */ - $contentStreamPruner = $this->getContentRepositoryService(new ContentStreamPrunerFactory()); - $contentStreamPruner->prune(); + // todo a little dirty to compare the cli output here :D + $lines = []; + $this->getContentRepositoryService(new ContentStreamPrunerFactory())->outputStatus(function ($line = '') use (&$lines) { + $lines[] = $line; + }); + Assert::assertSame($pyStringNode->getRaw(), join("\n", $lines)); } - /** - * @When I prune removed content streams from the event stream - */ - public function iPruneRemovedContentStreamsFromTheEventStream(): void - { - $this->getContentRepositoryService(new ContentStreamPrunerFactory())->pruneRemovedFromEventStream(); - } abstract protected function getContentRepositoryService( ContentRepositoryServiceFactoryInterface $factory diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamClosing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamClosing.php index 81c21a91567..05c7546c0b2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamClosing.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamClosing.php @@ -15,7 +15,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; -use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Command\CloseContentStream; +use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; @@ -29,26 +29,17 @@ trait ContentStreamClosing abstract protected function readPayloadTable(TableNode $payloadTable): array; /** - * @Given /^the command CloseContentStream is executed with payload:$/ + * @Given /^the event ContentStreamWasClosed was published with payload:$/ + * @param TableNode $payloadTable * @throws \Exception */ - public function theCommandCloseContentStreamIsExecutedWithPayload(TableNode $payloadTable): void + public function theEventContentStreamWasClosedWasPublishedWithPayload(TableNode $payloadTable) { - $commandArguments = $this->readPayloadTable($payloadTable); - $command = CloseContentStream::create(ContentStreamId::fromString($commandArguments['contentStreamId'])); + $eventPayload = $this->readPayloadTable($payloadTable); + $streamName = ContentStreamEventStreamName::fromContentStreamId( + ContentStreamId::fromString($eventPayload['contentStreamId']) + ); - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command CloseContentStream is executed with payload and exceptions are caught:$/ - */ - public function theCommandCloseContentStreamIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandCloseContentStreamIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } + $this->publishEvent('ContentStreamWasClosed', $streamName->getEventStreamName(), $eventPayload); } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamForking.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamForking.php deleted file mode 100644 index dc859e092d4..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/ContentStreamForking.php +++ /dev/null @@ -1,60 +0,0 @@ -readPayloadTable($payloadTable); - $command = ForkContentStream::create( - ContentStreamId::fromString($commandArguments['contentStreamId']), - ContentStreamId::fromString($commandArguments['sourceContentStreamId']), - ); - - $this->currentContentRepository->handle($command); - } - - /** - * @Given /^the command ForkContentStream is executed with payload and exceptions are caught:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandForkContentStreamIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable): void - { - try { - $this->theCommandForkContentStreamIsExecutedWithPayload($payloadTable); - } catch (\Exception $exception) { - $this->lastCommandException = $exception; - } - } -} diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php index d20ebdab535..fc6f5eb0fd2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeCreation.php @@ -201,11 +201,12 @@ public function theFollowingCreateNodeAggregateWithNodeCommandsAreExecuted(Table isset($row['initialPropertyValues']) ? $this->parsePropertyValuesJsonString($row['initialPropertyValues']) : null, + isset($row['references']) ? json_decode($row['references']) : null, ); - if (isset($row['tetheredDescendantNodeAggregateIds'])) { + if (!empty($row['tetheredDescendantNodeAggregateIds'])) { $command = $command->withTetheredDescendantNodeAggregateIds(NodeAggregateIdsByNodePaths::fromJsonString($row['tetheredDescendantNodeAggregateIds'])); } - if (isset($row['nodeName'])) { + if (!empty($row['nodeName'])) { $command = $command->withNodeName(NodeName::fromString($row['nodeName'])); } $this->currentContentRepository->handle($command); diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php index 19fc646c11c..43cdfc68c60 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/NodeReferencing.php @@ -19,6 +19,7 @@ use Neos\ContentRepository\Core\Feature\ContentStreamEventStreamName; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesForName; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferenceToWrite; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -55,23 +56,12 @@ public function theCommandSetNodeReferencesIsExecutedWithPayload(TableNode $payl $sourceOriginDimensionSpacePoint = isset($commandArguments['sourceOriginDimensionSpacePoint']) ? OriginDimensionSpacePoint::fromArray($commandArguments['sourceOriginDimensionSpacePoint']) : OriginDimensionSpacePoint::fromDimensionSpacePoint($this->currentDimensionSpacePoint); - $references = NodeReferencesToWrite::fromReferences( - array_map( - fn (array $referenceData): NodeReferenceToWrite => new NodeReferenceToWrite( - NodeAggregateId::fromString($referenceData['target']), - isset($referenceData['properties']) - ? $this->deserializeProperties($referenceData['properties']) - : null - ), - $commandArguments['references'] - ) - ); + $references = $this->mapRawNodeReferencesToNodeReferencesToWrite($commandArguments['references']); $command = SetNodeReferences::create( $workspaceName, NodeAggregateId::fromString($commandArguments['sourceNodeAggregateId']), $sourceOriginDimensionSpacePoint, - ReferenceName::fromString($commandArguments['referenceName']), $references, ); @@ -107,4 +97,18 @@ public function theEventNodeReferencesWereSetWasPublishedWithPayload(TableNode $ $this->publishEvent('NodeReferencesWereSet', $streamName->getEventStreamName(), $eventPayload); } + + protected function mapRawNodeReferencesToNodeReferencesToWrite(array $deserializedTableContent): NodeReferencesToWrite + { + $referencesForProperty = []; + foreach ($deserializedTableContent as $nodeReferencesForProperty) { + $references = []; + foreach ($nodeReferencesForProperty['references'] as $referenceData) { + $properties = isset($referenceData['properties']) ? $this->deserializeProperties($referenceData['properties']) : PropertyValuesToWrite::createEmpty(); + $references[] = NodeReferenceToWrite::fromTargetAndProperties(NodeAggregateId::fromString($referenceData['target']), $properties); + } + $referencesForProperty[] = NodeReferencesForName::fromReferences(ReferenceName::fromString($nodeReferencesForProperty['referenceName']), $references); + } + return NodeReferencesToWrite::fromArray($referencesForProperty); + } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php index b876e98a7b4..91122fe5dba 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php @@ -21,6 +21,7 @@ use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\CRTestSuiteRuntimeVariables; @@ -37,22 +38,6 @@ abstract protected function readPayloadTable(TableNode $payloadTable): array; abstract protected function publishEvent(string $eventType, StreamName $streamName, array $eventPayload): void; - /** - * @When /^the command CreateContentStream is executed with payload:$/ - * @param TableNode $payloadTable - * @throws \Exception - */ - public function theCommandCreateContentStreamIsExecutedWithPayload(TableNode $payloadTable) - { - $commandArguments = $this->readPayloadTable($payloadTable); - - $command = CreateContentStream::create( - ContentStreamId::fromString($commandArguments['contentStreamId']), - ); - - $this->currentContentRepository->handle($command); - } - /** * @When /^the command CreateRootWorkspace is executed with payload:$/ * @param TableNode $payloadTable @@ -100,6 +85,19 @@ public function theCommandCreateWorkspaceIsExecutedWithPayload(TableNode $payloa $this->currentContentRepository->handle($command); } + /** + * @When /^the command CreateWorkspace is executed with payload and exceptions are caught:$/ + * @param TableNode $payloadTable + * @throws \Exception + */ + public function theCommandCreateWorkspaceIsExecutedWithPayloadAndExceptionsAreCaught(TableNode $payloadTable) + { + try { + $this->theCommandCreateWorkspaceIsExecutedWithPayload($payloadTable); + } catch (\Exception $e) { + $this->lastCommandException = $e; + } + } /** * @When /^the command RebaseWorkspace is executed with payload:$/ diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php index 0b7c9cf04e9..6b656113bee 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspacePublishing.php @@ -15,6 +15,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Features; use Behat\Gherkin\Node\TableNode; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; @@ -46,9 +47,6 @@ public function theCommandPublishIndividualNodesFromWorkspaceIsExecuted(TableNod : $this->currentWorkspaceName, $nodesToPublish, ); - if (isset($commandArguments['contentStreamIdForMatchingPart'])) { - $command = $command->withContentStreamIdForMatchingPart(ContentStreamId::fromString($commandArguments['contentStreamIdForMatchingPart'])); - } if (isset($commandArguments['contentStreamIdForRemainingPart'])) { $command = $command->withContentStreamIdForRemainingPart(ContentStreamId::fromString($commandArguments['contentStreamIdForRemainingPart'])); } @@ -99,4 +97,35 @@ public function theCommandPublishWorkspaceIsExecutedAndExceptionsAreCaught(Table $this->lastCommandException = $exception; } } + + /** + * @Given /^the command ChangeBaseWorkspace is executed with payload:$/ + * @throws \Exception + */ + public function theCommandChangeBaseWorkspaceIsExecuted(TableNode $payloadTable): void + { + $commandArguments = $this->readPayloadTable($payloadTable); + $command = ChangeBaseWorkspace::create( + array_key_exists('workspaceName', $commandArguments) + ? WorkspaceName::fromString($commandArguments['workspaceName']) + : $this->currentWorkspaceName, + WorkspaceName::fromString($commandArguments['baseWorkspaceName']), + ); + if (array_key_exists('newContentStreamId', $commandArguments)) { + $command = $command->withNewContentStreamId(ContentStreamId::fromString($commandArguments['newContentStreamId'])); + } + $this->currentContentRepository->handle($command); + } + + /** + * @Given /^the command ChangeBaseWorkspace is executed with payload and exceptions are caught:$/ + */ + public function theCommandChangeBaseWorkspaceIsExecutedAndExceptionsAreCaught(TableNode $payloadTable): void + { + try { + $this->theCommandChangeBaseWorkspaceIsExecuted($payloadTable); + } catch (\Exception $exception) { + $this->lastCommandException = $exception; + } + } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index 6ae0344e203..dd899ee4e24 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -20,7 +20,6 @@ use Neos\ContentRepository\Core\EventStore\EventPersister; use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; -use Neos\ContentRepository\Core\Feature\ContentStreamForking\Command\ForkContentStream; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; @@ -35,6 +34,7 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\EventStore\EventStoreInterface; use Neos\EventStore\Model\Event; use Neos\EventStore\Model\Event\StreamName; @@ -105,7 +105,6 @@ protected static function resolveShortCommandName(string $shortCommandName): str 'PublishIndividualNodesFromWorkspace' => PublishIndividualNodesFromWorkspace::class, 'RebaseWorkspace' => RebaseWorkspace::class, 'CreateNodeAggregateWithNodeAndSerializedProperties' => CreateNodeAggregateWithNodeAndSerializedProperties::class, - 'ForkContentStream' => ForkContentStream::class, 'ChangeNodeAggregateName' => ChangeNodeAggregateName::class, 'SetSerializedNodeProperties' => SetSerializedNodeProperties::class, 'DisableNodeAggregate' => DisableNodeAggregate::class, @@ -149,7 +148,6 @@ protected function publishEvent(string $eventType, StreamName $streamName, array /** * @Then /^the last command should have thrown an exception of type "([^"]*)"(?: with code (\d*))?$/ - * @throws \ReflectionException */ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null): void { @@ -166,6 +164,28 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int } } + /** + * @Then the last command should have thrown the WorkspaceRebaseFailed exception with: + */ + public function theLastCommandShouldHaveThrownTheWorkspaceRebaseFailedWith(TableNode $payloadTable) + { + /** @var WorkspaceRebaseFailed $exception */ + $exception = $this->lastCommandException; + Assert::assertNotNull($exception, 'Command did not throw exception'); + Assert::assertInstanceOf(WorkspaceRebaseFailed::class, $exception, sprintf('Actual exception: %s (%s): %s', get_class($exception), $exception->getCode(), $exception->getMessage())); + + $actualComparableHash = []; + foreach ($exception->commandsThatFailedDuringRebase as $commandsThatFailed) { + $actualComparableHash[] = [ + 'SequenceNumber' => (string)$commandsThatFailed->getSequenceNumber()->value, + 'Command' => (new \ReflectionClass($commandsThatFailed->command))->getShortName(), + 'Exception' => (new \ReflectionClass($commandsThatFailed->exception))->getShortName(), + ]; + } + + Assert::assertSame($payloadTable->getHash(), $actualComparableHash); + } + /** * @Then /^I expect exactly (\d+) events? to be published on stream "([^"]*)"$/ * @param int $numberOfEvents diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/ContentStreamCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/ContentStreamCommandController.php index 40d4330c168..61325fb6a0e 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/ContentStreamCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/ContentStreamCommandController.php @@ -19,55 +19,99 @@ class ContentStreamCommandController extends CommandController protected $contentRepositoryRegistry; /** - * Remove all content streams which are not needed anymore from the projections. + * Detects if dangling content streams exists and which content streams could be pruned from the event stream * - * NOTE: This still **keeps** the event stream as is; so it would be possible to re-construct the content stream - * at a later point in time (though we currently do not provide any API for it). + * Dangling content streams + * ------------------------ * - * To remove the deleted Content Streams, use `./flow contentStream:pruneRemovedFromEventStream` after running - * `./flow contentStream:prune`. + * Content streams that are not removed via the event ContentStreamWasRemoved and are not in use by a workspace + * (not a current's workspace content stream). * - * By default, only content streams in STATE_NO_LONGER_IN_USE and STATE_REBASE_ERROR will be removed. - * If you also call with "--removeTemporary", will delete ALL content streams which are currently not assigned - * to a workspace (f.e. dangling ones in FORKED or CREATED.). + * Previously before Neos 9 beta 15 (#5301), dangling content streams were not removed during publishing, discard or rebase. + * + * ./flow contentStream:removeDangling + * + * Pruneable content streams + * ------------------------- + * + * Content streams that were removed ContentStreamWasRemoved e.g. after publishing, and are not required for a full + * replay to reconstruct the current projections state. The ability to reconstitute a previous state will be lost. + * + * ./flow contentStream:pruneRemovedFromEventStream * * @param string $contentRepository Identifier of the content repository. (Default: 'default') - * @param boolean $removeTemporary Will delete all content streams which are currently not assigned (Default: false) */ - public function pruneCommand(string $contentRepository = 'default', bool $removeTemporary = false): void + public function statusCommand(string $contentRepository = 'default'): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); $contentStreamPruner = $this->contentRepositoryRegistry->buildService($contentRepositoryId, new ContentStreamPrunerFactory()); - $unusedContentStreams = $contentStreamPruner->prune($removeTemporary); - $unusedContentStreamsPresent = false; - foreach ($unusedContentStreams as $contentStreamId) { - $this->outputFormatted('Removed %s', [$contentStreamId->value]); - $unusedContentStreamsPresent = true; - } - if (!$unusedContentStreamsPresent) { - $this->outputLine('There are no unused content streams.'); + $status = $contentStreamPruner->outputStatus( + $this->outputLine(...) + ); + if ($status === false) { + $this->quit(1); } } /** - * Remove unused and deleted content streams from the event stream; effectively REMOVING information completely + * Removes all nodes, hierarchy relations and content stream entries which are not needed anymore from the projections. + * + * NOTE: This still **keeps** the event stream as is; so it would be possible to re-construct the content stream at a later point in time. + * + * HINT: ./flow contentStream:status gives information what is about to be removed + * + * To prune the removed content streams from the event stream, run ./flow contentStream:pruneRemovedFromEventStream afterwards. + * + * NOTE: To ensure that no temporary content streams of the *current* moment are removed, a time threshold is configurable via --remove-temporary-before * * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string $removeTemporaryBefore includes all temporary content streams like FORKED or CREATED older than that in the removal. To remove all use --remove-temporary-before=-1sec */ - public function pruneRemovedFromEventStreamCommand(string $contentRepository = 'default'): void + public function removeDanglingCommand(string $contentRepository = 'default', string $removeTemporaryBefore = '-1day'): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); $contentStreamPruner = $this->contentRepositoryRegistry->buildService($contentRepositoryId, new ContentStreamPrunerFactory()); - $unusedContentStreams = $contentStreamPruner->pruneRemovedFromEventStream(); - $unusedContentStreamsPresent = false; - foreach ($unusedContentStreams as $contentStreamId) { - $this->outputFormatted('Removed events for %s', [$contentStreamId->id->value]); - $unusedContentStreamsPresent = true; + try { + $removeTemporaryBeforeDate = new \DateTimeImmutable($removeTemporaryBefore); + } catch (\Exception $exception) { + $this->outputLine(sprintf('--remove-temporary-before=%s is not a valid date: %s', $removeTemporaryBefore, $exception->getMessage())); + $this->quit(1); } - if (!$unusedContentStreamsPresent) { - $this->outputLine('There are no unused content streams.'); + + $now = new \DateTimeImmutable('now'); + if ($removeTemporaryBeforeDate > $now) { + $this->outputLine(sprintf('--remove-temporary-before=%s must be in the past', $removeTemporaryBefore)); + $this->quit(1); + } + + $contentStreamPruner->removeDanglingContentStreams( + $this->outputLine(...), + $removeTemporaryBeforeDate + ); + } + + /** + * Prune removed content streams that are unused from the event stream; effectively REMOVING information completely + * + * HINT: ./flow contentStream:status gives information what is about to be pruned + * + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param bool $force Prune the unused content streams without confirmation. This cannot be reverted! + */ + public function pruneRemovedFromEventStreamCommand(string $contentRepository = 'default', bool $force = false): void + { + if (!$force && !$this->output->askConfirmation(sprintf('> This will prune removed content streams that are unused from the event stream in content repository "%s" (see flow contentStream:status). Are you sure to proceed? (y/n) ', $contentRepository), false)) { + $this->outputLine('Abort.'); + return; } + + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentStreamPruner = $this->contentRepositoryRegistry->buildService($contentRepositoryId, new ContentStreamPrunerFactory()); + + $contentStreamPruner->pruneRemovedFromEventStream( + $this->outputLine(...) + ); } } diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php index c0401b67d1c..4825a4420b9 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php @@ -18,6 +18,18 @@ public function __construct( parent::__construct(); } + /** + * Temporary low level backup to ensure the prune migration https://github.com/neos/neos-development-collection/pull/5297 is safe + * + * @param string $contentRepository Identifier of the Content Repository to backup + */ + public function backupCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->backup($this->outputLine(...)); + } + /** * Migrates initial metadata & roles from the CR core workspaces to the corresponding Neos database tables * @@ -97,4 +109,11 @@ public function migratePayloadToValidWorkspaceNamesCommand(string $contentReposi $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); $eventMigrationService->migratePayloadToValidWorkspaceNames($this->outputLine(...)); } + + public function migrateSetReferencesToMultiNameFormatCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $eventMigrationService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->migrateReferencesToMultiFormat($this->outputLine(...)); + } } diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 9cd7d315f80..2a71cbd4333 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -15,6 +15,7 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Event\NodeReferencesWereSet; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; @@ -56,6 +57,14 @@ public function __construct( ) { } + public function backup(\Closure $outputFn): void + { + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId) + . '_bkp_' . date('Y_m_d_H_i_s'); + $this->copyEventTable($backupEventTableName); + $outputFn(sprintf('Backup. Copied events table to %s', $backupEventTableName)); + } + /** * The following things have to be migrated: * @@ -240,6 +249,107 @@ public function migratePropertiesToUnset(\Closure $outputFn): void } } + public function migrateReferencesToMultiFormat(\Closure $outputFn): void + { + $this->eventsModified = []; + $warnings = 0; + + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($this->contentRepositoryId) + . '_bkp_' . date('Y_m_d_H_i_s'); + $outputFn(sprintf('Backup: copying events table to %s', $backupEventTableName)); + + $this->copyEventTable($backupEventTableName); + + $streamName = VirtualStreamName::all(); + $eventStream = $this->eventStore->load($streamName, EventStreamFilter::create(EventTypes::create(EventType::fromString('NodeReferencesWereSet')))); + foreach ($eventStream as $eventEnvelope) { + $outputRewriteNotice = fn(string $message) => $outputFn(sprintf('%s@%s %s', $eventEnvelope->sequenceNumber->value, $eventEnvelope->event->type->value, $message)); + if ($eventEnvelope->event->type->value !== 'NodeReferencesWereSet') { + throw new \RuntimeException(sprintf('Unhandled event: %s', $eventEnvelope->event->type->value)); + } + + // migrate payload + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($eventData['referenceName'])) { + // this event must have the new format already + continue; + } + + $referenceNameString = $eventData['referenceName']; + // double array is not a mistake + $newReferencesPayload = [[ + 'referenceName' => $referenceNameString, + 'references' => [] + ]]; + + unset($eventData['referenceName']); + + // technically the false case should never happen, but we update it anyway + if (is_array($eventData['references'])) { + foreach ($eventData['references'] as $reference) { + $reference['target'] = $reference['targetNodeAggregateId']; + unset($reference['targetNodeAggregateId']); + if ($reference['properties'] === null) { + unset($reference['properties']); + } + $newReferencesPayload[0]['references'][] = $reference; + } + } + + $eventData['references'] = $newReferencesPayload; + $this->updateEventPayload($eventEnvelope->sequenceNumber, $eventData); + $outputRewriteNotice('Payload: references migrated'); + + // optionally also migrate metadata + $eventMetaData = $eventEnvelope->event->metadata?->value; + if (!isset($eventMetaData['commandClass'])) { + continue; + } + + if ($eventMetaData['commandClass'] !== SetSerializedNodeReferences::class) { + $warnings++; + $outputFn(sprintf('WARNING: Cannot migrate event metadata of %s as commandClass %s was not expected.', $eventEnvelope->event->type->value, $eventMetaData['commandClass'])); + continue; + } + + $referenceNameString = $eventMetaData['commandPayload']['referenceName']; + $newReferencesPayload = [ + [ + 'referenceName' => $referenceNameString, + 'references' => [] + ] + ]; + + unset($eventMetaData['commandPayload']['referenceName']); + + // technically the false case should never happen, but we update it anyway + if (is_array($eventMetaData['commandPayload']['references'])) { + foreach ($eventMetaData['commandPayload']['references'] as $reference) { + $reference['target'] = $reference['targetNodeAggregateId']; + unset($reference['targetNodeAggregateId']); + if ($reference['properties'] === null) { + unset($reference['properties']); + } + $newReferencesPayload[0]['references'][] = $reference; + } + } + + $eventMetaData['commandPayload']['references'] = $newReferencesPayload; + $outputRewriteNotice('Metadata: references migrated'); + $this->updateEventMetaData($eventEnvelope->sequenceNumber, $eventMetaData); + } + + if (!count($this->eventsModified)) { + $outputFn('Migration was not necessary.'); + return; + } + + $outputFn(); + $outputFn(sprintf('Migration applied to %s events.', count($this->eventsModified))); + if ($warnings) { + $outputFn(sprintf('WARNING: Finished but %d warnings emitted.', $warnings)); + } + } /** * Adds a dummy workspace name to the events meta-data, so it can be rebased diff --git a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/FlushSubgraphCachePoolCatchUpHookFactory.php b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/FlushSubgraphCachePoolCatchUpHookFactory.php index f31545386b3..cebe03ebb43 100644 --- a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/FlushSubgraphCachePoolCatchUpHookFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/FlushSubgraphCachePoolCatchUpHookFactory.php @@ -4,13 +4,15 @@ namespace Neos\ContentRepositoryRegistry\SubgraphCachingInMemory; -use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; /** * Factory for {@see FlushSubgraphCachePoolCatchUpHook}, auto-registered in Settings.yaml for GraphProjection * + * @implements CatchUpHookFactoryInterface * @internal */ class FlushSubgraphCachePoolCatchUpHookFactory implements CatchUpHookFactoryInterface @@ -20,7 +22,8 @@ public function __construct( private readonly SubgraphCachePool $subgraphCachePool ) { } - public function build(ContentRepository $contentRepository): CatchUpHookInterface + + public function build(CatchUpHookFactoryDependencies $dependencies): CatchUpHookInterface { return new FlushSubgraphCachePoolCatchUpHook($this->subgraphCachePool); } diff --git a/Neos.Fusion/Classes/Core/EelNeosDeprecationTracer.php b/Neos.Fusion/Classes/Core/EelNeosDeprecationTracer.php new file mode 100644 index 00000000000..4608aefdafb --- /dev/null +++ b/Neos.Fusion/Classes/Core/EelNeosDeprecationTracer.php @@ -0,0 +1,96 @@ + true, + 'accessroles' => true, + 'accessible' => true, + 'autocreated' => true, + 'cacheentryidentifier' => true, + 'childnodes' => true, + 'contentobject' => true, + 'context' => true, + 'contextpath' => true, + 'creationdatetime' => true, + 'depth' => true, + 'dimensions' => true, + 'hidden' => true, + 'hiddenafterdatetime' => true, + 'hiddenbeforedatetime' => true, + 'hiddeninindex' => true, + 'identifier' => true, + 'index' => true, + 'label' => true, + 'lastmodificationdatetime' => true, + 'lastpublicationdatetime' => true, + 'nodeaggregateidentifier' => true, + 'nodedata' => true, + 'nodename' => true, + 'nodetype' => true, + 'numberofchildnodes' => true, + 'othernodevariants' => true, + 'parent' => true, + 'parentpath' => true, + 'path' => true, + 'primarychildnode' => true, + 'propertynames' => true, + 'removed' => true, + 'root' => true, + 'tethered' => true, + 'visible' => true, + 'workspace' => true, + ]; + + public function __construct( + private readonly string $eelExpression, + private readonly bool $throwExceptions, + ) { + } + + public function recordPropertyAccess(object $object, string $propertyName): void + { + if ( + // deliberate cross package reference from Neos.Fusion to simplify the wiring of this temporary migration helper + $object instanceof Node + && array_key_exists(strtolower($propertyName), self::LEGACY_NODE_FIELDS) + ) { + $this->logDeprecationOrThrowException( + sprintf('The node field "%s" is removed in "%s"', $propertyName, $this->eelExpression) + ); + } + } + + public function recordMethodCall(object $object, string $methodName, array $arguments): void + { + } + + public function recordFunctionCall(callable $function, string $functionName, array $arguments): void + { + } + + private function logDeprecationOrThrowException(string $message): void + { + if ($this->throwExceptions) { + throw new \RuntimeException($message); + } else { + $this->logger->debug($message); + } + } +} diff --git a/Neos.Fusion/Classes/Core/Runtime.php b/Neos.Fusion/Classes/Core/Runtime.php index 1d3b304187e..cf05bb8db2c 100644 --- a/Neos.Fusion/Classes/Core/Runtime.php +++ b/Neos.Fusion/Classes/Core/Runtime.php @@ -786,7 +786,13 @@ protected function evaluateEelExpression($expression, AbstractFusionObject $cont $this->eelEvaluator->_activateDependency(); } - return EelUtility::evaluateEelExpression($expression, $this->eelEvaluator, $contextVariables); + $tracer = match ($this->settings['deprecationTracer'] ?? null) { + 'LOG' => new EelNeosDeprecationTracer($expression, false), + 'EXCEPTION' => new EelNeosDeprecationTracer($expression, true), + default => null + }; + + return EelUtility::evaluateEelExpression($expression, $this->eelEvaluator, $contextVariables, [], $tracer); } /** diff --git a/Neos.Fusion/Configuration/Development/Settings.yaml b/Neos.Fusion/Configuration/Development/Settings.yaml index 7a129f68979..e4b7825cd2b 100644 --- a/Neos.Fusion/Configuration/Development/Settings.yaml +++ b/Neos.Fusion/Configuration/Development/Settings.yaml @@ -1,4 +1,5 @@ Neos: Fusion: + deprecationTracer: "LOG" rendering: exceptionHandler: Neos\Fusion\Core\ExceptionHandlers\HtmlMessageHandler diff --git a/Neos.Fusion/Configuration/Settings.yaml b/Neos.Fusion/Configuration/Settings.yaml index b9220ff66ac..7799ed47e9b 100644 --- a/Neos.Fusion/Configuration/Settings.yaml +++ b/Neos.Fusion/Configuration/Settings.yaml @@ -33,6 +33,11 @@ Neos: # This option is suited only for development and enabled there by default. enableParsePartialsCache: true + # Experimental logging for deprecated fusion code at runtime. + # Either disabled or "LOG" or "EXCEPTION" to let the exception handler step in ensuring no deprecated syntax is used. + # Should only be used during development + deprecationTracer: false + # Default context objects that are available in Eel expressions # # New variables should be added with a package key prefix. Example: diff --git a/Neos.Fusion/Resources/Private/Schema/Settings.Neos.Fusion.schema.yaml b/Neos.Fusion/Resources/Private/Schema/Settings.Neos.Fusion.schema.yaml index d2823246e65..e921228a917 100644 --- a/Neos.Fusion/Resources/Private/Schema/Settings.Neos.Fusion.schema.yaml +++ b/Neos.Fusion/Resources/Private/Schema/Settings.Neos.Fusion.schema.yaml @@ -19,6 +19,8 @@ properties: type: boolean enableParsePartialsCache: type: boolean + deprecationTracer: + type: [string, boolean] defaultContext: type: dictionary additionalProperties: diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index e240adb41bc..e52b55bb99c 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -4,7 +4,6 @@ namespace Neos\Neos\AssetUsage\CatchUpHook; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\EventInterface; @@ -21,9 +20,11 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasDiscarded; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyDiscarded; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindDescendantNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -36,7 +37,8 @@ class AssetUsageCatchUpHook implements CatchUpHookInterface { public function __construct( - private readonly ContentRepository $contentRepository, + private readonly ContentRepositoryId $contentRepositoryId, + private readonly ContentGraphReadModelInterface $contentGraphReadModel, private readonly AssetUsageIndexingService $assetUsageIndexingService ) { } @@ -47,16 +49,13 @@ public function onBeforeCatchUp(): void public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { - // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance instanceof EmbedsWorkspaceName) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + // Skip if the workspace does not exist: "The source workspace missing does not exist" https://github.com/neos/neos-development-collection/pull/5270 + $this->contentGraphReadModel->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { return; } - if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { - return; - } } match ($eventInstance::class) { @@ -68,16 +67,13 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if ($eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId) { - // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current content stream. + if ($eventInstance instanceof EmbedsWorkspaceName) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + // Skip if the workspace does not exist: "The source workspace missing does not exist" https://github.com/neos/neos-development-collection/pull/5270 + $this->contentGraphReadModel->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { return; } - if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { - return; - } } match ($eventInstance::class) { @@ -103,7 +99,7 @@ public function onAfterCatchUp(): void private function updateNode(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, DimensionSpacePoint $dimensionSpacePoint): void { - $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + $contentGraph = $this->contentGraphReadModel->getContentGraph($workspaceName); $node = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())->findNodeById($nodeAggregateId); if ($node === null) { @@ -112,14 +108,14 @@ private function updateNode(WorkspaceName $workspaceName, NodeAggregateId $nodeA } $this->assetUsageIndexingService->updateIndex( - $this->contentRepository->id, + $this->contentRepositoryId, $node ); } private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $dimensionSpacePoints): void { - $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + $contentGraph = $this->contentGraphReadModel->getContentGraph($workspaceName); foreach ($dimensionSpacePoints as $dimensionSpacePoint) { $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); @@ -131,7 +127,7 @@ private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $node /** @var Node $node */ foreach ($nodes as $node) { $this->assetUsageIndexingService->removeIndexForNode( - $this->contentRepository->id, + $this->contentRepositoryId, $node ); } @@ -140,14 +136,18 @@ private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $node private function discardWorkspace(WorkspaceName $workspaceName): void { - $this->assetUsageIndexingService->removeIndexForWorkspace($this->contentRepository->id, $workspaceName); + $this->assetUsageIndexingService->removeIndexForWorkspace($this->contentRepositoryId, $workspaceName); } 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, + $this->contentRepositoryId, $workspaceName, $nodeId->nodeAggregateId, $nodeId->dimensionSpacePoint @@ -157,6 +157,6 @@ private function discardNodes(WorkspaceName $workspaceName, NodeIdsToPublishOrDi private function updateDimensionSpacePoint(WorkspaceName $workspaceName, DimensionSpacePoint $source, DimensionSpacePoint $target): void { - $this->assetUsageIndexingService->updateDimensionSpacePointInIndex($this->contentRepository->id, $workspaceName, $source, $target); + $this->assetUsageIndexingService->updateDimensionSpacePointInIndex($this->contentRepositoryId, $workspaceName, $source, $target); } } diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php index 89bcec32e86..4188a10072f 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php @@ -14,10 +14,14 @@ * source code. */ -use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\Neos\AssetUsage\Service\AssetUsageIndexingService; +/** + * @implements CatchUpHookFactoryInterface + */ class AssetUsageCatchUpHookFactory implements CatchUpHookFactoryInterface { public function __construct( @@ -25,10 +29,11 @@ public function __construct( ) { } - public function build(ContentRepository $contentRepository): AssetUsageCatchUpHook + public function build(CatchUpHookFactoryDependencies $dependencies): AssetUsageCatchUpHook { return new AssetUsageCatchUpHook( - $contentRepository, + $dependencies->contentRepositoryId, + $dependencies->projectionState, $this->assetUsageIndexingService ); } diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php index b3dde8dd17a..a49c301a24d 100644 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -273,7 +273,7 @@ private function getAssetIdsByProperty(NodeType $nodeType, PropertyCollection $p $propertyType, $propertyValues->offsetGet($propertyName), ); - } catch (\Throwable) { + } catch (\Exception) { $extractedAssetIds = []; // We can't deserialize the property, so skip. } diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php index ed2ab33e522..d94cbeac1c9 100644 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ b/Neos.Neos/Classes/Command/CrCommandController.php @@ -165,8 +165,7 @@ public function pruneCommand(string $contentRepository = 'default', bool $force $this->workspaceService->pruneWorkspaceMetadata($contentRepositoryId); // reset the events table - $contentStreamPruner->pruneAll(); - $workspaceMaintenanceService->pruneAll(); + $contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); // reset the projections state $projectionService->resetAllProjections(); diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 2fdd87931c2..ddd62e7a8b3 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -396,22 +396,15 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $this->quit(3); } - - try { - $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); - } catch (\Exception $exception) { - $this->outputLine('Could not fetch unpublished nodes for workspace %s, nothing was deleted. %s', [$workspaceName->value, $exception->getMessage()]); - $this->quit(4); - } - - if ($nodesCount > 0) { + if ($crWorkspace->hasPublishableChanges()) { if ($force === false) { + $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); $this->outputLine( 'Did not delete workspace "%s" because it contains %s unpublished node(s).' - . ' Use --force to delete it nevertheless.', + . ' Use --force to delete it nevertheless.', [$workspaceName->value, $nodesCount] ); - $this->quit(5); + $this->quit(4); } $this->workspacePublishingService->discardAllWorkspaceChanges($contentRepositoryId, $workspaceName); } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php index 31836d383d2..dea005af9e1 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php @@ -16,6 +16,7 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\WorkspaceIsNotEmptyException; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; @@ -26,9 +27,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; @@ -76,7 +79,8 @@ public function countPendingWorkspaceChanges(ContentRepositoryId $contentReposit } /** - * @throws WorkspaceDoesNotExist | WorkspaceRebaseFailed + * @throws WorkspaceRebaseFailed is thrown if there are conflicts and the rebase strategy was {@see RebaseErrorHandlingStrategy::STRATEGY_FAIL} + * The workspace will be unchanged for this case. */ public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void { @@ -84,6 +88,10 @@ public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, Worksp $this->contentRepositoryRegistry->get($contentRepositoryId)->handle($rebaseCommand); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -96,6 +104,10 @@ public function publishWorkspace(ContentRepositoryId $contentRepositoryId, Works return new PublishingResult($numberOfPendingChanges, $crWorkspace->baseWorkspaceName); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -126,6 +138,10 @@ public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, W ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ public function publishChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): PublishingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -168,6 +184,10 @@ public function discardAllWorkspaceChanges(ContentRepositoryId $contentRepositor return new DiscardingResult($numberOfChangesToBeDiscarded); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): DiscardingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -194,6 +214,10 @@ public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, W ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ public function discardChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): DiscardingResult { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -220,6 +244,9 @@ public function discardChangesInDocument(ContentRepositoryId $contentRepositoryI ); } + /** + * @throws WorkspaceIsNotEmptyException in case a switch is attempted while the workspace still has pending changes + */ public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $newBaseWorkspaceName): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -232,19 +259,15 @@ public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, Wo ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be discarded for this case. + */ private function discardNodes( ContentRepository $contentRepository, WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIdsToDiscard ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? - */ - $contentRepository->handle( - RebaseWorkspace::create($workspaceName) - ); - $contentRepository->handle( DiscardIndividualNodesFromWorkspace::create( $workspaceName, @@ -253,19 +276,15 @@ private function discardNodes( ); } + /** + * @throws WorkspaceRebaseFailed is thrown if the workspace was outdated and an automatic rebase failed due to conflicts. + * No changes would be published for this case. + */ private function publishNodes( ContentRepository $contentRepository, WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIdsToPublish ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handlePublishIndividualNodesFromWorkspace ? - */ - $contentRepository->handle( - RebaseWorkspace::create($workspaceName) - ); - $contentRepository->handle( PublishIndividualNodesFromWorkspace::create( $workspaceName, @@ -339,7 +358,7 @@ private function resolveNodeIdsToPublishOrDiscard( $nodeIdsToPublishOrDiscard[] = new NodeIdToPublishOrDiscard( $change->nodeAggregateId, - $change->originDimensionSpacePoint->toDimensionSpacePoint() + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ); } @@ -358,7 +377,6 @@ private function countPendingWorkspaceChangesInternal(ContentRepository $content return $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId); } - private function isChangePublishableWithinAncestorScope( ContentRepository $contentRepository, WorkspaceName $workspaceName, @@ -374,20 +392,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/FrontendRouting/CatchUpHook/RouterCacheHook.php b/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHook.php index b6529d5be4c..1dc70d4fac7 100644 --- a/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHook.php +++ b/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHook.php @@ -4,7 +4,6 @@ namespace Neos\Neos\FrontendRouting\CatchUpHook; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\Feature\NodeModification\Event\NodePropertiesWereSet; @@ -28,7 +27,7 @@ final class RouterCacheHook implements CatchUpHookInterface private array $tagsToFlush = []; public function __construct( - private readonly ContentRepository $contentRepository, + private readonly DocumentUriPathFinder $documentUriPathFinder, private readonly RouterCachingService $routerCachingService, ) { } @@ -85,7 +84,7 @@ private function onBeforeSubtreeWasTagged(SubtreeWasTagged $event): void $this->collectTagsToFlush($node); - $descendantsOfNode = $this->getState()->getDescendantsOfNode($node); + $descendantsOfNode = $this->documentUriPathFinder->getDescendantsOfNode($node); array_map($this->collectTagsToFlush(...), iterator_to_array($descendantsOfNode)); } } @@ -105,7 +104,7 @@ private function onBeforeNodeAggregateWasRemoved(NodeAggregateWasRemoved $event) $this->collectTagsToFlush($node); - $descendantsOfNode = $this->getState()->getDescendantsOfNode($node); + $descendantsOfNode = $this->documentUriPathFinder->getDescendantsOfNode($node); array_map($this->collectTagsToFlush(...), iterator_to_array($descendantsOfNode)); } } @@ -130,7 +129,7 @@ private function onBeforeNodePropertiesWereSet(NodePropertiesWereSet $event): vo $this->collectTagsToFlush($node); - $descendantsOfNode = $this->getState()->getDescendantsOfNode($node); + $descendantsOfNode = $this->documentUriPathFinder->getDescendantsOfNode($node); array_map($this->collectTagsToFlush(...), iterator_to_array($descendantsOfNode)); } } @@ -153,7 +152,7 @@ private function onBeforeNodeAggregateWasMoved(NodeAggregateWasMoved $event): vo $this->collectTagsToFlush($node); - $descendantsOfNode = $this->getState()->getDescendantsOfNode($node); + $descendantsOfNode = $this->documentUriPathFinder->getDescendantsOfNode($node); array_map($this->collectTagsToFlush(...), iterator_to_array($descendantsOfNode)); } } @@ -173,15 +172,10 @@ private function flushAllCollectedTags(): void $this->tagsToFlush = []; } - private function getState(): DocumentUriPathFinder - { - return $this->contentRepository->projectionState(DocumentUriPathFinder::class); - } - private function findDocumentNodeInfoByIdAndDimensionSpacePoint(NodeAggregateId $nodeAggregateId, DimensionSpacePoint $dimensionSpacePoint): ?DocumentNodeInfo { try { - return $this->getState()->getByIdAndDimensionSpacePointHash( + return $this->documentUriPathFinder->getByIdAndDimensionSpacePointHash( $nodeAggregateId, $dimensionSpacePoint->hash ); diff --git a/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHookFactory.php b/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHookFactory.php index 15b92e86c39..cbdd469d930 100644 --- a/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHookFactory.php +++ b/Neos.Neos/Classes/FrontendRouting/CatchUpHook/RouterCacheHookFactory.php @@ -4,12 +4,15 @@ namespace Neos\Neos\FrontendRouting\CatchUpHook; -use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; use Neos\Flow\Mvc\Routing\RouterCachingService; -use Neos\RedirectHandler\NeosAdapter\Service\NodeRedirectService; +use Neos\Neos\FrontendRouting\Projection\DocumentUriPathFinder; +/** + * @implements CatchUpHookFactoryInterface + */ final class RouterCacheHookFactory implements CatchUpHookFactoryInterface { public function __construct( @@ -17,10 +20,10 @@ public function __construct( ) { } - public function build(ContentRepository $contentRepository): CatchUpHookInterface + public function build(CatchUpHookFactoryDependencies $dependencies): CatchUpHookInterface { return new RouterCacheHook( - $contentRepository, + $dependencies->projectionState, $this->routerCachingService ); } diff --git a/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php b/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php index 6c5b5d7a4fb..de859059ae0 100644 --- a/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php +++ b/Neos.Neos/Classes/FrontendRouting/Projection/DocumentUriPathProjection.php @@ -108,10 +108,7 @@ private function determineRequiredSqlStatements(): array $schemaManager = $this->dbal->createSchemaManager(); $schema = (new DocumentUriPathSchemaBuilder($this->tableNamePrefix))->buildSchema($schemaManager); $statements = DbalSchemaDiff::determineRequiredSqlStatements($this->dbal, $schema); - // MIGRATIONS - if ($this->dbal->getSchemaManager()->tablesExist([$this->tableNamePrefix . '_livecontentstreams'])) { - $statements[] = sprintf('DROP table %s_livecontentstreams;', $this->tableNamePrefix); - } + return $statements; } diff --git a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php index 868bf28dc7f..4acf758411e 100644 --- a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php +++ b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php @@ -14,7 +14,6 @@ * source code. */ -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\EventStore\EventInterface; use Neos\ContentRepository\Core\Feature\Common\EmbedsNodeAggregateId; use Neos\ContentRepository\Core\Feature\Common\EmbedsWorkspaceName; @@ -36,7 +35,9 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Event\WorkspaceWasPartiallyDiscarded; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -105,7 +106,8 @@ public static function disabled(\Closure $fn): void public function __construct( - private readonly ContentRepository $contentRepository, + private readonly ContentRepositoryId $contentRepositoryId, + private readonly ContentGraphReadModelInterface $contentGraphReadModel, private readonly ContentCacheFlusher $contentCacheFlusher ) { } @@ -157,7 +159,7 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even || $eventInstance instanceof NodeAggregateWasMoved ) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->workspaceName); + $contentGraph = $this->contentGraphReadModel->getContentGraph($eventInstance->workspaceName); } catch (WorkspaceDoesNotExist) { return; } @@ -166,7 +168,7 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even ); if ($nodeAggregate) { $this->scheduleCacheFlushJobForNodeAggregate( - $this->contentRepository, + $this->contentRepositoryId, $nodeAggregate, $contentGraph->findAncestorNodeAggregateIds($eventInstance->getNodeAggregateId()), ); @@ -191,14 +193,14 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event || $eventInstance instanceof WorkspaceWasPartiallyDiscarded || $eventInstance instanceof WorkspaceWasRebased ) { - $this->scheduleCacheFlushJobForWorkspaceName($this->contentRepository, $eventInstance->workspaceName); + $this->scheduleCacheFlushJobForWorkspaceName($this->contentRepositoryId, $eventInstance->workspaceName); } elseif ( !($eventInstance instanceof NodeAggregateWasRemoved) && $eventInstance instanceof EmbedsNodeAggregateId && $eventInstance instanceof EmbedsWorkspaceName ) { try { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + $contentGraph = $this->contentGraphReadModel->getContentGraph($eventInstance->getWorkspaceName()); } catch (WorkspaceDoesNotExist) { return; } @@ -208,7 +210,7 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event if ($nodeAggregate) { $this->scheduleCacheFlushJobForNodeAggregate( - $this->contentRepository, + $this->contentRepositoryId, $nodeAggregate, $contentGraph->findAncestorNodeAggregateIds($eventInstance->getNodeAggregateId()) ); @@ -217,13 +219,13 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event } private function scheduleCacheFlushJobForNodeAggregate( - ContentRepository $contentRepository, + ContentRepositoryId $contentRepositoryId, NodeAggregate $nodeAggregate, NodeAggregateIds $ancestorNodeAggregateIds ): void { // we store this in an associative array deduplicate. $this->flushNodeAggregateRequestsOnAfterCatchUp[$nodeAggregate->workspaceName->value . '__' . $nodeAggregate->nodeAggregateId->value] = FlushNodeAggregateRequest::create( - $contentRepository->id, + $contentRepositoryId, $nodeAggregate->workspaceName, $nodeAggregate->nodeAggregateId, $nodeAggregate->nodeTypeName, @@ -232,12 +234,12 @@ private function scheduleCacheFlushJobForNodeAggregate( } private function scheduleCacheFlushJobForWorkspaceName( - ContentRepository $contentRepository, + ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName ): void { // we store this in an associative array deduplicate. $this->flushWorkspaceRequestsOnAfterCatchUp[$workspaceName->value] = FlushWorkspaceRequest::create( - $contentRepository->id, + $contentRepositoryId, $workspaceName, ); } diff --git a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushingFactory.php b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushingFactory.php index 62e8c499008..a988f1bcac6 100644 --- a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushingFactory.php +++ b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushingFactory.php @@ -14,9 +14,13 @@ * source code. */ -use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryDependencies; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; +/** + * @implements CatchUpHookFactoryInterface + */ class GraphProjectorCatchUpHookForCacheFlushingFactory implements CatchUpHookFactoryInterface { public function __construct( @@ -24,10 +28,11 @@ public function __construct( ) { } - public function build(ContentRepository $contentRepository): GraphProjectorCatchUpHookForCacheFlushing + public function build(CatchUpHookFactoryDependencies $dependencies): GraphProjectorCatchUpHookForCacheFlushing { return new GraphProjectorCatchUpHookForCacheFlushing( - $contentRepository, + $dependencies->contentRepositoryId, + $dependencies->projectionState, $this->contentCacheFlusher ); } 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..4f91f39cea2 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([ @@ -140,10 +142,7 @@ private function determineRequiredSqlStatements(): array $schema = DbalSchemaFactory::createSchemaWithTables($schemaManager, [$changeTable]); $statements = DbalSchemaDiff::determineRequiredSqlStatements($connection, $schema); - // MIGRATIONS - if ($this->dbal->getSchemaManager()->tablesExist([$this->tableNamePrefix . '_livecontentstreams'])) { - $statements[] = sprintf('DROP table %s_livecontentstreams;', $this->tableNamePrefix); - } + return $statements; } @@ -167,7 +166,9 @@ public function canHandle(EventInterface $event): bool DimensionSpacePointWasMoved::class, NodeGeneralizationVariantWasCreated::class, NodeSpecializationVariantWasCreated::class, - NodePeerVariantWasCreated::class + NodePeerVariantWasCreated::class, + NodeAggregateTypeWasChanged::class, + NodeAggregateNameWasChanged::class, ]); } @@ -185,6 +186,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 +407,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 +444,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 +506,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 +543,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.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index eb89f8809ff..beaa86ac5dc 100644 --- a/Neos.Neos/Documentation/References/CommandReference.rst +++ b/Neos.Neos/Documentation/References/CommandReference.rst @@ -19,7 +19,7 @@ commands that may be available, use:: ./flow help -The following reference was automatically generated from code on 2024-10-23 +The following reference was automatically generated from code on 2024-11-04 .. _`Neos Command Reference: NEOS.FLOW`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index 002a4c1613e..ab86c36bdb5 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst @@ -3,7 +3,7 @@ FluidAdaptor ViewHelper Reference ################################# -This reference was automatically generated from code on 2024-10-23 +This reference was automatically generated from code on 2024-11-04 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index d8b8dd14893..f4cb783d1b7 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst @@ -3,7 +3,7 @@ Form ViewHelper Reference ######################### -This reference was automatically generated from code on 2024-10-23 +This reference was automatically generated from code on 2024-11-04 .. _`Form ViewHelper Reference: neos.form:form`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst index d7839a056be..aa9c60fdc2d 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Media.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Media.rst @@ -3,7 +3,7 @@ Media ViewHelper Reference ########################## -This reference was automatically generated from code on 2024-10-23 +This reference was automatically generated from code on 2024-11-04 .. _`Media ViewHelper Reference: neos.media:fileTypeIcon`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst index eb731ca543d..7f6f36a8242 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst @@ -3,7 +3,7 @@ Neos ViewHelper Reference ######################### -This reference was automatically generated from code on 2024-10-23 +This reference was automatically generated from code on 2024-11-04 .. _`Neos ViewHelper Reference: neos:backend.authenticationProviderLabel`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst index c3580612309..2e67bf7f29c 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst @@ -3,7 +3,7 @@ TYPO3 Fluid ViewHelper Reference ################################ -This reference was automatically generated from code on 2024-10-23 +This reference was automatically generated from code on 2024-11-04 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature index d7c95053274..3d56189288c 100644 --- a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature @@ -65,7 +65,6 @@ Feature: Publish nodes partially without dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -114,7 +113,6 @@ Feature: Publish nodes partially without dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature index 1d039f580be..932602a17dd 100644 --- a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature @@ -71,7 +71,6 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -138,7 +137,6 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | Then I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | @@ -184,11 +182,10 @@ Feature: Publish nodes partially with dimensions | Key | Value | | nodesToPublish | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"},{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "en"}, "nodeAggregateId": "sir-david-nodenborough"}] | | contentStreamIdForRemainingPart | "user-cs-identifier-remaining" | - | contentStreamIdForMatchingPart | "user-cs-identifier-matching" | And I expect the AssetUsageService to have the following AssetUsages: | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | | asset-1 | sir-david-nodenborough | asset | live | {"language": "en"} | | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | - | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | \ No newline at end of file + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature new file mode 100644 index 00000000000..6c82bbeacbb --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/PartialPublish.feature @@ -0,0 +1,68 @@ +@flowEntities @contentrepository +Feature: Test cases for partial publish to live and uri path generation + + Scenario: Create Document in another workspace and partially publish to live + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | example | source,peer,peerSpec | peerSpec->peer | + And using the following node types: + """yaml + 'Neos.Neos:Sites': + superTypes: + 'Neos.ContentRepository:Root': true + 'Neos.Neos:Document': + properties: + uriPathSegment: + type: string + 'Neos.Neos:Site': + superTypes: + 'Neos.Neos:Document': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {"example":"source"} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.Neos:Sites" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "shernode-homes" | + | nodeTypeName | "Neos.Neos:Site" | + | parentNodeAggregateId | "lady-eleonode-rootford" | + | originDimensionSpacePoint | {"example":"source"} | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | baseWorkspaceName | "live" | + | workspaceTitle | "My Personal Workspace" | + | workspaceDescription | "" | + | newContentStreamId | "cs-myworkspace" | + And the command CreateNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "justsomepage" | + | nodeTypeName | "Neos.Neos:Document" | + | parentNodeAggregateId | "shernode-homes" | + | originDimensionSpacePoint | {"example":"source"} | + | properties | {"uriPathSegment": "just"}| + | workspaceName | "myworkspace" | + And the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "myworkspace" | + | nodesToPublish | [{"nodeAggregateId": "justsomepage", "dimensionSpacePoint": {"example":"source"}}] | + + Then I expect the documenturipath table to contain exactly: + # source: 65901ded4f068dac14ad0dce4f459b29 + # spec: 9a723c057afa02982dae9d0b541739be + # leafSpec: c60c44685475d0e2e4f2b964e6158ce2 + | dimensionspacepointhash | uripath | nodeaggregateidpath | nodeaggregateid | parentnodeaggregateid | precedingnodeaggregateid | succeedingnodeaggregateid | nodetypename | + | "2ca4fae2f65267c94c85602df0cbb728" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "fbe53ddc3305685fbb4dbf529f283a0e" | "" | "lady-eleonode-rootford" | "lady-eleonode-rootford" | null | null | null | "Neos.Neos:Sites" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes" | "shernode-homes" | "lady-eleonode-rootford" | null | null | "Neos.Neos:Site" | + | "65901ded4f068dac14ad0dce4f459b29" | "" | "lady-eleonode-rootford/shernode-homes/justsomepage" | "justsomepage" | "shernode-homes" | null | null | "Neos.Neos:Document" | diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index b6f7b4553a5..dc1c0aa5bb0 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; @@ -26,11 +27,11 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Diff\Diff; @@ -134,6 +135,9 @@ public function indexAction(): void $items = []; $allWorkspaces = $contentRepository->findWorkspaces(); foreach ($allWorkspaces as $workspace) { + if ($workspace->isRootWorkspace()) { + continue; + } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); if (!$permissions->read) { @@ -144,7 +148,7 @@ public function indexAction(): void classification: $workspaceMetadata->classification->name, title: $workspaceMetadata->title->value, description: $workspaceMetadata->description->value, - baseWorkspaceName: $workspace->baseWorkspaceName?->value, + baseWorkspaceName: $workspace->baseWorkspaceName->value, pendingChanges: $this->computePendingChanges($workspace, $contentRepository), hasDependantWorkspaces: !$allWorkspaces->getDependantWorkspaces($workspace->workspaceName)->isEmpty(), permissions: $permissions, @@ -369,26 +373,8 @@ public function deleteAction(WorkspaceName $workspaceName): void $this->redirect('index'); } - $nodesCount = 0; - - try { - $nodesCount = $contentRepository->projectionState(ChangeFinder::class) - ->countByContentStreamId( - $workspace->currentContentStreamId - ); - } catch (\Exception $exception) { - $message = $this->translator->translateById( - 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes', - [$workspaceMetadata->title->value], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes'; - $this->addFlashMessage($message, '', Message::SEVERITY_WARNING); - $this->redirect('index'); - } - if ($nodesCount > 0) { + if ($workspace->hasPublishableChanges()) { + $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); $message = $this->translator->translateById( 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes', [$workspaceMetadata->title->value, $nodesCount], @@ -684,20 +670,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 +754,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 +871,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) diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf index 23abe452d01..69c51c14e80 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf @@ -3,11 +3,11 @@ - Back + Back رجوع - Cancel + Cancel إلغاء @@ -20,187 +20,187 @@ - Workspaces + Workspaces مساحات العمل - Details for "{0}" + Details for "{0}" تفاصيل عن "{0}" - Create new workspace + Create new workspace إنشاء فضاء عمل جديد - Create workspace + Create workspace إنشاء فضاء العمل - Delete workspace + Delete workspace حذف فضاء العمل - Edit workspace + Edit workspace تعديل مجال العمل - Yes, delete the workspace + Yes, delete the workspace نعم، إحذف فضاء العمل - Edit workspace "{0}" + Edit workspace "{0}" تعديل مجال العمل - Personal workspace + Personal workspace فضاء العمل الشخصي - Private workspace + Private workspace مجال العمل الخاص - Internal workspace + Internal workspace فضاء العمل الداخلي - Read-only workspace + Read-only workspace فضاء العمل خاص بالقراءة فقط - Title + Title العنوان - Description + Description الوصف - Base workspace + Base workspace مجال العمل الأساسي - Owner + Owner المالك - Visibility + Visibility الظهور - Private + Private خاص - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace فضاء العمل هذا لا يمكن الوصول إليه وتعديله إلا من طرف المراجعين والمسؤولين فقط - Internal + Internal داخلي - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. يمكن لأي محرر تسجيل الدخول رؤية وتعديل فضاء العمل هذا. - Changes + Changes التغييرات - Open page in "{0}" workspace + Open page in "{0}" workspace فتح الصفحة في فضاء العمل "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. هذا هو فضاء العمل الخاص بك الذي لا يمكنك حذفه. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. يحتوي فضاء العمل على مجموعة من التغييرات. للحذف، يرجى تجاهل التغييرات أولاً. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. لا يمكن إعادة إنشاء هذا فضاء العمل على أساس فضاء عمل مختلف لأنه لا يزال يحتوي على التغييرات. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. لا يمكن حذف فضاء العمل لأن مجالات عمل أخرى تعتمد عليه. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. ليس لديك الصلاحية لحذف هذا الفضاء العمل. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? هل تريد حقاً حذف فضاء العمل "{0}"؟ - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. سيؤدي هذا إلى حذف فضاء العمل بما في ذلك كل شيء غير منشور. هذه العملية لا يمكن التراجع عنها. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. هذه الوحدة النمطية يحتوي على نظرة عامة على جميع العناصر في فضاء العمل الحالية، وأنه يمكن مواصلة استعراض ونشر سير العمل لهم. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" التغييرات غير المنشورة في فضاء العمل "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} الإضافات: {new}، التغييرات: {changed}، عمليات الإزالة: {removed} - Review + Review المراجعة - Discard selected changes + Discard selected changes تجاهل التغييرات المحددة - Discard all changes + Discard all changes تجاهل كل التغييرات - Publish all changes + Publish all changes انشر كل التغييرات - Publish all changes to {0} + Publish all changes to {0} نشر كافة التغييرات إلى {0} - Changed Content + Changed Content المحتوى المتغير - deleted + deleted حذف - created + created أنشأت - moved + moved نقل - hidden + hidden مخفي - edited + edited حررت - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. ليس هناك أية تغييرات غير منشورة في فضاء العمل هذا. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? هل تريد حقاً أن تتجاهل كافة التغييرات في فضاء العمل "{0}"؟ @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} لا يمكن حذف فضاء عمل "{0}" لأن فضاء العمل التالي يستند إليه: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - حدث خطأ أثناء إحضار العقد الغير منشورة من مساحة العمل "{0}"، لم يتم حذف أي شيء. - Your personal workspace contains changes, please publish or discard them first. فضاء العمل الشخصي الخاص بك يحتوي على تغييرات، يرجى نشرها أو تجاهلها أولاً. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf index cddedf52426..4f0d177bfb2 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Back - Cancel + Cancel Zrušit @@ -20,187 +20,187 @@ - Workspaces + Workspaces Workspaces - Details for "{0}" + Details for "{0}" Details for "{0}" - Create new workspace + Create new workspace Create new workspace - Create workspace + Create workspace Create workspace - Delete workspace + Delete workspace Delete workspace - Edit workspace + Edit workspace Edit workspace - Yes, delete the workspace + Yes, delete the workspace Yes, delete the workspace - Edit workspace "{0}" + Edit workspace "{0}" Edit workspace - Personal workspace + Personal workspace Personal workspace - Private workspace + Private workspace Private workspace - Internal workspace + Internal workspace Internal workspace - Read-only workspace + Read-only workspace Read-only workspace - Title + Title Titul - Description + Description Popis - Base workspace + Base workspace Base workspace - Owner + Owner Owner - Visibility + Visibility Viditelnost - Private + Private Private - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - Internal + Internal Internal - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes Changes - Open page in "{0}" workspace + Open page in "{0}" workspace Open page in "{0}" workspace - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. This is your personal workspace which you can't delete. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review Review - Discard selected changes + Discard selected changes Discard selected changes - Discard all changes + Discard all changes Discard all changes - Publish all changes + Publish all changes Publikovat všechny změny - Publish all changes to {0} + Publish all changes to {0} Publish all changes to {0} - Changed Content + Changed Content Changed Content - deleted + deleted deleted - created + created created - moved + moved moved - hidden + hidden hidden - edited + edited edited - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. There are no unpublished changes in this workspace. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Do you really want to discard all changes in the "{0}" workspace? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf index 2f59152a56c..fcd1ef9604e 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Tilbage - Cancel + Cancel Annullér @@ -20,187 +20,187 @@ - Workspaces + Workspaces Arbejdsrum - Details for "{0}" + Details for "{0}" Detaljer for "{0}" - Create new workspace + Create new workspace Opret nyt arbejdsrum - Create workspace + Create workspace Opret arbejdsrum - Delete workspace + Delete workspace Slet arbejdsrum - Edit workspace + Edit workspace Redigér arbejdsrum - Yes, delete the workspace + Yes, delete the workspace Ja, slette arbejdsrummet - Edit workspace "{0}" + Edit workspace "{0}" Redigér arbejdsrum - Personal workspace + Personal workspace Personligt arbejdsrum - Private workspace + Private workspace Privat arbejdsrum - Internal workspace + Internal workspace Internt arbejdsrum - Read-only workspace + Read-only workspace Skrivebeskyttet arbejdsrum - Title + Title Titel - Description + Description Beskrivelse - Base workspace + Base workspace Basisarbejdsrum - Owner + Owner Ejer - Visibility + Visibility Synlighed - Private + Private Privat - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Kun korrekturlæsere og administratorer kan få adgang til og ændre dette arbejdsrum - Internal + Internal Internt - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Enhver redaktør som er logget ind kan se og ændre dette arbejdsrum. - Changes + Changes Ændringer - Open page in "{0}" workspace + Open page in "{0}" workspace Åbn side i arbejdsrummet "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Dette er dit personlige arbejdsrum, som du ikke kan slette. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Arbejdsrummet indeholder ændringer. Hvis du vil slette, slet ændringerne først. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Arbejdsrummet kan ikke sammenflettes med et andet arbejdsrum, fordi den stadig indeholder ændringer. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Arbejdsrummet kan ikke slettes, fordi andre arbejdsrum er afhængige af det. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Du har ikke tilladelse til at slette dette arbejdsrum. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Vil du slette arbejdsrummet "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Dette vil slette arbejdsrummet inklusiv alle ikke-publicerede ændringer. Denne handling kan ikke fortrydes. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Dette modul indeholder en oversigt over alle elementer i det aktuelle arbejdsrum, og det gør det muligt at fortsætte korrektur- og udgivelsesarbejdsgangen for dem. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Ikke publiceret ændringer - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} tilføjelser: {new}, ændringer: {changed}, flyttede: {removed} - Review + Review Gennemgå - Discard selected changes + Discard selected changes Kassér valgte ændringer - Discard all changes + Discard all changes Kassér alle ændringer - Publish all changes + Publish all changes Publicér alle ændringer - Publish all changes to {0} + Publish all changes to {0} Publicér alle ændringer til {0} - Changed Content + Changed Content Ændret indhold - deleted + deleted slettet - created + created oprettet - moved + moved flytted - hidden + hidden skjult - edited + edited redigeret - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Der er ingen upublicerede ændringer i dette arbejdsrum. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Er du sikker på du virkelig vil slette alle ændringer i "{0}" arbejdsrum? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Arbejdsrummet "{0}" kan ikke slettes, fordi de følgende arbejdsrum er baseret på det: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Der opstod en fejl under hentning upublicerede indhold fra arbejdsrummet "{0}", intet blev slettet. - Your personal workspace contains changes, please publish or discard them first. Dit personlige arbejdsrum indeholder ændringer, venligst publicér eller kassér dem først. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf index 3adbcd76135..2b56aa46d05 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Zurück - Cancel + Cancel Abbrechen @@ -20,187 +20,187 @@ - Workspaces + Workspaces Arbeitsbereiche - Details for "{0}" + Details for "{0}" Details für "{0}" - Create new workspace + Create new workspace Neuen Arbeitsbereich erstellen - Create workspace + Create workspace Arbeitsbereich erstellen - Delete workspace + Delete workspace Arbeitsbereich löschen - Edit workspace + Edit workspace Arbeitsbereich bearbeiten - Yes, delete the workspace + Yes, delete the workspace Ja, den Arbeitsbereich löschen - Edit workspace "{0}" + Edit workspace "{0}" Arbeitsbereich "{0}" bearbeiten - Personal workspace + Personal workspace Persönlicher Arbeitsbereich - Private workspace + Private workspace Privater Arbeitsbereich - Internal workspace + Internal workspace Interner Arbeitsbereich - Read-only workspace + Read-only workspace Nur-Lese Arbeitsbereich - Title + Title Titel - Description + Description Beschreibung - Base workspace + Base workspace Basis-Arbeitsbereich - Owner + Owner Besitzer - Visibility + Visibility Sichtbarkeit - Private + Private Privat - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Nur Reviewer und Administratoren können auf diesen Arbeitsbereich zugreifen und ihn bearbeiten - Internal + Internal Intern - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Jeder angemeldete Redakteur kann diesen Arbeitsbereich sehen und bearbeiten. - Changes + Changes Änderungen - Open page in "{0}" workspace + Open page in "{0}" workspace Seite in Arbeitsbereich "{0}" öffnen - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Dies ist Ihr persönlicher Arbeitsbereich, welcher nicht gelöscht werden kann. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Der Arbeitsbereich enthält Änderungen. Um ihn zu löschen, müssen die Änderungen zuerst verworfen werden. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Der Arbeitsbereich kann nicht auf einen anderen Basis-Arbeitsbereich umgestellt werden, da er noch Änderungen enthält. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Der Arbeitsbereich kann nicht gelöscht werden, da andere Arbeitsbereiche auf ihm basieren. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Sie haben nicht die Berechtigung diesen Arbeitsbereich zu löschen. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Wollen Sie wirklich den Arbeitsbereich "{0}" löschen? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Dadurch wird der Arbeitsbereich einschließlich aller unveröffentlichten Inhalte gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Dieses Modul bietet einen Überblick aller geänderter Inhalte des aktuellen Workspaces und ermöglicht deren Kontrolle und Veröffentlichung. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unveröffentlichte Änderungen im Arbeitsbereich "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} Ergänzungen: {new}, Änderungen: {changed}, Entfernungen: {removed} - Review + Review Überprüfen - Discard selected changes + Discard selected changes Ausgewählte Änderungen verwerfen - Publish selected changes to {0} + Publish selected changes to {0} Ausgewählte Änderungen nach {0} veröffentlichen - Discard all changes + Discard all changes Alle Änderungen verwerfen - Publish all changes + Publish all changes Alle Änderungen veröffentlichen - Publish all changes to {0} + Publish all changes to {0} Alle Änderungen nach {0} veröffentlichen - Changed Content + Changed Content Geänderter Inhalt - deleted + deleted gelöscht - created + created erstellt - moved + moved erstellt - hidden + hidden erstellt - edited + edited erstellt - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. In diesem Arbeitsbereich gibt es keine unveröffentlichten Änderungen. @@ -223,10 +223,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Arbeitsbereich "{0}" kann nicht gelöscht werden, da die folgenden Arbeitsbereiche auf ihm basieren: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Fehler beim Abrufen der unveröffentlichten Elemente des Arbeitsbereichs "{0}", es wurde nichts gelöscht. - Your personal workspace contains changes, please publish or discard them first. Ihr persönlicher Arbeitsbereich enthält Änderungen, bitte veröffentlichen oder verwerfen Sie diese zuerst. @@ -234,10 +230,12 @@ Did not delete workspace "{0}" because it currently contains {1} node. - Arbeitsbereich "{0}" wurde nicht gelöscht, da er derzeit {1} Element enthält. + Arbeitsbereich "{0}" wurde nicht gelöscht, da er derzeit {1} Element enthält. + Did not delete workspace "{0}" because it currently contains {1} nodes. - Arbeitsbereich "{0}" wurde nicht gelöscht, da er derzeit {1} Elemente enthält. + Arbeitsbereich "{0}" wurde nicht gelöscht, da er derzeit {1} Elemente enthält. + The workspace "{0}" has been removed. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf index cff87de3460..b52d42417cb 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Επιστροφή - Cancel + Cancel Άκυρο @@ -20,187 +20,187 @@ - Workspaces + Workspaces Workspaces - Details for "{0}" + Details for "{0}" Λεπτομέρειες για "{0}" - Create new workspace + Create new workspace Create new workspace - Create workspace + Create workspace Create workspace - Delete workspace + Delete workspace Delete workspace - Edit workspace + Edit workspace Edit workspace - Yes, delete the workspace + Yes, delete the workspace Yes, delete the workspace - Edit workspace "{0}" + Edit workspace "{0}" Edit workspace - Personal workspace + Personal workspace Personal workspace - Private workspace + Private workspace Private workspace - Internal workspace + Internal workspace Internal workspace - Read-only workspace + Read-only workspace Read-only workspace - Title + Title Τίτλος - Description + Description Description - Base workspace + Base workspace Base workspace - Owner + Owner Ιδιοκτήτης - Visibility + Visibility Ορατότητα - Private + Private Ιδιωτικό - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - Internal + Internal Εσωτερικό - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes Αλλαγές - Open page in "{0}" workspace + Open page in "{0}" workspace Open page in "{0}" workspace - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. This is your personal workspace which you can't delete. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review Επισκόπηση - Discard selected changes + Discard selected changes Discard selected changes - Discard all changes + Discard all changes Discard all changes - Publish all changes + Publish all changes Publish all changes - Publish all changes to {0} + Publish all changes to {0} Publish all changes to {0} - Changed Content + Changed Content Τροποποιημένο Περιεχόμενο - deleted + deleted διαγράφηκε - created + created δημιουργήθηκε - moved + moved μετακινήθηκε - hidden + hidden κρυφό - edited + edited διορθώθηκε - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. There are no unpublished changes in this workspace. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Do you really want to discard all changes in the "{0}" workspace? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf index da39fa6b61e..bd8b046baef 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf @@ -168,9 +168,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf index 49c41f42dd1..1447062369f 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Espacio de trabajo "{0}" no se puede eliminar porque las siguientes áreas de trabajo se basan en él: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Un error ha ocurrido mientras se buscaba los nodos no publicados del espacio de trabajo "{0}", nada ha sido eliminado. - Your personal workspace contains changes, please publish or discard them first. Su espacio de trabajo personal contiene cambios, por favor publíquelos o descártelos. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf index c46d815a421..4b686ba606f 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Takaisin - Cancel + Cancel Peruuta @@ -20,187 +20,187 @@ - Workspaces + Workspaces Työtilat - Details for "{0}" + Details for "{0}" "{0}":n tiedot - Create new workspace + Create new workspace Luo uusi työtila - Create workspace + Create workspace Luo työtila - Delete workspace + Delete workspace Poista työtila - Edit workspace + Edit workspace Muokkaa työtilaa - Yes, delete the workspace + Yes, delete the workspace Kyllä, poista työtila - Edit workspace "{0}" + Edit workspace "{0}" Muokkaa työtilaa - Personal workspace + Personal workspace Henkilökohtainen työtila - Private workspace + Private workspace Yksityinen työtila - Internal workspace + Internal workspace Sisäinen työtila - Read-only workspace + Read-only workspace Vain luku-työtila - Title + Title Otsikko - Description + Description Kuvaus - Base workspace + Base workspace Päätyötila - Owner + Owner Omistaja - Visibility + Visibility Näkyvyys - Private + Private Yksityinen - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Ainoastaan arvioijat ja pääkäyttäjät voivat käyttää ja muokata tätä työtilaa - Internal + Internal Sisäinen - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Kaikki sisäänkirjautuneet julkaisijat näkevät tämän työtilan ja voivat muokata sitä. - Changes + Changes Muutokset - Open page in "{0}" workspace + Open page in "{0}" workspace Avaa sivu "{0}" työtilassa - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Tämä on henkilökohtainen työtilasi, jota et voi poistaa. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Työtila sisältää muutoksia. Jos haluat poistaa sen, hylkää muutokset ensin. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Työtilaa ei voida asettaa pohjautumaan toiseen työtilaan, koska se sisältää muutoksia. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Työtilaa ei voida poistaa, koska on muita työtiloja jotka pohjautuvat siihen. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Sinulla ei ole riittävästi oikeuksia poistaa tätä työtilaa. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Haluatko todella poistaa työtilan "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Tämä poistaa työtilan ja kaiken sen julkaisemattoman sisällön. Tätä toimintoa ei voi peruuttaa. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Tämä moduuli sisältää näkymän kaikista elementeistä nykyisessä työtilassa ja mahdollistaa niiden julkaisuprosessin tarkastelun. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Julkaisematomat muutokset - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} lisäykset: {new}, muutokset: {changed}, poistot: {removed} - Review + Review Arviointi - Discard selected changes + Discard selected changes Hylkää valitut muutokset - Discard all changes + Discard all changes Hylkää kaikki muutokset - Publish all changes + Publish all changes Julkaise kaikki muutokset - Publish all changes to {0} + Publish all changes to {0} Julkaise kaikki muutokset kohteeseen {0} - Changed Content + Changed Content Muuttunut sisältö - deleted + deleted poistettu - created + created luotu - moved + moved luotu - hidden + hidden luotu - edited + edited luotu - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Tässä työtilassa ei ole julkaisemattomia muutoksia. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Halutako varmasti hylätä kaikki muutokset työtilassa "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Työtilaa "{0}" ei voida poistaa koska työtila "{1}" pohjautuu siihen - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Virhe haettassa julkaisemattomia muutoksia työtilalle "{0}"; mitään ei poistettu. - Your personal workspace contains changes, please publish or discard them first. Henkilökohtainen työtilasi sisältää muutoksia, julkaise tai hylkää ne ensin. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf index 3d2b787e92f..9bae769a3ab 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Retour - Cancel + Cancel Annuler @@ -20,187 +20,187 @@ - Workspaces + Workspaces Espaces de travail - Details for "{0}" + Details for "{0}" Détails pour «{0} » - Create new workspace + Create new workspace Créer un nouvel espace de travail - Create workspace + Create workspace Créer l'espace de travail - Delete workspace + Delete workspace Supprimer l'espace de travail - Edit workspace + Edit workspace Modifier l'espace de travail - Yes, delete the workspace + Yes, delete the workspace Oui, supprimer l'espace de travail - Edit workspace "{0}" + Edit workspace "{0}" Modifier l'espace de travail - Personal workspace + Personal workspace Espace de travail personnel - Private workspace + Private workspace Espace de travail privé - Internal workspace + Internal workspace Espace de travail interne - Read-only workspace + Read-only workspace Espace de travail en lecture seule - Title + Title Titre - Description + Description Description - Base workspace + Base workspace Espace de travail de base - Owner + Owner Propriétaire - Visibility + Visibility Visibilité - Private + Private Privé - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Seulement les relecteurs et les administrateurs peuvent accéder et modifier cet espace de travail - Internal + Internal Interne - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. N'importe quel éditeur connecté peut voir et modifier cet espace de travail. - Changes + Changes Changements - Open page in "{0}" workspace + Open page in "{0}" workspace Ouvrir la page dans l'espace de travail "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Il s'agit de votre espace de travail personnel que vous ne pouvez pas supprimer. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. L'espace de travail contient des modifications. Pour supprimer, ignorer tout d'abord les modifications. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Espace de travail ne peut pas être redéfinie sur un espace de travail différent, car il contient encore des changements. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. L'espace de travail ne peut pas être supprimé car d'autres espaces de travail en dépendent. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Vous n'avez pas les autorisations pour la suppression de cet espace de travail. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Voulez-vous vraiment supprimer l'espace de travail « {0} » ? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Cela va supprimer l'espace de travail y compris les contenus non publiés. Cette opération ne peut pas être annulée. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Ce module contient une vue d'ensemble de tous les éléments dans l'espace de travail actuel et vous permet de poursuivre le travail de révision et de publication. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Modifications non publiées - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} ajouts: {new}, changements: {changed}, suppressions: {removed} - Review + Review Relire - Discard selected changes + Discard selected changes Ignorer les modifications sélectionnées - Discard all changes + Discard all changes Ignorer toutes les modifications - Publish all changes + Publish all changes Publier toutes les modifications - Publish all changes to {0} + Publish all changes to {0} Publier toutes les modifications vers {0} - Changed Content + Changed Content Contenu modifié - deleted + deleted supprimé - created + created créé - moved + moved déplacé - hidden + hidden caché - edited + edited édité - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Il n'y a aucun changement non publié dans cet espace de travail. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Souhaitez-vous réellement annuler tous les changements dans l'espace de travail "{0}", vous ne pourrez pas annuler cette opération? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} L'espace de travail « {0} » ne peut être supprimé parce que les espaces de travail suivants sont basés sur lui: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Une erreur s'est produite lors de la lecteur des contenus non publiées de l'espace de travail « {0} », rien n'a été supprimé. - Your personal workspace contains changes, please publish or discard them first. Votre espace de travail personnel contient des modifications, s'il vous plaît publier ou jetez-les tout d'abord. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf index 5aeb2d689f1..e77621b6658 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Vissza - Cancel + Cancel Mégsem @@ -20,187 +20,187 @@ - Workspaces + Workspaces Munkaterület - Details for "{0}" + Details for "{0}" Részletek "{0}" - Create new workspace + Create new workspace Új munkafelület létrehozása - Create workspace + Create workspace Új munkafelület - Delete workspace + Delete workspace Munkafelület törlése - Edit workspace + Edit workspace Munkafelület szerkesztése - Yes, delete the workspace + Yes, delete the workspace Igen, törlöm a munkafelületet - Edit workspace "{0}" + Edit workspace "{0}" Munkafelület szerkesztése - Personal workspace + Personal workspace Személyes munkafelület - Private workspace + Private workspace Privát munkafelület - Internal workspace + Internal workspace Belső munkafelület - Read-only workspace + Read-only workspace Csak olvasható munkafelület - Title + Title Cím - Description + Description Leírás - Base workspace + Base workspace Alap munkafelület - Owner + Owner Tulajdonos - Visibility + Visibility Láthatóság - Private + Private Privát - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Csak moderátorok és adminisztrátoroknak van engedélye a tartalom szerkesztéséhez ezen a munkafelületen - Internal + Internal Belső - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Minden bejelentkezett szerkesztő láthatja és módosíthatja a munkafelületet. - Changes + Changes Változások - Open page in "{0}" workspace + Open page in "{0}" workspace Oldal megnyitása "{0}" a munkafelületen - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Ez egy személyes munkafelület, amit nem törölhet. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. A munkafelület változásokat tartalmaz. A törléshez először vesse el a módosításokat. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. A munkafelületet nem lehet újraalapozni egy másik munkafelületen, mert változásokat tartalmaz. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Ezt a munkafelületet nem lehet törölni, mert más munkafelületek erre épülnek. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Nincs jogod, hogy töröld a munkafelületet. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Biztos törölni akarod a "{0}" munkafelületet? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Ez törölni fogja a munkafelületet az összes közzé nem tett tartalommal együtt. Ez a művelet nem vonható vissza. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Ez a modul tartalmazza az áttekintését az összes elemnek a jelenlegi munkafelületen és ez engedélyezi, hogy folytassa a felülvizsgálatát, munkafolyamatot. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Közzé nem tett változások a "{0}" munkafelületen - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} kiegészítések: {new}, változások: {changed}, eltávolítások: {removed} - Review + Review Áttekintés - Discard selected changes + Discard selected changes Kiválasztott változások elvetése - Discard all changes + Discard all changes Összes változtatás elvetése - Publish all changes + Publish all changes Minden változtatás közzététele - Publish all changes to {0} + Publish all changes to {0} Minden változás közzététele {0} - Changed Content + Changed Content Változott tartalom - deleted + deleted törölt - created + created létrehozott - moved + moved mozgatott - hidden + hidden rejtett - edited + edited szerkesztett - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Minden közzé van téve. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Biztos minden változás el akar vetni a "{0}" munkafelületen? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} A munkafelületet "{0}" nem sikerült törölni, mert a következő munkafelületek erre épülnek: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Hiba történt a kiadatlan csomópontok bevitele közben a munkafelületen "{0}", semmi nem lett törölve. - Your personal workspace contains changes, please publish or discard them first. A személyes felület módosításokat tartalmaz, tegye közzé vagy dobja őket először. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf index 6471a86b060..e2208d1b025 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Kembali - Cancel + Cancel Batalkan @@ -20,187 +20,187 @@ - Workspaces + Workspaces Bidang Kerja - Details for "{0}" + Details for "{0}" Rincian untuk "{0}" - Create new workspace + Create new workspace Membuat bidang kerja baru - Create workspace + Create workspace Membuat bidang kerja - Delete workspace + Delete workspace Hapus bidang kerja - Edit workspace + Edit workspace Edit bidang kerja - Yes, delete the workspace + Yes, delete the workspace Ya, menghapus bidang kerja - Edit workspace "{0}" + Edit workspace "{0}" Edit bidang kerja - Personal workspace + Personal workspace Bidang kerja personal - Private workspace + Private workspace Bidang kerja Privat - Internal workspace + Internal workspace Bidang kerja internal - Read-only workspace + Read-only workspace Bidang kerja Read-only - Title + Title Judul - Description + Description Deskripsi - Base workspace + Base workspace Bidang Kerja Dasar - Owner + Owner Pemilik - Visibility + Visibility Visibilitas - Private + Private Rahasia - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Hanya pengulas dan administrator yang dapat mengakses dan memodifikasi bidang kerja ini - Internal + Internal Internal - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Editor login yang dapat melihat dan mengubah bidang kerja ini. - Changes + Changes Perubahan - Open page in "{0}" workspace + Open page in "{0}" workspace Buka halaman di bidang kerja "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Ini adalah ruang kerja pribadi Anda yang tidak dapat Anda hapus. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Ruang kerja berisi perubahan. Untuk menghapus, buang perubahan terlebih dahulu. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Bidang kerja tidak dapat didasarkan pada bidang kerja yang berbeda karena masih mengandung perubahan. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Bidang kerja tidak dapat dihapus karena bidang kerja lain bergantung padanya. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Anda tidak memiliki izin untuk menghapus bidang kerja ini. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Apakah Anda benar-benar ingin menghapus bidang kerja "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Ini akan menghapus ruang kerja termasuk konten yang belum diterbitkan. Operasi ini tidak bisa dibatalkan. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Modul ini berisi ikhtisar dari semua elemen dalam bidang kerja saat ini dan memungkinkan untuk terus diperiksa dan penerbitan alur kerja mereka. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Perubahan yang tidak dipublikasikan di bidang kerja "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} penambahan: {new}, perubahan: {changed}, perpindahan: {removed} - Review + Review Ulasan - Discard selected changes + Discard selected changes Membuang perubahan yang dipilih - Discard all changes + Discard all changes Buang semua perubahan - Publish all changes + Publish all changes Mempublikasikan semua perubahan - Publish all changes to {0} + Publish all changes to {0} Mempublikasikan semua perubahan ke {0} - Changed Content + Changed Content Konten yang dirubah - deleted + deleted dihapus - created + created dibuat - moved + moved dipindah - hidden + hidden tersembunyi - edited + edited diedit - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Tidak ada perubahan yang tidak dipublikasikan di bidang kerja ini. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Apakah Anda benar-benar ingin membuang semua perubahan dalam bidang kerja "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Bidang kerja "{0}" tidak dapat dihapus karena bidang kerja berikut didasarkan pada itu: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Terjadi kesalahan saat mengambil node yang tidak diterbitkan dari bidang kerja "{0}", tidak dihapus. - Your personal workspace contains changes, please publish or discard them first. Ruang kerja pribadi Anda berisi perubahan, silahkan mempublikasikan atau membuangnya dulu. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf index 65f14a9bf6a..e263dbdbd25 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Indietro - Cancel + Cancel Annulla @@ -20,187 +20,187 @@ - Workspaces + Workspaces Aree Lavoro (Workspaces) - Details for "{0}" + Details for "{0}" Dettagli per "{0}" - Create new workspace + Create new workspace Crea nuovo spazio di lavoro - Create workspace + Create workspace Crea spazio di lavoro - Delete workspace + Delete workspace Elimina spazio di lavoro - Edit workspace + Edit workspace Modifica spazio di lavoro - Yes, delete the workspace + Yes, delete the workspace Sì, elimina lo spazio di lavoro - Edit workspace "{0}" + Edit workspace "{0}" Modifica spazio di lavoro - Personal workspace + Personal workspace Spazio di lavoro personale - Private workspace + Private workspace Spazio di lavoro privato - Internal workspace + Internal workspace Spazio di lavoro interno - Read-only workspace + Read-only workspace Spazio di lavoro di sola lettura - Title + Title Titolo - Description + Description Descrizione - Base workspace + Base workspace Spazio di lavoro base - Owner + Owner Proprietario - Visibility + Visibility Visibilità - Private + Private Privato - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Solo i revisori e gli amministratori possono accedere e modificare questo spazio di lavoro - Internal + Internal Interno - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Ogni editore loggato può vedere e modificare questo spazio di lavoro. - Changes + Changes Modifiche - Open page in "{0}" workspace + Open page in "{0}" workspace Apri pagina nello spazio di lavoro "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Questo è il tuo spazio di lavoro personale che non puoi eliminare. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Lo spazio di lavoro contiene modifiche. Per eliminare, scarta prima le modifiche. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Lo spazio di lavoro non può essere ribasato su un differente spazio di lavoro perchè contiene ancora modifiche. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Lo spazio di lavoro non può essere eliminato perchè un altro spazio di lavoro dipende da esso. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Non hai i permessi per eliminare questo spazio di lavoro. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Vuoi veramente eliminare lo spazio di lavoro "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Questo eliminerà lo spazio di lavoro inclusi tutti i contenuti non pubblicati. Questa operazione non può essere annullata. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Questo modulo contiene una panoramica di tutti gli elementi all'interno dell'area di lavoro corrente e consente di continuare la revisione e la pubblicazione del flusso di lavoro. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Modifiche non pubblicate nel progetto "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} aggiunte: {new}, modifiche: {changed}, rimozioni: {removed} - Review + Review Revisiona - Discard selected changes + Discard selected changes Annulla le modifiche selezionate - Discard all changes + Discard all changes Annullare tutte le modifiche - Publish all changes + Publish all changes Pubblicare tutte le modifiche - Publish all changes to {0} + Publish all changes to {0} Pubblica tutte le modifiche a {0} - Changed Content + Changed Content Contenuto modificato - deleted + deleted eliminato - created + created creato - moved + moved spostato - hidden + hidden nascosto - edited + edited modificato - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Non ci sono modifiche non pubblicate in questo spazio di lavoro. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Vuoi veramente eliminare tutte le modifiche nello spazio di lavoro "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Lo spazio di lavoro "{0}" non può essere eliminato perchè i seguenti spazi di lavoro sono basati su di esso: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Si è verificato un errore durante il recupero dei nodi non pubblicati "{0}", nulla è stato eliminato. - Your personal workspace contains changes, please publish or discard them first. Il tuo spazio di lavoro personale contiene modifiche, per favore pubblicalo o scartalo prima. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf index f2c8a8b5342..771aed88384 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf index 990eda46b86..ae3342c7b9a 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf @@ -3,11 +3,11 @@ - Back + Back ត្រលប់ក្រោយ - Cancel + Cancel បោះបង់ @@ -20,188 +20,188 @@ - Workspaces + Workspaces លំហរការងារ - Details for "{0}" + Details for "{0}" ពត៌មានលំអិត "{០}" - Create new workspace + Create new workspace បង្កើតតំបន់ការងារថ្មី - Create workspace + Create workspace បង្កើតតំបន់ការងារ - Delete workspace + Delete workspace លុបតំបន់ការងារ - Edit workspace + Edit workspace កែសម្រួលតំបន់ការងារ - Yes, delete the workspace + Yes, delete the workspace បាទ, លុបតំបន់ធ្វើការ - Edit workspace "{0}" + Edit workspace "{0}" កែសម្រួលតំបន់ការងារ - Personal workspace + Personal workspace តំបន់ការងារផ្ទាល់ខ្លួន - Private workspace + Private workspace តំបន់ការងារឯកជន - Internal workspace + Internal workspace តំបន់ការងារផ្ទៃក្នុង - Read-only workspace + Read-only workspace តំបន់ធ្វើការបានតែអាន - Title + Title ចំណងជើង - Description + Description ពិពណ៍នា - Base workspace + Base workspace តំបន់ការងារមូលដ្ឋាន - Owner + Owner ម្ចាស់ - Visibility + Visibility ដែលអាចមើលឃើញ - Private + Private ឯកជន - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace មាន​តែអ្នក​ត្រួត​ពិនិត្យនិងអ្នកគ្រប់គ្រងអាចចូលដំណើរការនិងកែប្រែតំបន់ការងារនេះ - Internal + Internal ផ្ទៃក្នុង - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. រាល់ការចូលទេនៅក្នុងកម្មវិធីនិពន្ធអាចមើលឃើញនិងកែប្រែតំបន់ការងារនេះ - Changes + Changes ផ្លាស់ប្ដូរ - Open page in "{0}" workspace + Open page in "{0}" workspace បើកទំព័រក្នុង "{0}" ចន្លោះការងារ - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. នេះគឺជាតំបន់​ការងារផ្ទាល់ខ្លួនរបស់អ្នកដែលអ្នកមិនអាចលុប - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. តំបន់​ការងារនេះមានការផ្លាស់ប្តូរ។ លុប, បោះបង់ការផ្លាស់ប្តូរជាលើកដំបូង។ - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. កន្លែងធ្វើការមិនអាចត្រូវបាន rebased នៅលើតំបន់​ការងារផ្សេងគ្នាដោយសារតែវានៅតែមានការផ្លាស់ប្តូរ។ - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. តំបន់​ការងារនេះមិនអាចត្រូវបានលុបបានទេព្រោះតំបន់ធ្វើការផ្សេងទៀតពឹងផ្អែកលើវា។ - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. អ្នកមិនមានសិទ្ធិដើម្បីលុបតំបន់​ការងារនេះ។ - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? តើអ្នកពិតជាចង់លុបតំបន់ធ្វើការ "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. វានឹងលុបតំបន់ធ្វើការរួម​ទាំង​ខ្លឹមសារដែលមិនបានផ្សព្វផ្សាយទាំងអស់។ ប្រតិបត្តិការនេះមិនអាចមិនធ្វើ​វិញ​ទេ។ - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. ម៉ូឌុលនេះផ្ទុកទិដ្ឋភាពទូទៅនៃទាំងអស់នៅក្នុងតំបន់ធ្វើការធាតុនាពេលបច្ចុប្បន្ននេះហើយវាអនុញ្ញាតឱ្យបន្តការពិនិត្យនិងការបោះពុម្ពផ្សាយលំហូរការងារសម្រាប់ពួកគេ។ - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" ការផ្លាស់ប្តូរដែលមិនទាន់ដាក់ផ្សាយ - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} ការបន្ថែម: {ថ្មី}, ការផ្លាស់ប្តូរ: {ផ្លាស់ប្តូរ} ដកចេញ: {យកចេញ} - Review + Review ពិនិត្យឡើងវិញ - Discard selected changes + Discard selected changes បេាះបង់ចោលការកែតម្រូវ - Discard all changes + Discard all changes បោះបង់ការផ្លាស់ប្តូរទាំងអស់ - Publish all changes + Publish all changes ផ្សាយរាល់ការផ្លាស់ប្តូរ - Publish all changes to {0} + Publish all changes to {0} បោះពុម្ពផ្សាយការផ្លាស់ប្តូរទាំងអស់ទៅ {0} - Changed Content + Changed Content ផ្លាស់ប្តូរខ្លឹមសារ - deleted + deleted ត្រូវបានលប់ - created + created ត្រូរបានបង្កើត - moved + moved ត្រូវបានផ្ដូរទីតាំង - hidden + hidden លាក់បាំង - edited + edited ត្រូវបានកែរប្រែរ - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. មិនមានការផ្លាស់ប្តូរដែលត្រូវផ្សាយទេនៅក្នុងកន្លែធ្វើការនេះ - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? តើអ្នកពិតជាចង់បោះបង់កំនែរនៅក្នុង កន្លែងការងារ "{0}" @@ -216,10 +216,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} កន្លែងធ្វើការ "{0}" មិនអាចលុបដោយសារតែការតំបន់​ធ្វើការខាងក្រោមនេះត្រូវបានផ្អែកលើវា: {១} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - កំហុសមួយបានកើតឡើងខណៈពេលទៅយកថ្នាំងដែលមិនបានផ្សព្វផ្សាយពីតំបន់​ការងារ "{0}" គ្មានអ្វីត្រូវបានលុប។ - Your personal workspace contains changes, please publish or discard them first. តំបន់​ការងារផ្ទាល់ខ្លួនរបស់អ្នកមានការផ្លាស់ប្តូរ, សូមបោះពុម្ពផ្សាយឬមិនរក្សាទុកពួកជាលើកដំបូង។ diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf index 671e3452642..650d7922d09 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Atpakaļ - Cancel + Cancel Atcelt @@ -20,187 +20,187 @@ - Workspaces + Workspaces Darba virsmas - Details for "{0}" + Details for "{0}" Informācija par {0} - Create new workspace + Create new workspace Izveidot jaunu darba virsmu - Create workspace + Create workspace Izveidot darba virsmu - Delete workspace + Delete workspace Dzēst darba virsmu - Edit workspace + Edit workspace Rediģēt darba virsmu - Yes, delete the workspace + Yes, delete the workspace Jā, dzēst darba virsmu - Edit workspace "{0}" + Edit workspace "{0}" Rediģēt darba virsmu - Personal workspace + Personal workspace Personāla darba virsma - Private workspace + Private workspace Privāta darba virsma - Internal workspace + Internal workspace Iekšējā darba virsma - Read-only workspace + Read-only workspace Tikai lasāma darba virsma - Title + Title Virsraksts - Description + Description Apraksts - Base workspace + Base workspace Pamata darba virsma - Owner + Owner Īpašnieks - Visibility + Visibility Redzamība - Private + Private Privāts - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Tikai recenzenti un administratori var piekļūt un modificēt šo darba virsmu - Internal + Internal Iekšēji - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Autorizēts redaktors var redzēt un rediģēt šo darba virsmu. - Changes + Changes Izmaiņas - Open page in "{0}" workspace + Open page in "{0}" workspace Atvērt lapu "{0}" darba virsmā - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Tā ir jūsu personīgā darba virsma, kuru nevar izdzēst. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Darba virsma satur izmaiņas. Lai izdzēstu, vispirms atceļiet veiktās izmaiņas. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Darba virsmu nevar pārvietot uz citu, jo tajā vēl joprojām ir izmaiņas. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Darba virsmu nevar izdzēst, jo citas darba virsmas ir atkarīgas no tās. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Jums nav atļaujas dzēst šo darba virsmu. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Vai tiešām vēlaties izdzēst darba virsmu, "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Ar šo tiks izdzēsta darba virsma, ieskaitot visu nepublicētu saturu. Šo darbību nevar atsaukt. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Šis modulis ietver visu esošās darbav virsmas elementu pārskatu, dod iespēju tos komentēt un publicēt. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Nepublicētās izmaiņas darba vietā "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} papildinājumi: {new}, izmaiņas: {changed}, noņemšana: {removed} - Review + Review Recenzija - Discard selected changes + Discard selected changes Atcelt iezīmētās izmaiņas - Discard all changes + Discard all changes Atcelt visas izmaiņas - Publish all changes + Publish all changes Publicēt visas izmaiņas - Publish all changes to {0} + Publish all changes to {0} Publicētu visas izmaiņas {0} - Changed Content + Changed Content Mainīts saturs - deleted + deleted dzēsts - created + created izveidots - moved + moved pārvietots - hidden + hidden paslēpts - edited + edited labots - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Nav nepublicētu izmaiņu šajā darba virsmā. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Vai vēlaties atcelt visas izmaiņas šajā {0} darba virsmā? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Darba virsmu "{0}" nevar dzēst, jo šīs darba virsmas pamatā ir: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Rādās kļūda, ielādējot nepublicētus mezglus no darbvietas "{0}", nekas nav izdzēsts. - Your personal workspace contains changes, please publish or discard them first. Jūsu personīgā darba virsma satur izmaiņas, lūdzu publicējiet vai atceliet tās vispirms. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf index 7c25e0dec63..4417d0fff6a 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf @@ -207,10 +207,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" kan niet verwijderd worden, omdat de volgende workspaces hierop gebaseerd zijn: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Er is een fout opgetreden bij het laden van ongepubliceerde nodes van workspace "{0}", er is niets verwijderd. - Your personal workspace contains changes, please publish or discard them first. Uw persoonlijke workspace bevat wijzigingen. Publiceer deze wijzigingen of maak ze ongedaan. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf index 62a5f0e51ed..2190e1ba404 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Tilbake - Cancel + Cancel Avbryt @@ -20,187 +20,187 @@ - Workspaces + Workspaces Arbeidsområder - Details for "{0}" + Details for "{0}" Detaljer for "{0}" - Create new workspace + Create new workspace Opprett nytt arbeidsområde - Create workspace + Create workspace Opprett arbeidsområde - Delete workspace + Delete workspace Slett arbeidsområdet - Edit workspace + Edit workspace Rediger arbeidsområde - Yes, delete the workspace + Yes, delete the workspace Ja, slett arbeidsområdet - Edit workspace "{0}" + Edit workspace "{0}" Rediger arbeidsområde - Personal workspace + Personal workspace Personlig arbeidsområde - Private workspace + Private workspace Privat arbeidsområde - Internal workspace + Internal workspace Internt arbeidsområde - Read-only workspace + Read-only workspace Skrivebeskyttet arbeidsområde - Title + Title Tittel - Description + Description Beskrivelse - Base workspace + Base workspace Utgangspunkt for arbeidsområde - Owner + Owner Eier - Visibility + Visibility Synlighet - Private + Private Privat - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Bare korrekturlesere og administratorer kan få tilgang til og endre dette arbeidsområdet - Internal + Internal Intern - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Alle påloggede redaktører kan se og endre dette arbeidsområdet. - Changes + Changes Endringer - Open page in "{0}" workspace + Open page in "{0}" workspace Åpne side i arbeidsområde "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Dette er ditt personlige arbeidsområde. Du kan ikke slette det. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Arbeidsområdet inneholder endringer. Hvis du vil slette det må du forkaste endringene først. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Arbeidsområdet kan ikke sammenflettes med et annet arbeidsområde fordi det fortsatt inneholder endringer. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Arbeidsområdet kan ikke slettes fordi andre arbeidsområder er avhengig av det. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Du har ikke tilgang til å slette dette arbeidsområdet. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Vil du virkelig slette arbeidsområdet "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Dette vil slette hele arbeidsområdet inkludert alt upubliserte innhold. Denne operasjonen kan ikke angres. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Denne modulen inneholder en oversikt over alle elementer i gjeldende arbeidsområde og du kan fortsette arbeidsflyten for gjennomgang og publisering. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Upubliserte endringer - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} nye: {new}, endringer: {changed}, flyttinger: {removed} - Review + Review Korrektur - Discard selected changes + Discard selected changes Forkast merkede endringer - Discard all changes + Discard all changes Forkast alle endringer - Publish all changes + Publish all changes Publiser alle endringer - Publish all changes to {0} + Publish all changes to {0} Publiser alle endringer til {0} - Changed Content + Changed Content Endret innhold - deleted + deleted slettet - created + created opprettet - moved + moved flyttet - hidden + hidden skjult - edited + edited endret - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Det er ingen upubliserte endringer i arbeidsområdet. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Ønsker du virkelig å forkaste alle endringer i arbeidsområdet "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Arbeidsområdet "{0}" kan ikke slettes fordi følgende arbeidsområder er basert på det: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - En feil oppstod under henting av upubliserte noder fra arbeidsområdet "{0}". Ingenting ble slettet. - Your personal workspace contains changes, please publish or discard them first. Ditt personlige arbeidsområde inneholder endringer. Vennligst publiser eller forkast dem først. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf index f1cd00ef230..02c192ab401 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Wstecz - Cancel + Cancel Anuluj @@ -20,187 +20,187 @@ - Workspaces + Workspaces Obszary robocze - Details for "{0}" + Details for "{0}" Szczegóły dla "{0}" - Create new workspace + Create new workspace Utwórz nowy obszar roboczy - Create workspace + Create workspace Utwórz obszar roboczy - Delete workspace + Delete workspace Usuń obszar roboczy - Edit workspace + Edit workspace Edytuj obszar roboczy - Yes, delete the workspace + Yes, delete the workspace Tak, usuń obszar roboczy - Edit workspace "{0}" + Edit workspace "{0}" Edytuj obszar roboczy - Personal workspace + Personal workspace Osobisty obszar roboczy - Private workspace + Private workspace Prywatny obszar roboczy - Internal workspace + Internal workspace Wewnętrzny obszar roboczy - Read-only workspace + Read-only workspace Obszar roboczy tylko do odczytu - Title + Title Tytuł - Description + Description Opis - Base workspace + Base workspace Podstawowy obszar roboczy - Owner + Owner Właściciel - Visibility + Visibility Widoczność - Private + Private Prywatny - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Tylko recenzenci i administratorzy mogą otwierać i modyfikować ten obszar roboczy - Internal + Internal Wewnętrzny - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Każdy zalogowany redaktor może zobaczyć i modyfikować ten obszar roboczy. - Changes + Changes Zmiany - Open page in "{0}" workspace + Open page in "{0}" workspace Otwórz stronę w obszarze roboczym "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. To jest twój osobisty obszar roboczy, którego nie można usunąć. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Obszar roboczy zawiera zmiany. Aby usunąć, odrzuć najpierw zmiany. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Obszar roboczy nie może być przeniesiony na inny, ponieważ nadal zawiera zmiany. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Obszaru roboczego nie może być usunięty, ponieważ inne obszary robocze zależą od niego. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Nie masz uprawnień do usunięcia tego obszaru roboczego. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Czy na pewno chcesz usunąć obszar roboczy "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Spowoduje to usunięcie obszaru roboczego, w tym wszystkie nieopublikowane treści. Tej operacji nie można cofnąć. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Ten moduł zawiera listę wszystkich elementów w bieżącym obszarze roboczym i pozwala na ich przegląd i publikowanie. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Nieopublikowane zmiany - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} dodania: {new}, zmiany: {changed}, usunięcia: {removed} - Review + Review Recenzuj - Discard selected changes + Discard selected changes Odrzuć zaznaczone zmiany - Discard all changes + Discard all changes Odrzuć wszystkie zmiany - Publish all changes + Publish all changes Publikuj wszystkie zmiany - Publish all changes to {0} + Publish all changes to {0} Publikuj wszystkie zmiany do {0} - Changed Content + Changed Content Zmieniona zawartość - deleted + deleted usunięte - created + created utworzone - moved + moved przeniesione - hidden + hidden ukryte - edited + edited edytowane - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Brak nieopublikowanych zmian w tym obszarze roboczym. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Czy na pewno zaniechać wszystkie zmiany w obszarze roboczym "{0}" ? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Obszar roboczy "{0}" nie można być usunięty, ponieważ następujące obszary robocze są oparte na nim: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Wystąpił błąd podczas pobierania nieopublikowanych węzłów z obszaru roboczego "{0}", nic nie zostało usunięte. - Your personal workspace contains changes, please publish or discard them first. Twój osobisty obszar roboczy zawiera zmiany, musisz je najpierw opublikować lub odrzucić. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf index c23c7dd0dea..e5546413fc7 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} A área de Trabalho "{0}" não pode ser apagada porque outras baseiam-se nela: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Ocorreu um erro ao angariar os Nós não publicados da área de trabalho "{0}", nada foi apagado. - Your personal workspace contains changes, please publish or discard them first. A sua área de trabalho pessoal contém alterações, por favor, publique ou descarte-as primeiro. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf index ad62baa0de3..657ecb3c219 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Voltar - Cancel + Cancel Cancelar @@ -20,187 +20,187 @@ - Workspaces + Workspaces Espaços de Trabalho - Details for "{0}" + Details for "{0}" Detalhes para "{0}" - Create new workspace + Create new workspace Criar novo espaço de trabalho - Create workspace + Create workspace Criar espaço de trabalho - Delete workspace + Delete workspace Apagar espaço de trabalho - Edit workspace + Edit workspace Editar o espaço de trabalho - Yes, delete the workspace + Yes, delete the workspace Sim, excluir o espaço de trabalho - Edit workspace "{0}" + Edit workspace "{0}" Editar o espaço de trabalho - Personal workspace + Personal workspace Espaço de trabalho pessoal - Private workspace + Private workspace Espaço de trabalho privado - Internal workspace + Internal workspace Espaço de trabalho interno - Read-only workspace + Read-only workspace Espaço de trabalho somente leitura - Title + Title Título - Description + Description Descrição - Base workspace + Base workspace Espaço de trabalho de base - Owner + Owner Proprietário - Visibility + Visibility Visibilidade - Private + Private Privado - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Apenas os revisores e os administradores podem acessar e modificar este espaço de trabalho - Internal + Internal Interno - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Qualquer editor logado pode ver e modificar este espaço de trabalho. - Changes + Changes Mudanças - Open page in "{0}" workspace + Open page in "{0}" workspace Abrir página em espaço de trabalho "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Este é o seu espaço de trabalho pessoal, que não é possível apagar. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. O espaço de trabalho contém alterações. Para o excluir, descarte as alterações primeiro. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. A base deste espaço de trabalho não pode ser alterada para uma área diferente porque ele ainda contém alterações. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. O espaço de trabalho não pode ser apagado porque outros espaços de trabalho dependem dele. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Você não tem as permissões para apagar este espaço de trabalho. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Deseja mesmo excluir o espaço de trabalho "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Isto irá apagar o espaço de trabalho, incluindo todos os conteúdos não publicados. Esta operação não pode ser desfeita. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Este módulo contém a visão geral de todos os elementos dentro do espaço de trabalho atual e permite continuar a revisão e publicação do fluxo de trabalho para eles. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Alterações não publicadas - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} adições: {new}, alterações: {changed}, remoções: {removed} - Review + Review Revisão - Discard selected changes + Discard selected changes Descartar alterações selecionadas - Discard all changes + Discard all changes Descartar todas as alterações - Publish all changes + Publish all changes Publicar todas as alterações - Publish all changes to {0} + Publish all changes to {0} Publicar todas as alterações para {0} - Changed Content + Changed Content Conteúdo alterado - deleted + deleted excluído - created + created criado - moved + moved movido - hidden + hidden oculto - edited + edited editado - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Não há nenhuma mudança não publicada neste espaço de trabalho. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Você realmente quer descartar todas as alterações na área de trabalho "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Espaço de trabalho "{0}" não pode ser apagado porque os seguintes espaços de trabalho baseiam-se nele: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Ocorreu um erro ao buscar nós não publicados da área de trabalho "{0}", nada foi excluído. - Your personal workspace contains changes, please publish or discard them first. Seu espaço de trabalho pessoal contém alterações, por favor, publique ou descarte-as primeiro. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf index b4a371476a8..6d3ff3831e7 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Назад - Cancel + Cancel Отмена @@ -20,187 +20,187 @@ - Workspaces + Workspaces Рабочие области - Details for "{0}" + Details for "{0}" Подробно об "{0}" - Create new workspace + Create new workspace Создать новую рабочую область - Create workspace + Create workspace Создать рабочую область - Delete workspace + Delete workspace Удалить рабочую область - Edit workspace + Edit workspace Редактировать рабочую область - Yes, delete the workspace + Yes, delete the workspace Да, удалить рабочую область - Edit workspace "{0}" + Edit workspace "{0}" Редактировать рабочую область - Personal workspace + Personal workspace Персональная рабочая область - Private workspace + Private workspace Приватная рабочая область - Internal workspace + Internal workspace Внутренняя рабочая область - Read-only workspace + Read-only workspace Неизменяемая рабочая область - Title + Title Название - Description + Description Описание - Base workspace + Base workspace Базовая рабочая область - Owner + Owner Владелец - Visibility + Visibility Видимость - Private + Private Приватный - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Только рецензенты и администраторы имеют доступ к этой рабочей области и могут изменять её - Internal + Internal Внутренняя - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Любой вошедший в систему редактор может просматривать и редактировать эту рабочую область. - Changes + Changes Изменения - Open page in "{0}" workspace + Open page in "{0}" workspace Открыть страницу в рабочей области "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Это ваша личная рабочяя область, которую вы не можете удалить. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Рабочая область содержит изменения. Чтобы её удалить, отмените сначала эти изменения. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Рабочая область не может быть перебазирована поверх другой, так как она всё ещё содержит изменения. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Рабочая область не может быть удалена, поскольку другие рабочие области зависят от неё. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. У вас нет прав на удаление этой рабочей области. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Вы действительно хотите удалить рабочую область "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Это безвозвратно удалит рабочую область, включая все неопубликованные изменения. Эта операция не может быть отменена. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Этот модуль содержит обзор всех элементов текущей рабочей области, позволяющий продолжать рецензирование и процесс публикации этих элементов. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Неопубликованные изменения в рабочей области "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} дополнения: {new}, изменения: {changed}, удаления: {removed} - Review + Review Проверить - Discard selected changes + Discard selected changes Отклонить выбранные изменения - Discard all changes + Discard all changes Отменить все изменения - Publish all changes + Publish all changes Опубликовать все изменения - Publish all changes to {0} + Publish all changes to {0} Публиковать все изменения в {0} - Changed Content + Changed Content Измененное содержимое - deleted + deleted удалено - created + created создано - moved + moved перемещен - hidden + hidden скрыт - edited + edited отредактирован - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Существуют неопубликованные изменения в этой рабочей области. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Вы действительно хотите отменить все изменения в рабочей области "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Рабочая область "{0}" не может быть удалена, потому что на ней основанны следующие рабочие области: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Произошла ошибка при получении неопубликованных элементов из рабочей области "{0}", ничего не было удалено. - Your personal workspace contains changes, please publish or discard them first. Ваша персональная рабочая область содержит изменения, пожалуйста опубликуйте или отмените их сначала. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf index 24a6c60cc5e..24f4182c0ac 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Back - Cancel + Cancel Откажи @@ -20,187 +20,187 @@ - Workspaces + Workspaces Workspaces - Details for "{0}" + Details for "{0}" Details for "{0}" - Create new workspace + Create new workspace Create new workspace - Create workspace + Create workspace Create workspace - Delete workspace + Delete workspace Delete workspace - Edit workspace + Edit workspace Edit workspace - Yes, delete the workspace + Yes, delete the workspace Yes, delete the workspace - Edit workspace "{0}" + Edit workspace "{0}" Edit workspace - Personal workspace + Personal workspace Personal workspace - Private workspace + Private workspace Private workspace - Internal workspace + Internal workspace Internal workspace - Read-only workspace + Read-only workspace Read-only workspace - Title + Title Наслов - Description + Description Description - Base workspace + Base workspace Base workspace - Owner + Owner Owner - Visibility + Visibility Visibility - Private + Private Private - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - Internal + Internal Internal - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes Changes - Open page in "{0}" workspace + Open page in "{0}" workspace Open page in "{0}" workspace - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. This is your personal workspace which you can't delete. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review Review - Discard selected changes + Discard selected changes Discard selected changes - Discard all changes + Discard all changes Discard all changes - Publish all changes + Publish all changes Publish all changes - Publish all changes to {0} + Publish all changes to {0} Publish all changes to {0} - Changed Content + Changed Content Changed Content - deleted + deleted deleted - created + created created - moved + moved moved - hidden + hidden hidden - edited + edited edited - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. There are no unpublished changes in this workspace. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Do you really want to discard all changes in the "{0}" workspace? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf index f3f0d3dee52..e268756ca14 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Tillbaka - Cancel + Cancel Avbryt @@ -20,187 +20,187 @@ - Workspaces + Workspaces Arbetsytor - Details for "{0}" + Details for "{0}" Detaljer för "{0}" - Create new workspace + Create new workspace Skapa ny arbetsyta - Create workspace + Create workspace Skapa arbetsyta - Delete workspace + Delete workspace Ta bort arbetsyta - Edit workspace + Edit workspace Redigera arbetsyta - Yes, delete the workspace + Yes, delete the workspace Ja, ta bort arbetsytan - Edit workspace "{0}" + Edit workspace "{0}" Redigera arbetsyta - Personal workspace + Personal workspace Personlig arbetsyta - Private workspace + Private workspace Privat arbetsyta - Internal workspace + Internal workspace Intern arbetsyta - Read-only workspace + Read-only workspace Skrivskyddad arbetsyta - Title + Title Titel - Description + Description Beskrivning - Base workspace + Base workspace Bas-arbetsyta - Owner + Owner Ägare - Visibility + Visibility Synlighet - Private + Private Privat - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Endast förhandsgranskare och administratörer kan komma åt och ändra den här arbetsytan - Internal + Internal Internt - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. En inloggade redigerare kan se och ändra den här arbetsytan. - Changes + Changes Ändringar - Open page in "{0}" workspace + Open page in "{0}" workspace Öppna sidan i arbetsyta "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Detta är din personliga arbetsyta som du inte kan radera. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Arbetsytan innehåller ändringar. För att kunna ta bort arbetsytan måste du ignorera ändringarna först. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Arbetsytan kan inte vara baserad på en annan arbetsyta eftersom den fortfarande innehåller ändringar. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Arbetsytan kan inte raderas eftersom andra arbetsytor är beroende av den. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Du har inte behörighet att ta bort den här arbetsytan. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Vill du verkligen ta bort arbetsytan "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Detta tar bort arbetsytan inklusive allt opublicerat innehåll. Åtgärden kan ej ångras. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Denna modul innehåller en översikt över alla element inom den aktuella arbetsytan och gör det möjligt att fortsätta gransknings- och publiceringsarbetsflödet för dem. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Opublicerade ändringar i arbetsytan "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} tillägg: {new}, ändringar: {changed}, raderat: {removed} - Review + Review Granska - Discard selected changes + Discard selected changes Ignorera markerade ändringar - Discard all changes + Discard all changes Ignorera alla ändringar - Publish all changes + Publish all changes Publicera alla ändringar - Publish all changes to {0} + Publish all changes to {0} Publicera alla ändringar i {0} - Changed Content + Changed Content Ändrat innehåll - deleted + deleted raderat - created + created skapat - moved + moved flyttat - hidden + hidden dolt - edited + edited redigerat - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Det finns inga opublicerade ändringar i den här arbetsytan. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Vill du verkligen ignorera alla ändringar i arbetsytan "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Arbetsytan "{0}" kan ej raderas eftersom de följande arbetsytorna baserar på den: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Ett fel uppstod när opublicerade noder hämtades från arbetsytan "{0}", ingenting togs bort. - Your personal workspace contains changes, please publish or discard them first. Din personliga arbetsyta innehåller ändringar, vänligen publicera eller ignorera dem först. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf index 4e62cf1f951..689160914ac 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Ibalik - Cancel + Cancel Kanselahin @@ -20,187 +20,187 @@ - Workspaces + Workspaces Mga workspace - Details for "{0}" + Details for "{0}" Details for "{0}" - Create new workspace + Create new workspace Create new workspace - Create workspace + Create workspace Create workspace - Delete workspace + Delete workspace Delete workspace - Edit workspace + Edit workspace Edit workspace - Yes, delete the workspace + Yes, delete the workspace Oo, burahin ang workspace - Edit workspace "{0}" + Edit workspace "{0}" Edit workspace - Personal workspace + Personal workspace Personal workspace - Private workspace + Private workspace Private workspace - Internal workspace + Internal workspace Internal workspace - Read-only workspace + Read-only workspace Read-only workspace - Title + Title Pamagat - Description + Description Description - Base workspace + Base workspace Base workspace - Owner + Owner May-ari - Visibility + Visibility Bisibilidad - Private + Private Pribado - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - Internal + Internal Panloob - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes Mga pagbabago - Open page in "{0}" workspace + Open page in "{0}" workspace Open page in "{0}" workspace - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Ito ay iyong personal na workspace na kung saan ito ay hindi mabubura. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review Review - Discard selected changes + Discard selected changes Discard selected changes - Discard all changes + Discard all changes Discard all changes - Publish all changes + Publish all changes Ilathala lahat ng mga pagbabago - Publish all changes to {0} + Publish all changes to {0} Publish all changes to {0} - Changed Content + Changed Content Changed Content - deleted + deleted nabura - created + created nagawa - moved + moved inilipat - hidden + hidden nakatago - edited + edited na-edit - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. There are no unpublished changes in this workspace. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Do you really want to discard all changes in the "{0}" workspace? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf index 6aa56cea466..027eabc46df 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Geri - Cancel + Cancel İptal et @@ -20,187 +20,187 @@ - Workspaces + Workspaces Çalışma alanları - Details for "{0}" + Details for "{0}" "{0}" için ayrıntılar - Create new workspace + Create new workspace Yeni çalışma alanı oluştur - Create workspace + Create workspace Çalışma alanı oluştur - Delete workspace + Delete workspace Çalışma alanını sil - Edit workspace + Edit workspace Çalışma alanını düzenle - Yes, delete the workspace + Yes, delete the workspace Evet, çalışma alanını sil - Edit workspace "{0}" + Edit workspace "{0}" Çalışma alanını düzenle - Personal workspace + Personal workspace Kişisel çalışma alanı - Private workspace + Private workspace Özel çalışma alanı - Internal workspace + Internal workspace İç çalışma alanı - Read-only workspace + Read-only workspace Salt okunur çalışma alanı - Title + Title Başlık - Description + Description Açıklama - Base workspace + Base workspace Temel çalışma alanı - Owner + Owner Sahibi - Visibility + Visibility Netlik - Private + Private Özel - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Bu çalışma alanına yalnızca inceleyiciler ve yöneticiler erişebilir ve değiştirebilir - Internal + Internal Dahili - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Oturum açmış herhangi bir düzenleyici bu çalışma alanını görebilir ve değiştirebilir. - Changes + Changes Değişiklikler - Open page in "{0}" workspace + Open page in "{0}" workspace Sayfayı "{0}" çalışma alanında açın - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Bu sizin silemeyeceğiniz kişisel çalışma alanınızdır. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Çalışma alanı değişiklikler içeriyor. Silmek için önce değişiklikleri iptal etmelisiniz. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Çalışma alanı, hâlâ değişiklikler içerdiği için bir başka çalışma alanının üzerine yeniden kurulamaz. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Diğer çalışma alanları bu çalışma alanına bağlı olduklarından çalışma alanı silinemez. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. Bu çalışma alanını silmek için izniniz bulunmamakta. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? "{0}" çalışma alanını silmek istediğinizden emin misiniz? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Bu, çalışma alanını, yayımlanmamış bütün içerikler de dahil olmak üzere silecektir. Bu işlem geri alınamaz. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Bu modül geçerli çalışma alanı içerisindeki bütün öğelerin genel görünümünü içerir ve onların incelemeye devam etme ve yayımlama iş akışlarını etkinleştirir. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" {0} çalışma alanındaki yayımlanmamış değişiklikler - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} eklemeler: {new}, değişiklikler: {changed}, kaldırmalar: {removed} - Review + Review İncele - Discard selected changes + Discard selected changes Seçili değişiklikleri iptal et - Discard all changes + Discard all changes Bütün değişiklikleri iptal et - Publish all changes + Publish all changes Tüm değişiklikleri yayımla - Publish all changes to {0} + Publish all changes to {0} Bütün değişiklikleri {0} üzerine yayımla - Changed Content + Changed Content Değiştirilmiş İçerik - deleted + deleted silindi - created + created oluşturuldu - moved + moved taşındı - hidden + hidden gizli - edited + edited düzenlendi - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Bu çalışma alanında yayımlanmamış değişiklik yok. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? "{0}" çalışma alanındaki bütün değişiklikleri iptal etmek istediğinizden emin misiniz? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Aşağıdaki çalışma alanları buna dayandığından "{0}" çalışma alanı silinemez: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - "{0}" çalışma alanından yayımlanmamış düğümler yakalanırken bir hata oluştu, herhangi bir şey silinmedi. - Your personal workspace contains changes, please publish or discard them first. Kişisel çalışma alanınızda değişiklikler var, lütfen önce bunları yayımlayın veya iptal edin. @@ -226,10 +222,12 @@ Did not delete workspace "{0}" because it currently contains {1} node. - Şu anda {1} düğüm içerdiği için "{0}" çalışma alanı silinmedi. + Şu anda {1} düğüm içerdiği için "{0}" çalışma alanı silinmedi. + Did not delete workspace "{0}" because it currently contains {1} nodes. - Şu anda {1} düğüm içerdiği için "{0}" çalışma alanı silinmedi. + Şu anda {1} düğüm içerdiği için "{0}" çalışma alanı silinmedi. + The workspace "{0}" has been removed. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf index 49df55eafc2..55fc034cc0b 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Назад - Cancel + Cancel Відмінити @@ -20,187 +20,187 @@ - Workspaces + Workspaces Робочі області - Details for "{0}" + Details for "{0}" Деталі для "{0}" - Create new workspace + Create new workspace Створити нове робоче середовище - Create workspace + Create workspace Створити робоче середовище - Delete workspace + Delete workspace Видалити робоче середовище - Edit workspace + Edit workspace Редагувати робоче середовище - Yes, delete the workspace + Yes, delete the workspace Так, видалити робоче середовище - Edit workspace "{0}" + Edit workspace "{0}" Редагувати робоче середовище - Personal workspace + Personal workspace Особисте робоче середовище - Private workspace + Private workspace Приватне робоче середовище - Internal workspace + Internal workspace Внутрішнє робоче середовище - Read-only workspace + Read-only workspace Робоче середовище: тільки для читання - Title + Title Назва - Description + Description Опис - Base workspace + Base workspace Базове робоче середовище - Owner + Owner Власник - Visibility + Visibility Видимість - Private + Private Приватний - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Робоча область тільки для рецензентів і адміністраторів - Internal + Internal Внутрішній - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Будь-хто зареєстрований в редакторі може бачити та змінювати робоче середовище. - Changes + Changes Зміни - Open page in "{0}" workspace + Open page in "{0}" workspace Відкрити сторінку в робочому середовищі "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Це Ваше особисте робоче середовище яке неможливо видалити. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. Робоче середовище містить зміни. Спочатку відхиліть їх для видалення. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Робоче середовище не можна перебазувати на інше, бо воно досі містить зміни. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. Робоче середовище неможливо видалити, тому що інші середовища залежить від нього. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. У Вас немає прав для дозволу цього робочого середовища. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Ви дійсно хочете видалити робочу область "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. Робоче середовище, включаючи весь неопублікований вміст буде видалено. Цю операцію не можна скасувати. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. Цей модуль містить огляд всіх елементів в рамках поточної робочої області, і це дає можливість продовжити розгляд та видавничу діяльність. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Неопубліковані зміни в робочому середовищі "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} доповнення: {new}, зміни: {changed}, видалення: {removed} - Review + Review Огляд - Discard selected changes + Discard selected changes Скасувати вибрані зміни - Discard all changes + Discard all changes Скасувати всі зміни - Publish all changes + Publish all changes Опублікувати всі зміни - Publish all changes to {0} + Publish all changes to {0} Опублікувати всі зміни до {0} - Changed Content + Changed Content Змінений вміст - deleted + deleted видалено - created + created створено - moved + moved переміщено - hidden + hidden приховано - edited + edited відредаговано - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Немає ніяких неопублікованих змін у робочому середовищі. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Ви дійсно бажаєте скасувати всі зміни в робочій області "{0}"? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Робоче середовище "{0}" не можна видалити, оскільки на його основі такі робочі області: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Сталася помилка під час отримання неопублікованих вузлів з робочої області "{0}", нічого не було видалено. - Your personal workspace contains changes, please publish or discard them first. Ваш особистий робочий простір містить зміни, будь ласка, опублікуйте або скасуйте їх спочатку. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf index d9ba23c2a21..f2671f863a6 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf @@ -3,11 +3,11 @@ - Back + Back Quay lại - Cancel + Cancel Hủy bỏ @@ -20,187 +20,187 @@ - Workspaces + Workspaces Không gian làm việc - Details for "{0}" + Details for "{0}" Thông tin cho "{0}" - Create new workspace + Create new workspace Tạo một không gian làm việc mới - Create workspace + Create workspace Tạo không gian làm việc - Delete workspace + Delete workspace Xóa không gian làm việc - Edit workspace + Edit workspace Chỉnh sửa không gian làm việc - Yes, delete the workspace + Yes, delete the workspace Vâng, xóa không gian làm việc này - Edit workspace "{0}" + Edit workspace "{0}" Chỉnh sửa không gian làm việc - Personal workspace + Personal workspace Không gian làm việc cá nhân - Private workspace + Private workspace Không gian làm việc riêng - Internal workspace + Internal workspace Internal workspace - Read-only workspace + Read-only workspace Read-only workspace - Title + Title Tiêu đề - Description + Description Mô tả - Base workspace + Base workspace Base workspace - Owner + Owner Owner - Visibility + Visibility Khả năng hiển thị - Private + Private Riêng - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace Chỉ có người đánh giá và quản trị viên có thể truy cập và chỉnh sửa không gian làm việc này - Internal + Internal Nội bộ - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes Thay đổi - Open page in "{0}" workspace + Open page in "{0}" workspace Mở trang trong không gian làm việc "{0}" - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. Đây là không gian làm việc cá nhân mà bạn không thể xóa. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review Đánh giá - Discard selected changes + Discard selected changes Bỏ các thay đổi đã lựa chọn - Discard all changes + Discard all changes Loại bỏ tất cả thay đổi - Publish all changes + Publish all changes Công khai tất cả thay đổi - Publish all changes to {0} + Publish all changes to {0} Công khai tất cả thay đổi đến {0} - Changed Content + Changed Content Nội dung đã thay đổi - deleted + deleted đã xóa bỏ - created + created đã tạo - moved + moved đã di chuyển - hidden + hidden ẩn - edited + edited Đã chỉnh sửa - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. Không có những thay đổi không được công khai trong không gian làm việc này - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Bạn có thực sự muốn hủy bỏ tất cả những thay đổi trong không gian làm việc "{0}" @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf index 6906061494b..8f73159f416 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf @@ -3,11 +3,11 @@ - Back + Back 返回 - Cancel + Cancel 取消 @@ -20,187 +20,187 @@ - Workspaces + Workspaces 工作区 - Details for "{0}" + Details for "{0}" "{0}" 的详细信息 - Create new workspace + Create new workspace 创建新工作区 - Create workspace + Create workspace 创建工作区 - Delete workspace + Delete workspace 删除工作区 - Edit workspace + Edit workspace 编辑工作区 - Yes, delete the workspace + Yes, delete the workspace 是,删除工作区 - Edit workspace "{0}" + Edit workspace "{0}" 编辑工作区 - Personal workspace + Personal workspace 个人工作区 - Private workspace + Private workspace 私有工作区 - Internal workspace + Internal workspace 内部工作区 - Read-only workspace + Read-only workspace 只读的工作区 - Title + Title 标题 - Description + Description 说明 - Base workspace + Base workspace Base workspace - Owner + Owner 所有者 - Visibility + Visibility 可见性 - Private + Private Private - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace 只有审阅者和管理员可以访问和修改此工作区 - Internal + Internal Internal - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. - Changes + Changes 变更 - Open page in "{0}" workspace + Open page in "{0}" workspace 在 "{0}" 工作区中打开页 - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. 这是你个人的工作区,不能删除。 - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. 工作区中包含更改。若要删除,请先放弃更改。 - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. 不能删除工作区,因为其他工作区依赖于它。 - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. 您没有删除此工作区的权限。 - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? 你真的想要删除工作区 "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. 此操作将删除工作区,包括所有未发布的内容。此操作无法撤消。 - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. 此模块包含当前工作区所有元素的概述性描述、你也可以使用该模块继续审阅当前工作区的所有元素和实施发布流程中设定的步骤 - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" 未发布的更改 - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} 补充︰ {new},变化︰ {changed},移除︰ {removed} - Review + Review 审查 - Discard selected changes + Discard selected changes 放弃所选的更改 - Discard all changes + Discard all changes 舍弃所有更改 - Publish all changes + Publish all changes 发布所有更改 - Publish all changes to {0} + Publish all changes to {0} 发布所有更改到 {0} - Changed Content + Changed Content 已更改的内容 - deleted + deleted 已删除 - created + created 已创建 - moved + moved 已移动 - hidden + hidden 隐藏 - edited + edited 编辑 - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. 此工作区中没有未发布的更改。 - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? 你真的想要放弃"{0}"工作区中的所有更改? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} 不能删除工作区 "{0}",因为以下工作区基于此工作区︰ {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf index 5ac4a4fa202..df4624c22c2 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf @@ -3,11 +3,11 @@ - Back + Back 返回 - Cancel + Cancel 取消 @@ -20,187 +20,187 @@ - Workspaces + Workspaces 工作區 - Details for "{0}" + Details for "{0}" 「{0}」的細節 - Create new workspace + Create new workspace 建立新工作區 - Create workspace + Create workspace 建立工作區 - Delete workspace + Delete workspace 刪除工作區 - Edit workspace + Edit workspace 編輯工作區 - Yes, delete the workspace + Yes, delete the workspace 好的,刪除此工作區 - Edit workspace "{0}" + Edit workspace "{0}" 編輯工作區 - Personal workspace + Personal workspace 個人工作區 - Private workspace + Private workspace 私人工作區 - Internal workspace + Internal workspace 內部工作區 - Read-only workspace + Read-only workspace 唯讀工作區 - Title + Title 標題 - Description + Description 描述 - Base workspace + Base workspace 基本工作區 - Owner + Owner 擁有者 - Visibility + Visibility 能見度 - Private + Private 私人的 - Only reviewers and administrators can access and modify this workspace + Only reviewers and administrators can access and modify this workspace 只有審查者和管理員可以連結和修改此工作區 - Internal + Internal 內部的 - Any logged in editor can see and modify this workspace. + Any logged in editor can see and modify this workspace. 任何登入的編輯皆可檢視和修改此工作區。 - Changes + Changes 變更 - Open page in "{0}" workspace + Open page in "{0}" workspace 開啟頁面在「{0}」工作區 - This is your personal workspace which you can't delete. + This is your personal workspace which you can't delete. This is your personal workspace which you can't delete. - The workspace contains changes. To delete, discard the changes first. + The workspace contains changes. To delete, discard the changes first. The workspace contains changes. To delete, discard the changes first. - Workspace can't be rebased on a different workspace because it still contains changes. + Workspace can't be rebased on a different workspace because it still contains changes. Workspace can't be rebased on a different workspace because it still contains changes. - The workspace cannot be deleted because other workspaces depend on it. + The workspace cannot be deleted because other workspaces depend on it. The workspace cannot be deleted because other workspaces depend on it. - You don't have the permissions for deleting this workspace. + You don't have the permissions for deleting this workspace. You don't have the permissions for deleting this workspace. - Do you really want to delete the workspace "{0}"? + Do you really want to delete the workspace "{0}"? Do you really want to delete the workspace "{0}"? - This will delete the workspace including all unpublished content. This operation cannot be undone. + This will delete the workspace including all unpublished content. This operation cannot be undone. This will delete the workspace including all unpublished content. This operation cannot be undone. - This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. + This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. This module contains the overview of all elements within the current workspace and it enables to continue the review and publishing workflow for them. - Unpublished changes in workspace "{0}" + Unpublished changes in workspace "{0}" Unpublished changes in workspace "{0}" - additions: {new}, changes: {changed}, removals: {removed} + additions: {new}, changes: {changed}, removals: {removed} additions: {new}, changes: {changed}, removals: {removed} - Review + Review 審查 - Discard selected changes + Discard selected changes 放棄選取的變更 - Discard all changes + Discard all changes 捨棄全部變更 - Publish all changes + Publish all changes 發布全部變更 - Publish all changes to {0} + Publish all changes to {0} 發布全部變更至 {0} - Changed Content + Changed Content 變更的內容 - deleted + deleted 已刪除 - created + created 已建立 - moved + moved 已移動 - hidden + hidden 隱藏 - edited + edited 已編輯 - There are no unpublished changes in this workspace. + There are no unpublished changes in this workspace. There are no unpublished changes in this workspace. - Do you really want to discard all changes in the "{0}" workspace? + Do you really want to discard all changes in the "{0}" workspace? Do you really want to discard all changes in the "{0}" workspace? @@ -215,10 +215,6 @@ Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} Workspace "{0}" cannot be deleted because the following workspaces are based on it: {1} - - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - An error occurred while fetching unpublished nodes from workspace "{0}", nothing was deleted. - Your personal workspace contains changes, please publish or discard them first. Your personal workspace contains changes, please publish or discard them first.