From 0b0ca5dfb60a8811c47d24e3b4107a23129cd021 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 25 May 2024 11:37:25 +0200 Subject: [PATCH] TASK: Add workspace content stream mapping to content graph projection Previously the `DoctrineDbalContentGraphProjection` accessed the `workspace` table of a different projection in order to resolve the workspace<->content stream mapping. This change adds the `workspace` table to the content graph projection and uses that instead for the resolution. **Note:** This is not a breaking change because it comes with a migration and does not require a replay, but a ./flow cr:setup is needed in order to apply that! Related: #5038 --- .../src/ContentGraphFactory.php | 9 +- .../src/ContentGraphTableNames.php | 5 + .../DoctrineDbalContentGraphProjection.php | 121 +++++++++++++++++- .../DoctrineDbalContentGraphSchemaBuilder.php | 25 +++- .../Domain/Projection/Feature/Workspace.php | 112 ++++++++++++++++ .../Infrastructure/DbalSchemaFactory.php | 8 ++ .../SharedModel/Workspace/WorkspaceName.php | 3 +- 7 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php index 2599353780a..dcd8ea539cb 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php @@ -40,18 +40,11 @@ public function __construct( public function buildForWorkspace(WorkspaceName $workspaceName): ContentGraph { - // FIXME: Should be part of this projection, this is forbidden - $tableName = strtolower(sprintf( - 'cr_%s_p_%s', - $this->contentRepositoryId->value, - 'workspace' - )); - $currentContentStreamIdStatement = <<tableNames->workspace()} WHERE workspaceName = :workspaceName LIMIT 1 diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php index 0804d77e7e0..aa404052865 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php @@ -40,6 +40,11 @@ public function referenceRelation(): string return $this->tableNamePrefix . '_referencerelation'; } + public function workspace(): string + { + return $this->tableNamePrefix . '_workspace'; + } + public function checkpoint(): string { return $this->tableNamePrefix . '_checkpoint'; diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php index d2b530d7333..fedf6cdd0b3 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -11,6 +11,7 @@ use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\NodeRemoval; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\NodeVariation; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\SubtreeTagging; +use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\Feature\Workspace; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\HierarchyRelation; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\NodeRecord; use Neos\ContentGraph\DoctrineDbalAdapter\Domain\Projection\NodeRelationAnchorPoint; @@ -43,6 +44,16 @@ use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceWasRemoved; +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\Infrastructure\DbalCheckpointStorage; use Neos\ContentRepository\Core\Infrastructure\DbalSchemaDiff; use Neos\ContentRepository\Core\NodeType\NodeTypeName; @@ -65,10 +76,11 @@ */ final class DoctrineDbalContentGraphProjection implements ProjectionInterface, WithMarkStaleInterface { + use NodeMove; + use NodeRemoval; use NodeVariation; use SubtreeTagging; - use NodeRemoval; - use NodeMove; + use Workspace; public const RELATION_DEFAULT_OFFSET = 128; @@ -91,7 +103,19 @@ public function __construct( public function setUp(): void { - foreach ($this->determineRequiredSqlStatements() as $statement) { + $statements = $this->determineRequiredSqlStatements(); + + // MIGRATION from 2024-05-23: copy data from "cr__p_workspace" to "cr__p_graph_workspace" table + $legacyWorkspaceTableName = str_replace('_p_graph_workspace', '_p_workspace', $this->tableNames->workspace()); + if ( + $this->dbal->getSchemaManager()->tablesExist([$legacyWorkspaceTableName]) + && !$this->dbal->getSchemaManager()->tablesExist([$this->tableNames->workspace()]) + ) { + $statements[] = 'INSERT INTO ' . $this->tableNames->workspace() . ' (workspacename, baseworkspacename, currentcontentstreamid, status) SELECT workspacename, baseworkspacename, currentcontentstreamid, status FROM ' . $legacyWorkspaceTableName; + } + // /MIGRATION + + foreach ($statements as $statement) { try { $this->dbal->executeStatement($statement); } catch (DbalException $e) { @@ -169,8 +193,18 @@ public function canHandle(EventInterface $event): bool NodeSpecializationVariantWasCreated::class, RootNodeAggregateDimensionsWereUpdated::class, RootNodeAggregateWithNodeWasCreated::class, + RootWorkspaceWasCreated::class, SubtreeWasTagged::class, SubtreeWasUntagged::class, + WorkspaceBaseWorkspaceWasChanged::class, + WorkspaceRebaseFailed::class, + WorkspaceWasCreated::class, + WorkspaceWasDiscarded::class, + WorkspaceWasPartiallyDiscarded::class, + WorkspaceWasPartiallyPublished::class, + WorkspaceWasPublished::class, + WorkspaceWasRebased::class, + WorkspaceWasRemoved::class, ]); } @@ -193,8 +227,18 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void NodeSpecializationVariantWasCreated::class => $this->whenNodeSpecializationVariantWasCreated($event, $eventEnvelope), RootNodeAggregateDimensionsWereUpdated::class => $this->whenRootNodeAggregateDimensionsWereUpdated($event), RootNodeAggregateWithNodeWasCreated::class => $this->whenRootNodeAggregateWithNodeWasCreated($event, $eventEnvelope), + RootWorkspaceWasCreated::class => $this->whenRootWorkspaceWasCreated($event), SubtreeWasTagged::class => $this->whenSubtreeWasTagged($event), SubtreeWasUntagged::class => $this->whenSubtreeWasUntagged($event), + WorkspaceBaseWorkspaceWasChanged::class => $this->whenWorkspaceBaseWorkspaceWasChanged($event), + WorkspaceRebaseFailed::class => $this->whenWorkspaceRebaseFailed($event), + WorkspaceWasCreated::class => $this->whenWorkspaceWasCreated($event), + WorkspaceWasDiscarded::class => $this->whenWorkspaceWasDiscarded($event), + WorkspaceWasPartiallyDiscarded::class => $this->whenWorkspaceWasPartiallyDiscarded($event), + WorkspaceWasPartiallyPublished::class => $this->whenWorkspaceWasPartiallyPublished($event), + WorkspaceWasPublished::class => $this->whenWorkspaceWasPublished($event), + WorkspaceWasRebased::class => $this->whenWorkspaceWasRebased($event), + WorkspaceWasRemoved::class => $this->whenWorkspaceWasRemoved($event), default => throw new \InvalidArgumentException(sprintf('Unsupported event %s', get_debug_type($event))), }; } @@ -628,6 +672,11 @@ private function whenRootNodeAggregateWithNodeWasCreated(RootNodeAggregateWithNo ); } + private function whenRootWorkspaceWasCreated(RootWorkspaceWasCreated $event): void + { + $this->createWorkspace($event->workspaceName, null, $event->newContentStreamId); + } + private function whenSubtreeWasTagged(SubtreeWasTagged $event): void { $this->addSubtreeTag($event->contentStreamId, $event->nodeAggregateId, $event->affectedDimensionSpacePoints, $event->tag); @@ -638,6 +687,71 @@ private function whenSubtreeWasUntagged(SubtreeWasUntagged $event): void $this->removeSubtreeTag($event->contentStreamId, $event->nodeAggregateId, $event->affectedDimensionSpacePoints, $event->tag); } + private function whenWorkspaceBaseWorkspaceWasChanged(WorkspaceBaseWorkspaceWasChanged $event): void + { + $this->updateBaseWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); + } + + private function whenWorkspaceRebaseFailed(WorkspaceRebaseFailed $event): void + { + $this->markWorkspaceAsOutdatedConflict($event->workspaceName); + } + + private function whenWorkspaceWasCreated(WorkspaceWasCreated $event): void + { + $this->createWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId); + } + + private function whenWorkspaceWasDiscarded(WorkspaceWasDiscarded $event): void + { + $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); + $this->markWorkspaceAsOutdated($event->workspaceName); + $this->markDependentWorkspacesAsOutdated($event->workspaceName); + } + + private function whenWorkspaceWasPartiallyDiscarded(WorkspaceWasPartiallyDiscarded $event): void + { + $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId); + $this->markDependentWorkspacesAsOutdated($event->workspaceName); + } + + 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); + } + + 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); + } + + 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); + } + + private function whenWorkspaceWasRemoved(WorkspaceWasRemoved $event): void + { + $this->removeWorkspace($event->workspaceName); + } /** --------------------------------- */ @@ -661,6 +775,7 @@ private function truncateDatabaseTables(): void $this->dbal->executeQuery('TRUNCATE table ' . $this->tableNames->hierarchyRelation()); $this->dbal->executeQuery('TRUNCATE table ' . $this->tableNames->referenceRelation()); $this->dbal->executeQuery('TRUNCATE table ' . $this->tableNames->dimensionSpacePoints()); + $this->dbal->executeQuery('TRUNCATE table ' . $this->tableNames->workspace()); } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to truncate database tables for projection %s: %s', self::class, $e->getMessage()), 1716478318, $e); } diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php index 909bfcc9bdd..ff46c7d85b6 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php @@ -19,7 +19,7 @@ class DoctrineDbalContentGraphSchemaBuilder private const DEFAULT_TEXT_COLLATION = 'utf8mb4_unicode_520_ci'; public function __construct( - private readonly ContentGraphTableNames $contentGraphTableNames + private readonly ContentGraphTableNames $tableNames ) { } @@ -29,13 +29,14 @@ public function buildSchema(AbstractSchemaManager $schemaManager): Schema $this->createNodeTable(), $this->createHierarchyRelationTable(), $this->createReferenceRelationTable(), - $this->createDimensionSpacePointsTable() + $this->createDimensionSpacePointsTable(), + $this->createWorkspaceTable(), ]); } private function createNodeTable(): Table { - $table = self::createTable($this->contentGraphTableNames->node(), [ + $table = self::createTable($this->tableNames->node(), [ DbalSchemaFactory::columnForNodeAnchorPoint('relationanchorpoint')->setAutoincrement(true), DbalSchemaFactory::columnForNodeAggregateId('nodeaggregateid')->setNotnull(false), DbalSchemaFactory::columnForDimensionSpacePointHash('origindimensionspacepointhash')->setNotnull(false), @@ -57,7 +58,7 @@ private function createNodeTable(): Table private function createHierarchyRelationTable(): Table { - $table = self::createTable($this->contentGraphTableNames->hierarchyRelation(), [ + $table = self::createTable($this->tableNames->hierarchyRelation(), [ (new Column('position', self::type(Types::INTEGER)))->setNotnull(true), DbalSchemaFactory::columnForContentStreamId('contentstreamid')->setNotnull(true), DbalSchemaFactory::columnForDimensionSpacePointHash('dimensionspacepointhash')->setNotnull(true), @@ -76,7 +77,7 @@ private function createHierarchyRelationTable(): Table private function createDimensionSpacePointsTable(): Table { - $table = self::createTable($this->contentGraphTableNames->dimensionSpacePoints(), [ + $table = self::createTable($this->tableNames->dimensionSpacePoints(), [ DbalSchemaFactory::columnForDimensionSpacePointHash('hash')->setNotnull(true), DbalSchemaFactory::columnForDimensionSpacePoint('dimensionspacepoint')->setNotnull(true) ]); @@ -87,7 +88,7 @@ private function createDimensionSpacePointsTable(): Table private function createReferenceRelationTable(): Table { - $table = self::createTable($this->contentGraphTableNames->referenceRelation(), [ + $table = self::createTable($this->tableNames->referenceRelation(), [ (new Column('name', self::type(Types::STRING)))->setLength(255)->setNotnull(true)->setCustomSchemaOption('charset', 'ascii')->setCustomSchemaOption('collation', 'ascii_general_ci'), (new Column('position', self::type(Types::INTEGER)))->setNotnull(true), DbalSchemaFactory::columnForNodeAnchorPoint('nodeanchorpoint'), @@ -99,6 +100,18 @@ private function createReferenceRelationTable(): Table ->setPrimaryKey(['name', 'position', 'nodeanchorpoint']); } + private function createWorkspaceTable(): Table + { + $workspaceTable = self::createTable($this->tableNames->workspace(), [ + DbalSchemaFactory::columnForWorkspaceName('workspacename')->setNotnull(true), + DbalSchemaFactory::columnForWorkspaceName('baseworkspacename')->setNotnull(false), + DbalSchemaFactory::columnForContentStreamId('currentcontentstreamid')->setNotNull(true), + (new Column('status', self::type(Types::BINARY)))->setLength(20)->setNotnull(false), + ]); + + return $workspaceTable->setPrimaryKey(['workspacename']); + } + /** * @param array $columns */ diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php new file mode 100644 index 00000000000..bfe81c8dc5d --- /dev/null +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php @@ -0,0 +1,112 @@ +dbal->insert($this->tableNames->workspace(), [ + 'workspaceName' => $workspaceName->value, + 'baseWorkspaceName' => $baseWorkspaceName?->value, + 'currentContentStreamId' => $contentStreamId->value, + 'status' => WorkspaceStatus::UP_TO_DATE->value + ]); + } + + private function removeWorkspace(WorkspaceName $workspaceName): void + { + $this->dbal->delete( + $this->tableNames->workspace(), + ['workspaceName' => $workspaceName->value] + ); + } + + private function updateBaseWorkspace(WorkspaceName $workspaceName, WorkspaceName $baseWorkspaceName, ContentStreamId $newContentStreamId): void + { + $this->dbal->update( + $this->tableNames->workspace(), + [ + 'baseWorkspaceName' => $baseWorkspaceName->value, + 'currentContentStreamId' => $newContentStreamId->value, + ], + ['workspaceName' => $workspaceName->value] + ); + } + + private function updateWorkspaceContentStreamId( + WorkspaceName $workspaceName, + ContentStreamId $contentStreamId, + ): void { + $this->dbal->update($this->tableNames->workspace(), [ + 'currentContentStreamId' => $contentStreamId->value, + ], [ + 'workspaceName' => $workspaceName->value + ]); + } + + private function markWorkspaceAsUpToDate(WorkspaceName $workspaceName): void + { + $this->dbal->executeUpdate(' + UPDATE ' . $this->tableNames->workspace() . ' + SET status = :upToDate + WHERE + workspacename = :workspaceName + ', [ + 'upToDate' => WorkspaceStatus::UP_TO_DATE->value, + 'workspaceName' => $workspaceName->value + ]); + } + + private function markDependentWorkspacesAsOutdated(WorkspaceName $baseWorkspaceName): void + { + $this->dbal->executeUpdate(' + 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->executeUpdate(' + UPDATE ' . $this->tableNames->workspace() . ' + SET + status = :outdated + WHERE + workspacename = :workspaceName + ', [ + 'outdated' => WorkspaceStatus::OUTDATED->value, + 'workspaceName' => $workspaceName->value + ]); + } + + private function markWorkspaceAsOutdatedConflict(WorkspaceName $workspaceName): void + { + $this->dbal->executeUpdate(' + UPDATE ' . $this->tableNames->workspace() . ' + SET + status = :outdatedConflict + WHERE + workspacename = :workspaceName + ', [ + 'outdatedConflict' => WorkspaceStatus::OUTDATED_CONFLICT->value, + 'workspaceName' => $workspaceName->value + ]); + } +} diff --git a/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php b/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php index 8f2d455f009..9a18ac9d6f5 100644 --- a/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php +++ b/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php @@ -14,6 +14,7 @@ use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** * Provide doctrine DBAL column schema definitions for common types in the content repository to @@ -57,6 +58,13 @@ public static function columnForContentStreamId(string $columnName): Column ->setLength(36); } + public static function columnForWorkspaceName(string $columnName): Column + { + return (new Column($columnName, Type::getType(Types::STRING))) + ->setLength(WorkspaceName::MAX_LENGTH) + ->setCustomSchemaOption('collation', 'utf8mb4_unicode_520_ci'); + } + /** * An anchorpoint can be used in a given projection to link two nodes, it is a purely internal identifier and should * be as performant as possible in queries, the current code uses UUIDs, diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php index 37ac2fc8c24..3841fd02a95 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php @@ -23,6 +23,7 @@ */ final class WorkspaceName implements \JsonSerializable { + public const MAX_LENGTH = 200; public const WORKSPACE_NAME_LIVE = 'live'; /** @@ -33,7 +34,7 @@ final class WorkspaceName implements \JsonSerializable private function __construct( public readonly string $value ) { - if (preg_match('/^[\p{L}\p{P}\d \.]{1,200}$/u', $value) !== 1) { + if (preg_match('/^[\p{L}\p{P}\d .]{1,' . self::MAX_LENGTH . '}$/u', $value) !== 1) { throw new \InvalidArgumentException('Invalid workspace name given.', 1505826610318); } }