diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php index 5f1b45b09a5..4aee9e1b41c 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphFactory.php @@ -42,18 +42,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 82ec0d0b389..e591fdec386 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphTableNames.php @@ -42,6 +42,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 2cd030ee3bc..ed89f7adcf4 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php @@ -10,6 +10,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; @@ -42,8 +43,15 @@ 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; @@ -66,10 +74,11 @@ */ final class DoctrineDbalContentGraphProjection implements ProjectionInterface { + use NodeMove; + use NodeRemoval; use NodeVariation; use SubtreeTagging; - use NodeRemoval; - use NodeMove; + use Workspace; public const RELATION_DEFAULT_OFFSET = 128; @@ -92,7 +101,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) { @@ -164,11 +185,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, - WorkspaceWasRebased::class + WorkspaceWasPartiallyPublished::class, + WorkspaceWasPublished::class, + WorkspaceWasRebased::class, + WorkspaceWasRemoved::class, ]); } @@ -191,14 +219,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), - // the following three events are not actually handled, but we need to include them in {@see canHandle()} in order - // to trigger the catchup hooks for those (i.e. {@see GraphProjectorCatchUpHookForCacheFlushing}). This can - // be removed with https://github.com/neos/neos-development-collection/issues/4992 - WorkspaceWasDiscarded::class, - WorkspaceWasPartiallyDiscarded::class, - WorkspaceWasRebased::class => null, + 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))), }; } @@ -632,6 +664,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); @@ -642,6 +679,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); + } /** --------------------------------- */ @@ -662,6 +764,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 adbf2add9b8..13b561e12f9 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php @@ -22,7 +22,7 @@ class DoctrineDbalContentGraphSchemaBuilder private const DEFAULT_TEXT_COLLATION = 'utf8mb4_unicode_520_ci'; public function __construct( - private readonly ContentGraphTableNames $contentGraphTableNames + private readonly ContentGraphTableNames $tableNames ) { } @@ -36,13 +36,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), @@ -64,7 +65,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), @@ -84,7 +85,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) ]); @@ -95,7 +96,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)->setPlatformOption('charset', 'ascii')->setPlatformOption('collation', 'ascii_general_ci'), (new Column('position', self::type(Types::INTEGER)))->setNotnull(true), DbalSchemaFactory::columnForNodeAnchorPoint('nodeanchorpoint'), @@ -107,6 +108,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 34b59dc9ab0..767bde52864 100644 --- a/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php +++ b/Neos.ContentRepository.Core/Classes/Infrastructure/DbalSchemaFactory.php @@ -63,6 +63,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,