From 67a8dae5c7d9b5510635cf84ed666eb97b00ad67 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Thu, 12 Sep 2024 12:28:25 +0200 Subject: [PATCH 01/39] FEATURE: AssetUsage as CatchUpHook --- .../src/ExportService.php | 9 +- .../src/ExportServiceFactory.php | 7 +- .../src/Processors/AssetExportProcessor.php | 10 +- .../Classes/Controller/UsageController.php | 2 +- .../Private/Templates/Usage/RelatedNodes.html | 26 +- .../AssetUsageIndexingProcessor.php | 88 +++++ .../Classes/AssetUsage/AssetUsageService.php | 28 ++ .../Classes/AssetUsage/AssetUsageStrategy.php | 2 +- .../CatchUpHook/AssetUsageCatchUpHook.php | 184 ++++++++++ .../AssetUsageCatchUpHookFactory.php | 35 ++ .../Command/AssetUsageCommandController.php | 59 +--- .../AssetUsage/{Dto => Domain}/AssetUsage.php | 8 +- .../Domain/AssetUsageRepository.php | 272 +++++++++++++++ .../AssetUsage/Dto/AssetUsageFilter.php | 34 +- .../AssetUsage/Dto/AssetUsageNodeAddress.php | 29 -- .../AssetUsage/Dto/AssetUsageReference.php | 8 +- .../Classes/AssetUsage/Dto/AssetUsages.php | 1 + .../AssetUsage/GlobalAssetUsageService.php | 27 +- .../Projection/AssetUsageFinder.php | 35 -- .../Projection/AssetUsageProjection.php | 319 ------------------ .../AssetUsageProjectionFactory.php | 36 -- .../Projection/AssetUsageRepository.php | 285 ---------------- .../AssetUsageRepositoryFactory.php | 27 -- .../Service/AssetUsageIndexingService.php | 313 +++++++++++++++++ .../Service/AssetUsageSyncService.php | 70 ---- .../Service/AssetUsageSyncServiceFactory.php | 35 -- .../Classes/Command/CrCommandController.php | 5 +- .../AssetChangeHandlerForCacheFlushing.php | 65 ++-- .../Service/ImageVariantGarbageCollector.php | 2 +- .../Settings.ContentRepositoryRegistry.yaml | 4 +- .../Mysql/Version20240906102606.php | 56 +++ .../Module/Shared/DocumentBreadcrumb.html | 2 +- ...ggregateWithNode_WithoutDimensions.feature | 78 +++++ ...deAggregateWithNode_WithDimensions.feature | 89 +++++ ...01-CreateNodeGeneralizationVariant.feature | 83 +++++ ...02-CreateNodeSpecializationVariant.feature | 71 ++++ ...alizationVariant_InternalWorkspace.feature | 80 +++++ .../04-CreateNodePeerVariant.feature | 70 ++++ ...etNodeProperties_WithoutDimensions.feature | 155 +++++++++ ...2-SetNodeProperties_WithDimensions.feature | 162 +++++++++ ...oveNodeAggregate_WithoutDimensions.feature | 99 ++++++ .../01-MoveDimensionSpacePoints.feature | 187 ++++++++++ .../01-Indexing_WithoutDimensions.feature | 87 +++++ .../02-Indexing_WithDimensions.feature | 104 ++++++ ...PublishWorkspace_WithoutDimensions.feature | 121 +++++++ ...02-PublishWorkspace_WithDimensions.feature | 191 +++++++++++ ...desFromWorkspace_WithoutDimensions.feature | 123 +++++++ ...lNodesFromWorkspace_WithDimensions.feature | 194 +++++++++++ ...DiscardWorkspace_WithoutDimensions.feature | 125 +++++++ ...02-DiscardWorkspace_WithDimensions.feature | 216 ++++++++++++ ...desFromWorkspace_WithoutDimensions.feature | 220 ++++++++++++ ...lNodesFromWorkspace_WithDimensions.feature | 188 +++++++++++ .../Features/Bootstrap/AssetUsageTrait.php | 78 +++++ .../Features/Bootstrap/FeatureContext.php | 1 + 54 files changed, 3832 insertions(+), 973 deletions(-) create mode 100644 Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php create mode 100644 Neos.Neos/Classes/AssetUsage/AssetUsageService.php create mode 100644 Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php create mode 100644 Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php rename Neos.Neos/Classes/AssetUsage/{Dto => Domain}/AssetUsage.php (63%) create mode 100644 Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Dto/AssetUsageNodeAddress.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Projection/AssetUsageFinder.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjection.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjectionFactory.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepository.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepositoryFactory.php create mode 100644 Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncService.php delete mode 100644 Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncServiceFactory.php create mode 100644 Neos.Neos/Migrations/Mysql/Version20240906102606.php create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/01-CreateNodeAggregateWithNode_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/02-CreateNodeAggregateWithNode_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/01-CreateNodeGeneralizationVariant.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/02-CreateNodeSpecializationVariant.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/03-CreateNodeSpecializationVariant_InternalWorkspace.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/04-CreateNodePeerVariant.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/01-SetNodeProperties_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/02-SetNodeProperties_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/04-NodeRemoval/01-RemoveNodeAggregate_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/DimensionSpacePoints/01-MoveDimensionSpacePoints.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/01-Indexing_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/02-Indexing_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/01-PublishWorkspace_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/02-PublishWorkspace_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/01-DiscardWorkspace_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/02-DiscardWorkspace_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/03-DiscardIndividualNodesFromWorkspace_WithoutDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/04-DiscardIndividualNodesFromWorkspace_WithDimensions.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/AssetUsageTrait.php diff --git a/Neos.ContentRepository.Export/src/ExportService.php b/Neos.ContentRepository.Export/src/ExportService.php index 00a45bcbb16..97dcff6bc87 100644 --- a/Neos.ContentRepository.Export/src/ExportService.php +++ b/Neos.ContentRepository.Export/src/ExportService.php @@ -6,11 +6,12 @@ use League\Flysystem\Filesystem; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceFinder; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Export\Processors\AssetExportProcessor; use Neos\ContentRepository\Export\Processors\EventExportProcessor; use Neos\EventStore\EventStoreInterface; use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageFinder; +use Neos\Neos\AssetUsage\AssetUsageService; /** * @internal @@ -19,10 +20,11 @@ class ExportService implements ContentRepositoryServiceInterface { public function __construct( + private readonly ContentRepositoryId $contentRepositoryId, private readonly Filesystem $filesystem, private readonly WorkspaceFinder $workspaceFinder, private readonly AssetRepository $assetRepository, - private readonly AssetUsageFinder $assetUsageFinder, + private readonly AssetUsageService $assetUsageService, private readonly EventStoreInterface $eventStore, ) { } @@ -37,10 +39,11 @@ public function runAllProcessors(\Closure $outputLineFn, bool $verbose = false): $this->eventStore ), 'Exporting assets' => new AssetExportProcessor( + $this->contentRepositoryId, $this->filesystem, $this->assetRepository, $this->workspaceFinder, - $this->assetUsageFinder + $this->assetUsageService ) ]; diff --git a/Neos.ContentRepository.Export/src/ExportServiceFactory.php b/Neos.ContentRepository.Export/src/ExportServiceFactory.php index baa013bca98..d93b47c2d40 100644 --- a/Neos.ContentRepository.Export/src/ExportServiceFactory.php +++ b/Neos.ContentRepository.Export/src/ExportServiceFactory.php @@ -8,7 +8,7 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceFinder; use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageFinder; +use Neos\Neos\AssetUsage\AssetUsageService; /** * @internal @@ -21,17 +21,18 @@ public function __construct( private readonly Filesystem $filesystem, private readonly WorkspaceFinder $workspaceFinder, private readonly AssetRepository $assetRepository, - private readonly AssetUsageFinder $assetUsageFinder, + private readonly AssetUsageService $assetUsageService, ) { } public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ExportService { return new ExportService( + $serviceFactoryDependencies->contentRepositoryId, $this->filesystem, $this->workspaceFinder, $this->assetRepository, - $this->assetUsageFinder, + $this->assetUsageService, $serviceFactoryDependencies->eventStore, ); } diff --git a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php index db7679a18b9..ff2fa71dab9 100644 --- a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php @@ -4,6 +4,7 @@ use League\Flysystem\Filesystem; use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceFinder; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\Export\Asset\ValueObject\SerializedAsset; use Neos\ContentRepository\Export\Asset\ValueObject\SerializedImageVariant; @@ -15,8 +16,8 @@ use Neos\Media\Domain\Model\AssetVariantInterface; use Neos\Media\Domain\Model\ImageVariant; use Neos\Media\Domain\Repository\AssetRepository; +use Neos\Neos\AssetUsage\AssetUsageService; use Neos\Neos\AssetUsage\Dto\AssetUsageFilter; -use Neos\Neos\AssetUsage\Projection\AssetUsageFinder; /** * Processor that exports all assets and resources used in the Neos live workspace to the file system @@ -29,10 +30,11 @@ final class AssetExportProcessor implements ProcessorInterface private array $callbacks = []; public function __construct( + private readonly ContentRepositoryId $contentRepositoryId, private readonly Filesystem $files, private readonly AssetRepository $assetRepository, private readonly WorkspaceFinder $workspaceFinder, - private readonly AssetUsageFinder $assetUsageFinder, + private readonly AssetUsageService $assetUsageService, ) {} public function onMessage(\Closure $callback): void @@ -47,13 +49,13 @@ public function run(): ProcessorResult if ($liveWorkspace === null) { return ProcessorResult::error('Failed to find live workspace'); } - $assetFilter = AssetUsageFilter::create()->withContentStream($liveWorkspace->currentContentStreamId)->groupByAsset(); + $assetFilter = AssetUsageFilter::create()->withWorkspaceName($liveWorkspace->workspaceName)->groupByAsset(); $numberOfExportedAssets = 0; $numberOfExportedImageVariants = 0; $numberOfErrors = 0; - foreach ($this->assetUsageFinder->findByFilter($assetFilter) as $assetUsage) { + foreach ($this->assetUsageService->findByFilter($this->contentRepositoryId, $assetFilter) as $assetUsage) { /** @var Asset|null $asset */ $asset = $this->assetRepository->findByIdentifier($assetUsage->assetId); if ($asset === null) { diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index b3f65a5b53c..89b826814c6 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -98,7 +98,7 @@ public function relatedNodesAction(AssetInterface $asset) $contentRepository = $this->contentRepositoryRegistry->get($usage->getContentRepositoryId()); - $workspace = $contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($usage->getContentStreamId()); + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($usage->getWorkspaceName()); // FIXME: AssetUsageReference->workspaceName ? $nodeAggregate = $contentRepository->getContentGraph($workspace->workspaceName)->findNodeAggregateById( diff --git a/Neos.Media.Browser/Resources/Private/Templates/Usage/RelatedNodes.html b/Neos.Media.Browser/Resources/Private/Templates/Usage/RelatedNodes.html index 921537fb5c8..2a1b2c27af1 100644 --- a/Neos.Media.Browser/Resources/Private/Templates/Usage/RelatedNodes.html +++ b/Neos.Media.Browser/Resources/Private/Templates/Usage/RelatedNodes.html @@ -36,23 +36,21 @@ - {neos:backend.translate(id: 'workspaces.personalWorkspace', source: 'Modules', package: 'Neos.Neos')} + {neos:backend.translate(id: 'workspaces.personalWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')} - {neos:backend.translate(id: 'workspaces.privateWorkspace', source: 'Modules', package: - 'Neos.Neos')} + {neos:backend.translate(id: 'workspaces.privateWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')} - {neos:backend.translate(id: 'workspaces.internalWorkspace', source: 'Modules', package: - 'Neos.Neos')} + {neos:backend.translate(id: 'workspaces.internalWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')} --- @@ -96,8 +94,8 @@ - {nodeInformation.documentNode.label} + title="{neos:backend.translate(id: 'workspaces.openPageInWorkspace', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: nodeInformation.workspace.workspaceTitle.value})}"> + {neos:node.label(node: nodeInformation.documentNode)} @@ -113,7 +111,7 @@ title="{f:if(condition: nodeInformation.node.nodeType.label, then: '{neos:backend.translate(id: nodeInformation.node.nodeType.label, package: \'Neos.Neos\')}', else: '{nodeInformation.node.nodeType.name}')}" data-neos-toggle="tooltip" data-placement="left"> - {nodeInformation.node.label} + {neos:node.label(node: nodeInformation.node)} @@ -124,19 +122,19 @@ diff --git a/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php new file mode 100644 index 00000000000..4e50288ec2a --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php @@ -0,0 +1,88 @@ +getVariationGraph(); + + $workspaceFinder = $contentRepository->getWorkspaceFinder(); + $liveWorkspace = $workspaceFinder->findOneByName(WorkspaceName::forLive()); + if ($liveWorkspace === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo(WorkspaceName::forLive()); + } + + $this->assetUsageIndexingService->pruneIndex($contentRepository->id); + + $workspaces = [$liveWorkspace]; + + $this->dispatchMessage($callback, sprintf('ContentRepository "%s"', $contentRepository->id->value)); + while ($workspaces !== []) { + $workspace = array_shift($workspaces); + + $contentGraph = $contentRepository->getContentGraph($workspace->workspaceName); + $dimensionSpacePoints = $variationGraph->getDimensionSpacePoints(); + + $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType( + $nodeTypeName + ); + if ($rootNodeAggregate === null) { + throw new NodeAggregateCurrentlyDoesNotExist("RootNode aggregate not found"); + } + $rootNodeAggregateId = $rootNodeAggregate->nodeAggregateId; + + $this->dispatchMessage($callback, sprintf(' Workspace: %s', $contentGraph->getWorkspaceName()->value)); + + foreach ($dimensionSpacePoints as $dimensionSpacePoint) { + $this->dispatchMessage($callback, sprintf(' DimensionSpacePoint: %s', $dimensionSpacePoint->toJson())); + + $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); + $childNodes = iterator_to_array($subgraph->findChildNodes($rootNodeAggregateId, FindChildNodesFilter::create())); + + while ($childNodes !== []) { + /** @var Node $childNode */ + $childNode = array_shift($childNodes); + if (!$childNode->originDimensionSpacePoint->equals($childNode->dimensionSpacePoint)) { + continue; + } + $this->assetUsageIndexingService->updateIndex($contentRepository->id, $childNode); + + array_push($childNodes, ...iterator_to_array($subgraph->findChildNodes($childNode->aggregateId, FindChildNodesFilter::create()))); + } + } + + array_push($workspaces, ...array_values($workspaceFinder->findByBaseWorkspace($workspace->workspaceName))); + } + } + + private function dispatchMessage(?callable $callback, string $value): void + { + if ($callback === null) { + return; + } + + $callback($value); + } +} diff --git a/Neos.Neos/Classes/AssetUsage/AssetUsageService.php b/Neos.Neos/Classes/AssetUsage/AssetUsageService.php new file mode 100644 index 00000000000..a20dde844b4 --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/AssetUsageService.php @@ -0,0 +1,28 @@ +assetUsageRepository->findUsages($contentRepositoryId, $filter); + } +} diff --git a/Neos.Neos/Classes/AssetUsage/AssetUsageStrategy.php b/Neos.Neos/Classes/AssetUsage/AssetUsageStrategy.php index 00e4e2dde35..444d12a435f 100644 --- a/Neos.Neos/Classes/AssetUsage/AssetUsageStrategy.php +++ b/Neos.Neos/Classes/AssetUsage/AssetUsageStrategy.php @@ -51,7 +51,7 @@ public function getUsageReferences(AssetInterface $asset): array $convertedUsages[] = new AssetUsageReference( $asset, ContentRepositoryId::fromString($contentRepositoryId), - $usage->contentStreamId, + $usage->workspaceName, $usage->originDimensionSpacePoint, $usage->nodeAggregateId ); diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php new file mode 100644 index 00000000000..8e86b7be36c --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -0,0 +1,184 @@ +contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) + ) { + return; + } + + match ($eventInstance::class) { + NodeAggregateWasRemoved::class => $this->removeNodes($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->affectedCoveredDimensionSpacePoints), + WorkspaceWasPartiallyDiscarded::class => $this->discardNodes($eventInstance->getWorkspaceName(), $eventInstance->discardedNodes), + default => null + }; + } + + public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void + { + if (!self::$enabled) { + // performance optimization: on full replay, we assume all caches to be flushed anyways + // - so we do not need to do it individually here. + return; + } + + if ( + $eventInstance instanceof EmbedsWorkspaceName + && $eventInstance instanceof EmbedsContentStreamId + && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) + ) { + return; + } + + match ($eventInstance::class) { + NodeAggregateWithNodeWasCreated::class => $this->updateNode($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->originDimensionSpacePoint->toDimensionSpacePoint()), + NodePeerVariantWasCreated::class => $this->updateNode($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->peerOrigin->toDimensionSpacePoint()), + NodeGeneralizationVariantWasCreated::class => $this->updateNode($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->generalizationOrigin->toDimensionSpacePoint()), + NodeSpecializationVariantWasCreated::class => $this->updateNode($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->specializationOrigin->toDimensionSpacePoint()), + NodePropertiesWereSet::class => $this->updateNode($eventInstance->getWorkspaceName(), $eventInstance->nodeAggregateId, $eventInstance->originDimensionSpacePoint->toDimensionSpacePoint()), + WorkspaceWasDiscarded::class => $this->discardWorkspace($eventInstance->getWorkspaceName()), + DimensionSpacePointWasMoved::class => $this->updateDimensionSpacePoint($eventInstance->getWorkspaceName(), $eventInstance->source, $eventInstance->target), + default => null + }; + } + + + public function onBeforeBatchCompleted(): void + { + } + + public function onAfterCatchUp(): void + { + } + + private function updateNode(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, DimensionSpacePoint $dimensionSpacePoint): void + { + $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + $node = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())->findNodeById($nodeAggregateId); + + if ($node === null) { + throw new \Exception("Node not found"); + } + + $this->assetUsageIndexingService->updateIndex( + $this->contentRepository->id, + $node + ); + } + + private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $nodeAggregateId, DimensionSpacePointSet $dimensionSpacePoints): void + { + $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + + + foreach ($dimensionSpacePoints as $dimensionSpacePoint) { + $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); + $node = $subgraph->findNodeById($nodeAggregateId); + $descendants = $subgraph->findDescendantNodes($nodeAggregateId, FindDescendantNodesFilter::create()); + + $nodes = array_merge([$node], iterator_to_array($descendants)); + + /** @var Node $node */ + foreach ($nodes as $node) { + $this->assetUsageIndexingService->removeIndexForNode( + $this->contentRepository->id, + $node + ); + } + } + } + + private function discardWorkspace(WorkspaceName $workspaceName): void + { + $this->assetUsageIndexingService->removeIndexForWorkspace($this->contentRepository->id, $workspaceName); + } + + private function discardNodes(WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIds): void + { + foreach ($nodeIds as $nodeId) { + $this->assetUsageIndexingService->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( + $this->contentRepository->id, + $workspaceName, + $nodeId->nodeAggregateId, + $nodeId->dimensionSpacePoint + ); + } + } + + private function updateDimensionSpacePoint(WorkspaceName $workspaceName, DimensionSpacePoint $source, DimensionSpacePoint $target): void + { + $this->assetUsageIndexingService->updateDimensionSpacePointInIndex($this->contentRepository->id, $workspaceName, $source, $target); + } +} diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php new file mode 100644 index 00000000000..89bcec32e86 --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHookFactory.php @@ -0,0 +1,35 @@ +assetUsageIndexingService + ); + } +} diff --git a/Neos.Neos/Classes/AssetUsage/Command/AssetUsageCommandController.php b/Neos.Neos/Classes/AssetUsage/Command/AssetUsageCommandController.php index 2997cabdb70..043edc809b9 100644 --- a/Neos.Neos/Classes/AssetUsage/Command/AssetUsageCommandController.php +++ b/Neos.Neos/Classes/AssetUsage/Command/AssetUsageCommandController.php @@ -4,64 +4,37 @@ namespace Neos\Neos\AssetUsage\Command; +use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Cli\CommandController; -use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageRepositoryFactory; -use Neos\Neos\AssetUsage\Service\AssetUsageSyncServiceFactory; +use Neos\Neos\AssetUsage\AssetUsageIndexingProcessor; +use Neos\Neos\Domain\Service\NodeTypeNameFactory; final class AssetUsageCommandController extends CommandController { public function __construct( - private readonly AssetRepository $assetRepository, - private readonly AssetUsageRepositoryFactory $assetUsageRepositoryFactory, private readonly ContentRepositoryRegistry $contentRepositoryRegistry, + private readonly AssetUsageIndexingProcessor $assetUsageIndexingProcessor ) { parent::__construct(); } - /** - * Remove asset usages that are no longer valid - * - * This is the case for usages that refer to - * * deleted nodes (i.e. nodes that were implicitly removed because an ancestor node was deleted) - * * invalid dimension space points (e.g. because dimension configuration has been changed) - * * removed content streams - * - * @param bool $quiet if Set, only errors will be outputted - */ - public function syncCommand(string $contentRepository = 'default', bool $quiet = false): void + public function indexCommand(string $contentRepository = 'default', string $nodeTypeName = NodeTypeNameFactory::NAME_SITES): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $assetUsageSyncService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new AssetUsageSyncServiceFactory( - $this->assetRepository, - $this->assetUsageRepositoryFactory - ) - ); + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $usages = $assetUsageSyncService->findAllUsages(); - if (!$quiet) { - $this->output->progressStart($usages->count()); - } - $numberOfRemovedUsages = 0; - foreach ($usages as $usage) { - if (!$assetUsageSyncService->isAssetUsageStillValid($usage)) { - $assetUsageSyncService->removeAssetUsage($usage); - $numberOfRemovedUsages++; - } - if (!$quiet) { - $this->output->progressAdvance(); + $this->outputFormatted("Start indexing asset usages"); + + $this->assetUsageIndexingProcessor->buildIndex( + $contentRepository, + NodeTypeName::fromString($nodeTypeName), + function (string $message) { + $this->outputFormatted($message); } - } - if (!$quiet) { - $this->output->progressFinish(); - $this->outputLine(); - $this->outputLine('Removed %d asset usage%s', [ - $numberOfRemovedUsages, $numberOfRemovedUsages === 1 ? '' : 's' - ]); - } + ); + + $this->outputFormatted("Finished."); } } diff --git a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsage.php b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsage.php similarity index 63% rename from Neos.Neos/Classes/AssetUsage/Dto/AssetUsage.php rename to Neos.Neos/Classes/AssetUsage/Domain/AssetUsage.php index 82aaaece5c9..3f0eea0cd51 100644 --- a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsage.php +++ b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsage.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Neos\Neos\AssetUsage\Dto; +namespace Neos\Neos\AssetUsage\Domain; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** @@ -16,8 +17,9 @@ final readonly class AssetUsage { public function __construct( + public ContentRepositoryId $contentRepositoryId, public string $assetId, - public ContentStreamId $contentStreamId, + public WorkspaceName $workspaceName, public OriginDimensionSpacePoint $originDimensionSpacePoint, public NodeAggregateId $nodeAggregateId, public string $propertyName, diff --git a/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php new file mode 100644 index 00000000000..44692ee79b6 --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php @@ -0,0 +1,272 @@ +dbal->createQueryBuilder(); + $queryBuilder + ->select('*') + ->from(self::TABLE); + $queryBuilder->andWhere('contentrepositoryid = :contentRepositoryId'); + $queryBuilder->setParameter('contentRepositoryId', $contentRepositoryId->value); + if ($filter->hasAssetId()) { + if ($filter->includeVariantsOfAsset === true) { + $queryBuilder->andWhere( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq('assetid', ':assetId'), + $queryBuilder->expr()->eq('originalassetid', ':assetId'), + ) + ); + } else { + $queryBuilder->andWhere('assetid = :assetId'); + } + + $queryBuilder->setParameter('assetId', $filter->assetId); + } + if ($filter->hasWorkspaceName()) { + $queryBuilder->andWhere('workspacename = :workspaceName'); + $queryBuilder->setParameter('workspaceName', $filter->workspaceName?->value); + } + if ($filter->groupByAsset) { + $queryBuilder->addGroupBy('assetid'); + } + if ($filter->groupByNodeAggregate) { + $queryBuilder->addGroupBy('nodeaggregateid'); + } + if ($filter->groupByWorkspaceName) { + $queryBuilder->addGroupBy('workspacename'); + } + if ($filter->groupByNode) { + $queryBuilder->addGroupBy('nodeaggregateid'); + $queryBuilder->addGroupBy('origindimensionspacepointhash'); + } + return new AssetUsages(function () use ($queryBuilder) { + $result = $queryBuilder->execute(); + if (!$result instanceof Result) { + throw new \RuntimeException(sprintf( + 'Expected instance of "%s", got: "%s"', + Result::class, + get_debug_type($result) + ), 1646320966); + } + /** @var array{contentrepositoryid: string,assetid: string, workspacename: string, origindimensionspacepointhash: string, origindimensionspacepoint: string, nodeaggregateid: string, propertyname: string} $row */ + foreach ($result->iterateAssociative() as $row) { + yield new AssetUsage( + ContentRepositoryId::fromString($row['contentrepositoryid']), + $row['assetid'], + WorkspaceName::fromString($row['workspacename']), + OriginDimensionSpacePoint::fromJsonString($row['origindimensionspacepoint']), + NodeAggregateId::fromString($row['nodeaggregateid']), + $row['propertyname'] + ); + } + }, function () use ($queryBuilder) { + /** @var string $count */ + $count = $this->dbal->fetchOne( + 'SELECT COUNT(*) FROM (' . $queryBuilder->getSQL() . ') s', + $queryBuilder->getParameters() + ); + return (int)$count; + }); + } + + /** + * @param WorkspaceName[] $workspaceNames + * @return array + */ + public function findUsageForNodeInWorkspaces(ContentRepositoryId $contentRepositoryId, Node $node, array $workspaceNames): array + { + $sql = <<getTableName()} + WHERE + contentrepositoryid = :contentRepositoryId + AND nodeaggregateid = :nodeAggregateId + AND origindimensionspacepointhash = :originDimensionSpacePointHash + AND workspacename in (:workspaceNames) + SQL; + + $result = $this->dbal->executeQuery($sql, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'nodeAggregateId' => $node->aggregateId->value, + 'originDimensionSpacePointHash' => $node->dimensionSpacePoint->hash, + 'workspaceNames' => array_map(fn ($workspaceName) => $workspaceName->value, $workspaceNames), + ], [ + 'propertyNames' => Connection::PARAM_STR_ARRAY, + 'workspaceNames' => Connection::PARAM_STR_ARRAY, + ]); + + $usages = []; + foreach ($result->iterateAssociative() as $row) { + $usages[] = new AssetUsage( + ContentRepositoryId::fromString($row['contentrepositoryid']), + $row['assetid'], + WorkspaceName::fromString($row['workspacename']), + OriginDimensionSpacePoint::fromJsonString($row['origindimensionspacepoint']), + NodeAggregateId::fromString($row['nodeaggregateid']), + $row['propertyname'] + ); + } + return $usages; + } + + public function addUsagesForNodeWithAssetOnProperty(ContentRepositoryId $contentRepositoryId, Node $node, string $propertyName, string $assetId, ?string $originalAssetId = null): void + { + try { + $this->dbal->insert(self::TABLE, [ + 'contentrepositoryid' => $contentRepositoryId->value, + 'assetid' => $assetId, + 'originalassetid' => $originalAssetId, + 'workspacename' => $node->workspaceName->value, + 'nodeaggregateid' => $node->aggregateId->value, + 'origindimensionspacepoint' => $node->dimensionSpacePoint->toJson(), + 'origindimensionspacepointhash' => $node->dimensionSpacePoint->hash, + 'propertyname' => $propertyName, + ]); + } catch (UniqueConstraintViolationException $e) { + // A usage already exists for this node and property -> can be ignored + } + } + + public function updateAssetUsageDimensionSpacePoint(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, DimensionSpacePoint $source, DimensionSpacePoint $target): void + { + $this->dbal->update($this->getTableName(), [ + 'origindimensionspacepoint' => $target->toJson(), + 'origindimensionspacepointhash' => $target->hash, + ], [ + 'contentrepositoryid' => $contentRepositoryId->value, + 'workspacename' => $workspaceName->value, + 'origindimensionspacepointhash' => $source->hash, + ]); + } + + public function removeAssetUsagesOfWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName, + ): void { + $sql = <<getTableName()} + WHERE contentrepositoryid = :contentRepositoryId + AND workspacename = :workspaceName + SQL; + + $this->dbal->executeStatement($sql, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } + + public function removeAssetUsagesOfWorkspaceWithAllProperties( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName, + NodeAggregateId $nodeAggregateId, + DimensionSpacePoint $dimensionSpacePoint + ): void { + $sql = <<getTableName()} + WHERE contentrepositoryid = :contentRepositoryId + AND workspacename = :workspaceName + AND nodeAggregateId = :nodeAggregateId + AND originDimensionSpacePointHash = :originDimensionSpacePointHash + SQL; + + $this->dbal->executeStatement($sql, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'originDimensionSpacePointHash' => $dimensionSpacePoint->hash, + ]); + } + + /** + * @param WorkspaceName[] $workspaceNames + */ + public function removeAssetUsagesForNodeAggregateIdAndDimensionSpacePointWithAssetOnPropertyInWorkspaces( + ContentRepositoryId $contentRepositoryId, + NodeAggregateId $nodeAggregateId, + DimensionSpacePoint $dimensionSpacePoint, + string $propertyName, + string $assetId, + array $workspaceNames + ): void { + $sql = <<getTableName()} + WHERE contentrepositoryid = :contentRepositoryId + AND workspacename in (:workspaceNames) + AND nodeaggregateid = :nodeAggregateId + AND origindimensionspacepointhash = :originDimensionSpacePointHash + AND propertyname = :propertyName + AND assetId = :assetId + SQL; + + $this->dbal->executeStatement($sql, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'nodeAggregateId' => $nodeAggregateId->value, + 'originDimensionSpacePointHash' => $dimensionSpacePoint->hash, + 'workspaceNames' => array_map(fn ($workspaceName) => $workspaceName->value, $workspaceNames), + 'propertyName' => $propertyName, + 'assetId' => $assetId, + ], [ + 'workspaceNames' => Connection::PARAM_STR_ARRAY, + ]); + } + + public function remove(AssetUsage $usage): void + { + $this->dbal->delete(self::TABLE, [ + 'contentrepositoryid' => $usage->contentRepositoryId->value, + 'assetid' => $usage->assetId, + 'workspacename' => $usage->workspaceName->value, + 'nodeaggregateid' => $usage->nodeAggregateId->value, + 'origindimensionspacepointhash' => $usage->originDimensionSpacePoint->hash, + 'propertyname' => $usage->propertyName, + ]); + } + + public function removeAsset(ContentRepositoryId $contentRepositoryId, string $assetId): void + { + // TODO: What about OriginalAssetId? + $this->dbal->delete(self::TABLE, [ + 'contentrepositoryid' => $contentRepositoryId->value, + 'assetId' => $assetId, + ]); + } + + public function removeAll(ContentRepositoryId $contentRepositoryId): void + { + $this->dbal->delete(self::TABLE, [ + 'contentrepositoryid' => $contentRepositoryId->value, + ]); + } + + private function getTableName(): string + { + return self::TABLE; + } +} diff --git a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageFilter.php b/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageFilter.php index 1e65ab39f75..92822bf969c 100644 --- a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageFilter.php +++ b/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageFilter.php @@ -4,7 +4,7 @@ namespace Neos\Neos\AssetUsage\Dto; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** @@ -15,41 +15,53 @@ { private function __construct( public ?string $assetId, - public ?ContentStreamId $contentStreamId, + public ?WorkspaceName $workspaceName, public bool $groupByAsset, public bool $groupByNode, + public bool $groupByNodeAggregate, + public bool $groupByWorkspaceName, public bool $includeVariantsOfAsset, ) { } public static function create(): self { - return new self(null, null, false, false, false); + return new self(null, null, false, false, false, false, false); } public function withAsset(string $assetId): self { - return new self($assetId, $this->contentStreamId, $this->groupByAsset, $this->groupByNode, $this->includeVariantsOfAsset); + return new self($assetId, $this->workspaceName, $this->groupByAsset, $this->groupByNode, $this->groupByNodeAggregate, $this->groupByWorkspaceName, $this->includeVariantsOfAsset); } - public function withContentStream(ContentStreamId $contentStreamId): self + public function withWorkspaceName(WorkspaceName $workspaceName): self { - return new self($this->assetId, $contentStreamId, $this->groupByAsset, $this->groupByNode, $this->includeVariantsOfAsset); + return new self($this->assetId, $workspaceName, $this->groupByAsset, $this->groupByNode, $this->groupByNodeAggregate, $this->groupByWorkspaceName, $this->includeVariantsOfAsset); } public function includeVariantsOfAsset(): self { - return new self($this->assetId, $this->contentStreamId, $this->groupByAsset, $this->groupByNode, true); + return new self($this->assetId, $this->workspaceName, $this->groupByAsset, $this->groupByNode, $this->groupByNodeAggregate, $this->groupByWorkspaceName, true); } public function groupByAsset(): self { - return new self($this->assetId, $this->contentStreamId, true, $this->groupByNode, $this->includeVariantsOfAsset); + return new self($this->assetId, $this->workspaceName, true, $this->groupByNode, $this->groupByNodeAggregate, $this->groupByWorkspaceName, $this->includeVariantsOfAsset); } public function groupByNode(): self { - return new self($this->assetId, $this->contentStreamId, $this->groupByAsset, true, $this->includeVariantsOfAsset); + return new self($this->assetId, $this->workspaceName, $this->groupByAsset, true, $this->groupByNodeAggregate, $this->groupByWorkspaceName, $this->includeVariantsOfAsset); + } + + public function groupByNodeAggregate(): self + { + return new self($this->assetId, $this->workspaceName, $this->groupByAsset, $this->groupByNode, true, $this->groupByWorkspaceName, $this->includeVariantsOfAsset); + } + + public function groupByWorkspaceName(): self + { + return new self($this->assetId, $this->workspaceName, $this->groupByAsset, $this->groupByNode, $this->groupByNodeAggregate, true, $this->includeVariantsOfAsset); } public function hasAssetId(): bool @@ -57,8 +69,8 @@ public function hasAssetId(): bool return $this->assetId !== null; } - public function hasContentStreamId(): bool + public function hasWorkspaceName(): bool { - return $this->contentStreamId !== null; + return $this->workspaceName !== null; } } diff --git a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageNodeAddress.php b/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageNodeAddress.php deleted file mode 100644 index a501ce52b38..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsageNodeAddress.php +++ /dev/null @@ -1,29 +0,0 @@ -contentRepositoryId; } - public function getContentStreamId(): ContentStreamId + public function getWorkspaceName(): WorkspaceName { - return $this->contentStreamId; + return $this->workspaceName; } public function getOriginDimensionSpacePoint(): OriginDimensionSpacePoint diff --git a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsages.php b/Neos.Neos/Classes/AssetUsage/Dto/AssetUsages.php index fe2ca514853..a3c6870d5bd 100644 --- a/Neos.Neos/Classes/AssetUsage/Dto/AssetUsages.php +++ b/Neos.Neos/Classes/AssetUsage/Dto/AssetUsages.php @@ -5,6 +5,7 @@ namespace Neos\Neos\AssetUsage\Dto; use Neos\Flow\Annotations as Flow; +use Neos\Neos\AssetUsage\Domain\AssetUsage; /** * @implements \IteratorAggregate diff --git a/Neos.Neos/Classes/AssetUsage/GlobalAssetUsageService.php b/Neos.Neos/Classes/AssetUsage/GlobalAssetUsageService.php index db5a13af4e3..f7121535b13 100644 --- a/Neos.Neos/Classes/AssetUsage/GlobalAssetUsageService.php +++ b/Neos.Neos/Classes/AssetUsage/GlobalAssetUsageService.php @@ -5,15 +5,12 @@ namespace Neos\Neos\AssetUsage; use Neos\ContentRepository\Core\ContentRepository; -use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; +use Neos\Neos\AssetUsage\Domain\AssetUsageRepository; use Neos\Neos\AssetUsage\Dto\AssetUsageFilter; use Neos\Neos\AssetUsage\Dto\AssetUsagesByContentRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageFinder; -use Neos\Neos\AssetUsage\Projection\AssetUsageRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageRepositoryFactory; /** * Central authority to look up or remove asset usages in all configured Content Repositories @@ -21,24 +18,19 @@ * @api This is used by the {@see AssetUsageStrategy} */ #[Flow\Scope('singleton')] -class GlobalAssetUsageService implements ContentRepositoryServiceInterface +class GlobalAssetUsageService { /** * @var array */ private ?array $contentRepositories = null; - /** - * @var array - */ - private array $assetUsageRepositories = []; - /** * @param array $contentRepositoryIds in the format ['' => true, '' => false] */ public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly AssetUsageRepositoryFactory $assetUsageRepositoryFactory, + private readonly AssetUsageRepository $assetUsageRepository, private readonly array $contentRepositoryIds ) { } @@ -47,7 +39,7 @@ public function findByFilter(AssetUsageFilter $filter): AssetUsagesByContentRepo { $assetUsages = []; foreach ($this->getContentRepositories() as $contentRepositoryId => $contentRepository) { - $assetUsages[$contentRepositoryId] = $contentRepository->projectionState(AssetUsageFinder::class)->findByFilter($filter); + $assetUsages[$contentRepositoryId] = $this->assetUsageRepository->findUsages($contentRepository->id, $filter); } return new AssetUsagesByContentRepository($assetUsages); } @@ -55,7 +47,7 @@ public function findByFilter(AssetUsageFilter $filter): AssetUsagesByContentRepo public function removeAssetUsageByAssetId(string $assetId): void { foreach ($this->getContentRepositories() as $contentRepositoryId => $contentRepository) { - $this->getAssetUsageRepository(ContentRepositoryId::fromString($contentRepositoryId))->removeAsset($assetId); + $this->assetUsageRepository->removeAsset(ContentRepositoryId::fromString($contentRepositoryId), $assetId); } } @@ -81,13 +73,4 @@ private function getContentRepositories(): array return $this->contentRepositories; } - - private function getAssetUsageRepository(ContentRepositoryId $contentRepositoryId): AssetUsageRepository - { - if (!array_key_exists($contentRepositoryId->value, $this->assetUsageRepositories)) { - $this->assetUsageRepositories[$contentRepositoryId->value] = $this->assetUsageRepositoryFactory->build($contentRepositoryId); - } - - return $this->assetUsageRepositories[$contentRepositoryId->value]; - } } diff --git a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageFinder.php b/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageFinder.php deleted file mode 100644 index 3968832dafc..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageFinder.php +++ /dev/null @@ -1,35 +0,0 @@ -getProjectionState(AssetUsageProjection::class) - * - * To look up usages for all configured Content Repositories, use {@see GlobalAssetUsageService} instead - * - * @api - */ -final class AssetUsageFinder implements ProjectionStateInterface -{ - public function __construct( - private readonly AssetUsageRepository $repository, - ) { - } - - public function findByFilter(AssetUsageFilter $filter): AssetUsages - { - return $this->repository->findUsages($filter); - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjection.php b/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjection.php deleted file mode 100644 index ec762433ff9..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjection.php +++ /dev/null @@ -1,319 +0,0 @@ - - * @internal - */ -final class AssetUsageProjection implements ProjectionInterface -{ - private ?AssetUsageFinder $stateAccessor = null; - private AssetUsageRepository $repository; - private DbalCheckpointStorage $checkpointStorage; - /** @var array */ - private array $originalAssetIdMappingRuntimeCache = []; - - public function __construct( - private readonly AssetRepository $assetRepository, - ContentRepositoryId $contentRepositoryId, - Connection $dbal, - AssetUsageRepositoryFactory $assetUsageRepositoryFactory, - ) { - $this->repository = $assetUsageRepositoryFactory->build($contentRepositoryId); - $this->checkpointStorage = new DbalCheckpointStorage( - $dbal, - $this->repository->getTableNamePrefix() . '_checkpoint', - self::class - ); - } - - public function reset(): void - { - $this->repository->reset(); - $this->checkpointStorage->acquireLock(); - $this->checkpointStorage->updateAndReleaseLock(SequenceNumber::none()); - } - - public function whenNodeAggregateWithNodeWasCreated(NodeAggregateWithNodeWasCreated $event, EventEnvelope $eventEnvelope): void - { - try { - $assetIdsByProperty = $this->getAssetIdsByProperty($event->initialPropertyValues); - } catch (InvalidTypeException $e) { - throw new \RuntimeException( - sprintf( - 'Failed to extract asset ids from event "%s": %s', - $eventEnvelope->event->id->value, - $e->getMessage() - ), - 1646321894, - $e - ); - } - $nodeAddress = new AssetUsageNodeAddress( - $event->getContentStreamId(), - $event->getOriginDimensionSpacePoint()->toDimensionSpacePoint(), - $event->getNodeAggregateId() - ); - $this->repository->addUsagesForNode($nodeAddress, $assetIdsByProperty); - } - - public function whenNodePropertiesWereSet(NodePropertiesWereSet $event, EventEnvelope $eventEnvelope): void - { - try { - $assetIdsByProperty = $this->getAssetIdsByProperty($event->propertyValues); - } catch (InvalidTypeException $e) { - throw new \RuntimeException( - sprintf( - 'Failed to extract asset ids from event "%s": %s', - $eventEnvelope->event->id->value, - $e->getMessage() - ), - 1646321894, - $e - ); - } - $nodeAddress = new AssetUsageNodeAddress( - $event->getContentStreamId(), - $event->getOriginDimensionSpacePoint()->toDimensionSpacePoint(), - $event->getNodeAggregateId() - ); - $this->repository->addUsagesForNode($nodeAddress, $assetIdsByProperty); - } - - public function whenNodeAggregateWasRemoved(NodeAggregateWasRemoved $event): void - { - $this->repository->removeNode( - $event->getNodeAggregateId(), - $event->affectedOccupiedDimensionSpacePoints->toDimensionSpacePointSet() - ); - } - - - public function whenNodePeerVariantWasCreated(NodePeerVariantWasCreated $event): void - { - $this->repository->copyDimensions($event->sourceOrigin, $event->peerOrigin); - } - - public function whenContentStreamWasForked(ContentStreamWasForked $event): void - { - $this->repository->copyContentStream( - $event->sourceContentStreamId, - $event->newContentStreamId - ); - } - - public function whenWorkspaceWasDiscarded(WorkspaceWasDiscarded $event): void - { - $this->repository->removeContentStream($event->previousContentStreamId); - } - - public function whenWorkspaceWasPartiallyDiscarded(WorkspaceWasPartiallyDiscarded $event): void - { - $this->repository->removeContentStream($event->previousContentStreamId); - } - - public function whenWorkspaceWasPartiallyPublished(WorkspaceWasPartiallyPublished $event): void - { - $this->repository->removeContentStream($event->previousSourceContentStreamId); - } - - public function whenWorkspaceWasPublished(WorkspaceWasPublished $event): void - { - $this->repository->removeContentStream($event->previousSourceContentStreamId); - } - - public function whenWorkspaceWasRebased(WorkspaceWasRebased $event): void - { - $this->repository->removeContentStream($event->previousContentStreamId); - } - - public function whenContentStreamWasRemoved(ContentStreamWasRemoved $event): void - { - $this->repository->removeContentStream($event->contentStreamId); - } - - - // ---------------- - - /** - * @throws InvalidTypeException - */ - private function getAssetIdsByProperty(SerializedPropertyValues $propertyValues): AssetIdsByProperty - { - /** @var array> $assetIds */ - $assetIds = []; - foreach ($propertyValues as $propertyName => $propertyValue) { - $extractedAssetIds = $this->extractAssetIds( - $propertyValue->type, - $propertyValue->value, - ); - - $assetIds[$propertyName] = array_map( - fn($assetId) => new AssetIdAndOriginalAssetId($assetId, $this->findOriginalAssetId($assetId)), - $extractedAssetIds - ); - } - return new AssetIdsByProperty($assetIds); - } - - /** - * @param mixed $value - * @return array - * @throws InvalidTypeException - */ - private function extractAssetIds(string $type, mixed $value): array - { - if (is_string($value)) { - preg_match_all('/asset:\/\/(?[\w-]*)/i', $value, $matches, PREG_SET_ORDER); - return array_map(static fn(array $match) => $match['assetId'], $matches); - } - if (is_subclass_of($type, ResourceBasedInterface::class)) { - return isset($value['__identifier']) ? [$value['__identifier']] : []; - } - - // Collection type? - /** @var array{type: string, elementType: string|null, nullable: bool} $parsedType */ - $parsedType = TypeHandling::parseType($type); - if ($parsedType['elementType'] === null) { - return []; - } - if ( - !is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class) - && !is_subclass_of($parsedType['elementType'], \Stringable::class) - ) { - return []; - } - /** @var array> $assetIds */ - $assetIds = []; - /** @var iterable $value */ - foreach ($value as $elementValue) { - $assetIds[] = $this->extractAssetIds($parsedType['elementType'], $elementValue); - } - return array_merge(...$assetIds); - } - - public function setUp(): void - { - $this->repository->setUp(); - $this->checkpointStorage->setUp(); - } - - public function status(): ProjectionStatus - { - $checkpointStorageStatus = $this->checkpointStorage->status(); - if ($checkpointStorageStatus->type === CheckpointStorageStatusType::ERROR) { - return ProjectionStatus::error($checkpointStorageStatus->details); - } - if ($checkpointStorageStatus->type === CheckpointStorageStatusType::SETUP_REQUIRED) { - return ProjectionStatus::setupRequired($checkpointStorageStatus->details); - } - try { - $falseOrDetailsString = $this->repository->isSetupRequired(); - if (is_string($falseOrDetailsString)) { - return ProjectionStatus::setupRequired($falseOrDetailsString); - } - } catch (\Throwable $e) { - return ProjectionStatus::error(sprintf('Failed to determine required SQL statements: %s', $e->getMessage())); - } - return ProjectionStatus::ok(); - } - - public function canHandle(EventInterface $event): bool - { - return in_array($event::class, [ - NodeAggregateWithNodeWasCreated::class, - NodePropertiesWereSet::class, - NodeAggregateWasRemoved::class, - NodePeerVariantWasCreated::class, - ContentStreamWasForked::class, - WorkspaceWasDiscarded::class, - WorkspaceWasPartiallyDiscarded::class, - WorkspaceWasPartiallyPublished::class, - WorkspaceWasPublished::class, - WorkspaceWasRebased::class, - ContentStreamWasRemoved::class, - ]); - } - - public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void - { - match ($event::class) { - NodeAggregateWithNodeWasCreated::class => $this->whenNodeAggregateWithNodeWasCreated($event, $eventEnvelope), - NodePropertiesWereSet::class => $this->whenNodePropertiesWereSet($event, $eventEnvelope), - NodeAggregateWasRemoved::class => $this->whenNodeAggregateWasRemoved($event), - NodePeerVariantWasCreated::class => $this->whenNodePeerVariantWasCreated($event), - ContentStreamWasForked::class => $this->whenContentStreamWasForked($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), - ContentStreamWasRemoved::class => $this->whenContentStreamWasRemoved($event), - default => throw new \InvalidArgumentException(sprintf('Unsupported event %s', get_debug_type($event))), - }; - } - - public function getCheckpointStorage(): DbalCheckpointStorage - { - return $this->checkpointStorage; - } - - public function getState(): AssetUsageFinder - { - if (!$this->stateAccessor) { - $this->stateAccessor = new AssetUsageFinder($this->repository); - } - return $this->stateAccessor; - } - - private function findOriginalAssetId(string $assetId): ?string - { - if (!array_key_exists($assetId, $this->originalAssetIdMappingRuntimeCache)) { - try { - /** @var AssetInterface|null $asset */ - $asset = $this->assetRepository->findByIdentifier($assetId); - } /** @noinspection PhpRedundantCatchClauseInspection */ catch (ORMException) { - return null; - } - /** @phpstan-ignore-next-line */ - $this->originalAssetIdMappingRuntimeCache[$assetId] = $asset instanceof AssetVariantInterface ? $asset->getOriginalAsset()->getIdentifier() : null; - } - - return $this->originalAssetIdMappingRuntimeCache[$assetId]; - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjectionFactory.php b/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjectionFactory.php deleted file mode 100644 index d49df6574fc..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageProjectionFactory.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @internal - */ -final class AssetUsageProjectionFactory implements ProjectionFactoryInterface -{ - public function __construct( - private readonly Connection $dbal, - private readonly AssetUsageRepositoryFactory $assetUsageRepositoryFactory, - private readonly AssetRepository $assetRepository, - ) { - } - - public function build( - ProjectionFactoryDependencies $projectionFactoryDependencies, - array $options, - ): AssetUsageProjection { - return new AssetUsageProjection( - $this->assetRepository, - $projectionFactoryDependencies->contentRepositoryId, - $this->dbal, - $this->assetUsageRepositoryFactory, - ); - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepository.php b/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepository.php deleted file mode 100644 index e650fe81daf..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepository.php +++ /dev/null @@ -1,285 +0,0 @@ -dbal, $this->databaseSchema()) as $statement) { - $this->dbal->executeStatement($statement); - } - } - - /** - * @return false|non-empty-string false if everything is okay, otherwise the details string, why a setup is required - */ - public function isSetupRequired(): false|string - { - $requiredSqlStatements = DbalSchemaDiff::determineRequiredSqlStatements($this->dbal, $this->databaseSchema()); - if ($requiredSqlStatements !== []) { - return sprintf('The following SQL statement%s required: %s', count($requiredSqlStatements) !== 1 ? 's are' : ' is', implode(chr(10), $requiredSqlStatements)); - } - return false; - } - - private function databaseSchema(): Schema - { - $schemaManager = $this->dbal->createSchemaManager(); - $table = new Table($this->tableNamePrefix, [ - (new Column('assetid', Type::getType(Types::STRING)))->setLength(40)->setNotnull(true)->setDefault(''), - (new Column('originalassetid', Type::getType(Types::STRING)))->setLength(40)->setNotnull(false)->setDefault(null), - DbalSchemaFactory::columnForContentStreamId('contentstreamid')->setNotNull(true), - DbalSchemaFactory::columnForNodeAggregateId('nodeaggregateid')->setNotNull(true), - DbalSchemaFactory::columnForDimensionSpacePoint('origindimensionspacepoint')->setNotNull(false), - DbalSchemaFactory::columnForDimensionSpacePointHash('origindimensionspacepointhash')->setNotNull(true), - (new Column('propertyname', Type::getType(Types::STRING)))->setLength(255)->setNotnull(true)->setDefault('') - ]); - - $table - ->addUniqueIndex(['assetid', 'originalassetid', 'contentstreamid', 'nodeaggregateid', 'origindimensionspacepointhash', 'propertyname'], 'assetperproperty') - ->addIndex(['assetid']) - ->addIndex(['originalassetid']) - ->addIndex(['contentstreamid']) - ->addIndex(['nodeaggregateid']) - ->addIndex(['origindimensionspacepointhash']); - - return DbalSchemaFactory::createSchemaWithTables($schemaManager, [$table]); - } - - public function findUsages(AssetUsageFilter $filter): AssetUsages - { - $queryBuilder = $this->dbal->createQueryBuilder(); - $queryBuilder - ->select('*') - ->from($this->tableNamePrefix); - if ($filter->hasAssetId()) { - if ($filter->includeVariantsOfAsset === true) { - $queryBuilder->andWhere( - $queryBuilder->expr()->or( - $queryBuilder->expr()->eq('assetId', ':assetId'), - $queryBuilder->expr()->eq('originalAssetId', ':assetId'), - ) - ); - } else { - $queryBuilder->andWhere('assetId = :assetId'); - } - - $queryBuilder->setParameter('assetId', $filter->assetId); - } - if ($filter->hasContentStreamId()) { - $queryBuilder->andWhere('contentStreamId = :contentStreamId'); - $queryBuilder->setParameter('contentStreamId', $filter->contentStreamId?->value); - } - if ($filter->groupByAsset) { - $queryBuilder->addGroupBy('assetId'); - } - if ($filter->groupByNode) { - $queryBuilder->addGroupBy('nodeaggregateid'); - $queryBuilder->addGroupBy('origindimensionspacepointhash'); - } - return new AssetUsages(function () use ($queryBuilder) { - $result = $queryBuilder->execute(); - if (!$result instanceof Result) { - throw new \RuntimeException(sprintf( - 'Expected instance of "%s", got: "%s"', - Result::class, - get_debug_type($result) - ), 1646320966); - } - /** @var array{assetid: string, contentstreamid: string, origindimensionspacepointhash: string, origindimensionspacepoint: string, nodeaggregateid: string, propertyname: string} $row */ - foreach ($result->iterateAssociative() as $row) { - yield new AssetUsage( - $row['assetid'], - ContentStreamId::fromString($row['contentstreamid']), - OriginDimensionSpacePoint::fromJsonString($row['origindimensionspacepoint']), - NodeAggregateId::fromString($row['nodeaggregateid']), - $row['propertyname'] - ); - } - }, function () use ($queryBuilder) { - /** @var string $count */ - $count = $this->dbal->fetchOne( - 'SELECT COUNT(*) FROM (' . $queryBuilder->getSQL() . ') s', - $queryBuilder->getParameters() - ); - return (int)$count; - }); - } - - public function addUsagesForNode(AssetUsageNodeAddress $nodeAddress, AssetIdsByProperty $assetIdsByProperty): void - { - // Delete all asset usage entries for newly set properties to ensure that removed or replaced assets are reflected - $this->dbal->executeStatement('DELETE FROM ' . $this->tableNamePrefix - . ' WHERE contentStreamId = :contentStreamId' - . ' AND nodeAggregateId = :nodeAggregateId' - . ' AND originDimensionSpacePointHash = :originDimensionSpacePointHash' - . ' AND propertyName IN (:propertyNames)', [ - 'contentStreamId' => $nodeAddress->contentStreamId->value, - 'nodeAggregateId' => $nodeAddress->nodeAggregateId->value, - 'originDimensionSpacePointHash' => $nodeAddress->dimensionSpacePoint->hash, - 'propertyNames' => $assetIdsByProperty->propertyNames(), - ], [ - 'propertyNames' => Connection::PARAM_STR_ARRAY, - ]); - - foreach ($assetIdsByProperty as $propertyName => $assetIdAndOriginalAssetIds) { - /** @var AssetIdAndOriginalAssetId $assetIdAndOriginalAssetId */ - foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { - try { - $this->dbal->insert($this->tableNamePrefix, [ - 'assetId' => $assetIdAndOriginalAssetId->assetId, - 'originalAssetId' => $assetIdAndOriginalAssetId->originalAssetId, - 'contentStreamId' => $nodeAddress->contentStreamId->value, - 'nodeAggregateId' => $nodeAddress->nodeAggregateId->value, - 'originDimensionSpacePoint' => $nodeAddress->dimensionSpacePoint->toJson(), - 'originDimensionSpacePointHash' => $nodeAddress->dimensionSpacePoint->hash, - 'propertyName' => $propertyName, - ]); - } catch (UniqueConstraintViolationException $e) { - // A usage already exists for this node and property -> can be ignored - } - } - } - } - - public function removeContentStream(ContentStreamId $contentStreamId): void - { - $this->dbal->delete($this->tableNamePrefix, ['contentStreamId' => $contentStreamId->value]); - } - - public function copyContentStream( - ContentStreamId $sourceContentStreamId, - ContentStreamId $targetContentStreamId, - ): void { - $this->dbal->executeStatement( - 'INSERT INTO ' . $this->tableNamePrefix . ' (assetid, originalassetid, contentstreamid, nodeaggregateid, origindimensionspacepoint, origindimensionspacepointhash, propertyname)' - . ' SELECT assetid, originalassetid, :targetContentStreamId AS contentstreamid,' - . ' nodeaggregateid, origindimensionspacepoint, origindimensionspacepointhash, propertyname' - . ' FROM ' . $this->tableNamePrefix - . ' WHERE contentStreamId = :sourceContentStreamId', - [ - 'sourceContentStreamId' => $sourceContentStreamId->value, - 'targetContentStreamId' => $targetContentStreamId->value, - ] - ); - } - - public function copyDimensions( - OriginDimensionSpacePoint $sourceOriginDimensionSpacePoint, - OriginDimensionSpacePoint $targetOriginDimensionSpacePoint, - ): void { - try { - $this->dbal->executeStatement( - 'INSERT INTO ' . $this->tableNamePrefix . ' (assetid, originalassetid, contentstreamid, nodeaggregateid, origindimensionspacepoint, origindimensionspacepointhash, propertyname)' - . ' SELECT assetid, originalassetid, contentstreamid, nodeaggregateid,' - . ' :targetOriginDimensionSpacePoint AS origindimensionspacepoint,' - . ' :targetOriginDimensionSpacePointHash AS origindimensionspacepointhash, propertyname' - . ' FROM ' . $this->tableNamePrefix - . ' WHERE originDimensionSpacePointHash = :sourceOriginDimensionSpacePointHash', - [ - 'sourceOriginDimensionSpacePointHash' => $sourceOriginDimensionSpacePoint->hash, - 'targetOriginDimensionSpacePoint' => $targetOriginDimensionSpacePoint->toJson(), - 'targetOriginDimensionSpacePointHash' => $targetOriginDimensionSpacePoint->hash, - ] - ); - } catch (UniqueConstraintViolationException $e) { - // A usage already exists for this node and property -> can be ignored - } - } - - public function remove(AssetUsage $usage): void - { - $this->dbal->delete($this->tableNamePrefix, [ - 'assetId' => $usage->assetId, - 'contentStreamId' => $usage->contentStreamId->value, - 'nodeAggregateId' => $usage->nodeAggregateId->value, - 'originDimensionSpacePointHash' => $usage->originDimensionSpacePoint->hash, - 'propertyName' => $usage->propertyName, - ]); - } - - public function removeAsset(string $assetId): void - { - $this->dbal->delete($this->tableNamePrefix, [ - 'assetId' => $assetId, - ]); - } - - public function removeNode( - NodeAggregateId $nodeAggregateId, - DimensionSpacePointSet $dimensionSpacePoints, - ): void { - $this->dbal->executeStatement( - 'DELETE FROM ' . $this->tableNamePrefix - . ' WHERE nodeAggregateId = :nodeAggregateId' - . ' AND originDimensionSpacePointHash IN (:dimensionSpacePointHashes)', - [ - 'nodeAggregateId' => $nodeAggregateId->value, - 'dimensionSpacePointHashes' => $dimensionSpacePoints->getPointHashes(), - ], - [ - 'dimensionSpacePointHashes' => Connection::PARAM_STR_ARRAY, - ] - ); - } - - /** - * @throws DBALException - */ - public function reset(): void - { - /** @var AbstractPlatform|null $platform */ - $platform = $this->dbal->getDatabasePlatform(); - if ($platform === null) { - throw new \RuntimeException( - sprintf( - 'Failed to determine database platform for database "%s"', - $this->dbal->getDatabase() - ), - 1645781464 - ); - } - $this->dbal->executeStatement($platform->getTruncateTableSQL($this->tableNamePrefix)); - } - - public function getTableNamePrefix(): string - { - return $this->tableNamePrefix; - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepositoryFactory.php b/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepositoryFactory.php deleted file mode 100644 index f930887a523..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Projection/AssetUsageRepositoryFactory.php +++ /dev/null @@ -1,27 +0,0 @@ -dbal, - sprintf('cr_%s_p_neos_%s', $contentRepositoryId->value, 'asset_usage') - ); - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php new file mode 100644 index 00000000000..bbc107a291e --- /dev/null +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -0,0 +1,313 @@ + */ + private array $originalAssetIdMappingRuntimeCache = []; + + public function __construct( + private readonly ContentRepositoryRegistry $contentRepositoryRegistry, + private readonly AssetUsageRepository $assetUsageRepository, + private readonly AssetRepository $assetRepository, + private readonly PersistenceManager $persistenceManager, + ) { + } + + /** @var array> */ + private array $workspaceBases = []; + + /** @var array> */ + private array $workspaceDependents = []; + + public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node): void + { + $workspaceBases = $this->getWorkspaceBasesAndWorkspace($contentRepositoryId, $node->workspaceName); + $workspaceDependents = $this->getWorkspaceDependents($contentRepositoryId, $node->workspaceName); + $nodeType = $this->contentRepositoryRegistry->get($contentRepositoryId)->getNodeTypeManager()->getNodeType($node->nodeTypeName); + + if ($nodeType === null) { + return; + } + + $assetIdsByPropertyOfNode = $this->getAssetIdsByProperty($nodeType, $node->properties); + $assetUsagesInAncestorWorkspaces = $this->assetUsageRepository->findUsageForNodeInWorkspaces($contentRepositoryId, $node, $workspaceBases); + + $propertiesAndAssetIdsNotExistingInAncestors = []; + foreach ($assetIdsByPropertyOfNode as $propertyName => $assetIdAndOriginalAssetIds) { + foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { + foreach ($assetUsagesInAncestorWorkspaces as $assetUsage) { + if ( + $assetUsage->assetId === $assetIdAndOriginalAssetId->assetId + && $assetUsage->propertyName === $propertyName + ) { + continue 2; + } + } + $propertiesAndAssetIdsNotExistingInAncestors[$propertyName][] = $assetIdAndOriginalAssetId; + } + } + $assetIdsByPropertyNotExistingInAncestors = new AssetIdsByProperty($propertiesAndAssetIdsNotExistingInAncestors); + + $removedPropertiesAndAssetIds = []; + foreach ($assetUsagesInAncestorWorkspaces as $assetUsage) { + $assetUsageFound = false; + $assetIds = []; + foreach ($assetIdsByPropertyOfNode as $property => $assetIdAndOriginalAssetIds) { + if ($assetUsage->propertyName === $property) { + foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { + if ( + $assetUsage->assetId === $assetIdAndOriginalAssetId->assetId + ) { + $assetUsageFound = true; + continue 2; + } + } + $assetIds[] = $assetUsage->assetId; + } + } + if (!$assetUsageFound) { + $assetIds[] = $assetUsage->assetId; + } + $removedPropertiesAndAssetIds[$assetUsage->propertyName] = array_map( + fn ($removedAssetIds) => new AssetIdAndOriginalAssetId($removedAssetIds, $this->findOriginalAssetId($removedAssetIds)), + $assetIds + ); + } + $removedAssetIdsByProperty = new AssetIdsByProperty($removedPropertiesAndAssetIds); + + + // TODO: TEST something is changed in child workspace ... and afterwards changed in a workspace between + + foreach ($assetIdsByPropertyNotExistingInAncestors as $propertyName => $assetIdAndOriginalAssetIds) { + /** @var AssetIdAndOriginalAssetId $assetIdAndOriginalAssetId */ + foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { + $this->assetUsageRepository->addUsagesForNodeWithAssetOnProperty($contentRepositoryId, $node, $propertyName, $assetIdAndOriginalAssetId->assetId, $assetIdAndOriginalAssetId->originalAssetId); + $this->assetUsageRepository->removeAssetUsagesForNodeAggregateIdAndDimensionSpacePointWithAssetOnPropertyInWorkspaces( + $contentRepositoryId, + $node->aggregateId, + $node->dimensionSpacePoint, + $propertyName, + $assetIdAndOriginalAssetId->assetId, + $workspaceDependents + ); + } + } + foreach ($removedAssetIdsByProperty as $propertyName => $assetIdAndOriginalAssetIds) { + /** @var AssetIdAndOriginalAssetId $assetIdAndOriginalAssetId */ + foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { + $this->assetUsageRepository->removeAssetUsagesForNodeAggregateIdAndDimensionSpacePointWithAssetOnPropertyInWorkspaces( + $contentRepositoryId, + $node->aggregateId, + $node->dimensionSpacePoint, + $propertyName, + $assetIdAndOriginalAssetId->assetId, + [$node->workspaceName] + ); + } + } + } + + public function updateDimensionSpacePointInIndex(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, DimensionSpacePoint $source, DimensionSpacePoint $target): void + { + $this->assetUsageRepository->updateAssetUsageDimensionSpacePoint($contentRepositoryId, $workspaceName, $source, $target); + } + + public function removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName, + NodeAggregateId $nodeAggregateId, + DimensionSpacePoint $dimensionSpacePoint + ): void { + $this->assetUsageRepository->removeAssetUsagesOfWorkspaceWithAllProperties( + $contentRepositoryId, + $workspaceName, + $nodeAggregateId, + $dimensionSpacePoint + ); + } + + public function removeIndexForNode( + ContentRepositoryId $contentRepositoryId, + Node $node + ): void { + $this->removeIndexForWorkspaceNameNodeAggregateIdAndDimensionSpacePoint( + $contentRepositoryId, + $node->workspaceName, + $node->aggregateId, + $node->dimensionSpacePoint + ); + } + + public function removeIndexForWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceName $workspaceName + ): void { + $this->assetUsageRepository->removeAssetUsagesOfWorkspace($contentRepositoryId, $workspaceName); + } + + public function pruneIndex(ContentRepositoryId $contentRepositoryId): void + { + $this->assetUsageRepository->removeAll($contentRepositoryId); + } + + /** + * @return WorkspaceName[] + */ + private function getWorkspaceBasesAndWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): array + { + if (!isset($this->workspaceBases[$contentRepositoryId->value][$workspaceName->value])) { + $workspaceFinder = $this->contentRepositoryRegistry->get($contentRepositoryId)->getWorkspaceFinder(); + $workspace = $workspaceFinder->findOneByName($workspaceName); + if ($workspace === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + + $stack = [$workspace]; + + $collectedWorkspaceNames = [$workspaceName]; + + while ($stack !== []) { + $workspace = array_shift($stack); + if ($workspace->baseWorkspaceName) { + $ancestor = $workspaceFinder->findOneByName($workspace->baseWorkspaceName); + if ($ancestor === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspace->baseWorkspaceName); + } + $stack[] = $ancestor; + $collectedWorkspaceNames[] = $ancestor->workspaceName; + } + } + + $this->workspaceBases[$contentRepositoryId->value][$workspaceName->value] = $collectedWorkspaceNames; + } + + return $this->workspaceBases[$contentRepositoryId->value][$workspaceName->value]; + } + + /** + * @return WorkspaceName[] + */ + private function getWorkspaceDependents(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): array + { + if (!isset($this->workspaceDependents[$contentRepositoryId->value][$workspaceName->value])) { + $workspaceFinder = $this->contentRepositoryRegistry->get($contentRepositoryId)->getWorkspaceFinder(); + $workspace = $workspaceFinder->findOneByName($workspaceName); + if ($workspace === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + $stack = [$workspace]; + $collectedWorkspaceNames = []; + + while ($stack !== []) { + /** @var Workspace $workspace */ + $workspace = array_shift($stack); + $descendants = $workspaceFinder->findByBaseWorkspace($workspace->workspaceName); + foreach ($descendants as $descendant) { + $collectedWorkspaceNames[] = $descendant->workspaceName; + $stack[] = $descendant; + } + } + $this->workspaceDependents[$contentRepositoryId->value][$workspaceName->value] = $collectedWorkspaceNames; + } + + return $this->workspaceDependents[$contentRepositoryId->value][$workspaceName->value]; + } + + private function getAssetIdsByProperty(NodeType $nodeType, PropertyCollection $propertyValues): AssetIdsByProperty + { + /** @var array> $assetIds */ + $assetIds = []; + foreach ($propertyValues as $propertyName => $propertyValue) { + if (!$nodeType->hasProperty($propertyName)) { + continue; + } + $propertyType = $nodeType->getPropertyType($propertyName); + + $extractedAssetIds = $this->extractAssetIds( + $propertyType, + $propertyValue, + ); + + $assetIds[$propertyName] = array_map( + fn ($assetId) => new AssetIdAndOriginalAssetId($assetId, $this->findOriginalAssetId($assetId)), + $extractedAssetIds + ); + } + return new AssetIdsByProperty($assetIds); + } + + /** + * @return array + */ + private function extractAssetIds(string $type, mixed $value): array + { + if (is_string($value)) { + preg_match_all('/asset:\/\/(?[\w-]*)/i', $value, $matches, PREG_SET_ORDER); + return array_map(static fn (array $match) => $match['assetId'], $matches); + } + if (is_subclass_of($type, ResourceBasedInterface::class)) { + return [$this->persistenceManager->getIdentifierByObject($value)]; + } + + // Collection type? + /** @var array{type: string, elementType: string|null, nullable: bool} $parsedType */ + $parsedType = TypeHandling::parseType($type); + if ($parsedType['elementType'] === null) { + return []; + } + if ( + !is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class) + && !is_subclass_of($parsedType['elementType'], \Stringable::class) + ) { + return []; + } + /** @var array> $assetIds */ + $assetIds = []; + /** @var iterable $value */ + foreach ($value as $elementValue) { + $assetIds[] = $this->extractAssetIds($parsedType['elementType'], $elementValue); + } + return array_merge(...$assetIds); + } + + private function findOriginalAssetId(string $assetId): ?string + { + if (!array_key_exists($assetId, $this->originalAssetIdMappingRuntimeCache)) { + try { + /** @var AssetInterface|null $asset */ + $asset = $this->assetRepository->findByIdentifier($assetId); + } /** @noinspection PhpRedundantCatchClauseInspection */ catch (ORMException) { + return null; + } + /** @phpstan-ignore-next-line */ + $this->originalAssetIdMappingRuntimeCache[$assetId] = $asset instanceof AssetVariantInterface ? $asset->getOriginalAsset()->getIdentifier() : null; + } + + return $this->originalAssetIdMappingRuntimeCache[$assetId]; + } +} diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncService.php deleted file mode 100644 index f73bf9865d5..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncService.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ - private array $existingAssetsById = []; - - public function __construct( - private readonly ContentRepository $contentRepository, - private readonly AssetUsageFinder $assetUsageFinder, - private readonly AssetRepository $assetRepository, - private readonly AssetUsageRepository $assetUsageRepository, - ) { - } - - public function findAllUsages(): AssetUsages - { - return $this->assetUsageFinder->findByFilter(AssetUsageFilter::create()); - } - - public function removeAssetUsage(AssetUsage $assetUsage): void - { - $this->assetUsageRepository->remove($assetUsage); - } - - public function isAssetUsageStillValid(AssetUsage $usage): bool - { - if (!isset($this->existingAssetsById[$usage->assetId])) { - /** @var AssetInterface|null $asset */ - $asset = $this->assetRepository->findByIdentifier($usage->assetId); - $this->existingAssetsById[$usage->assetId] = $asset !== null; - } - if ($this->existingAssetsById[$usage->assetId] === false) { - return false; - } - $dimensionSpacePoint = $usage->originDimensionSpacePoint->toDimensionSpacePoint(); - - // FIXME: AssetUsage->workspaceName ? - $workspace = $this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($usage->contentStreamId); - if (is_null($workspace)) { - return false; - } - $subGraph = $this->contentRepository->getContentGraph($workspace->workspaceName)->getSubgraph( - $dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); - $node = $subGraph->findNodeById($usage->nodeAggregateId); - return $node !== null; - } -} diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncServiceFactory.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncServiceFactory.php deleted file mode 100644 index 2e798ecf25d..00000000000 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageSyncServiceFactory.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @internal - */ -class AssetUsageSyncServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - public function __construct( - private readonly AssetRepository $assetRepository, - private readonly AssetUsageRepositoryFactory $assetUsageRepositoryFactory, - ) { - } - - public function build( - ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies, - ): AssetUsageSyncService { - return new AssetUsageSyncService( - $serviceFactoryDependencies->contentRepository, - $serviceFactoryDependencies->contentRepository->projectionState(AssetUsageFinder::class), - $this->assetRepository, - $this->assetUsageRepositoryFactory->build($serviceFactoryDependencies->contentRepositoryId), - ); - } -} diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php index af2719e83e4..ba7bb7e9b27 100644 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ b/Neos.Neos/Classes/Command/CrCommandController.php @@ -21,7 +21,7 @@ use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Flow\ResourceManagement\ResourceRepository; use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\AssetUsage\Projection\AssetUsageFinder; +use Neos\Neos\AssetUsage\AssetUsageService; use Neos\Utility\Files; class CrCommandController extends CommandController @@ -39,6 +39,7 @@ public function __construct( private readonly PersistenceManagerInterface $persistenceManager, private readonly ContentRepositoryRegistry $contentRepositoryRegistry, private readonly ProjectionReplayServiceFactory $projectionReplayServiceFactory, + private readonly AssetUsageService $assetUsageService, ) { parent::__construct(); } @@ -65,7 +66,7 @@ public function exportCommand(string $path, string $contentRepository = 'default $filesystem, $contentRepository->getWorkspaceFinder(), $this->assetRepository, - $contentRepository->projectionState(AssetUsageFinder::class), + $this->assetUsageService, ) ); assert($exportService instanceof ExportService); diff --git a/Neos.Neos/Classes/Fusion/Cache/AssetChangeHandlerForCacheFlushing.php b/Neos.Neos/Classes/Fusion/Cache/AssetChangeHandlerForCacheFlushing.php index e17c2b685b4..da3a7c05c55 100644 --- a/Neos.Neos/Classes/Fusion/Cache/AssetChangeHandlerForCacheFlushing.php +++ b/Neos.Neos/Classes/Fusion/Cache/AssetChangeHandlerForCacheFlushing.php @@ -19,6 +19,9 @@ class AssetChangeHandlerForCacheFlushing { + /** @var array > */ + private array $workspaceRuntimeCache = []; + public function __construct( protected readonly GlobalAssetUsageService $globalAssetUsageService, protected readonly ContentRepositoryRegistry $contentRepositoryRegistry, @@ -42,37 +45,31 @@ public function registerAssetChange(AssetInterface $asset): void $filter = AssetUsageFilter::create() ->withAsset($this->persistenceManager->getIdentifierByObject($asset)) + ->groupByWorkspaceName() + ->groupByNodeAggregate() ->includeVariantsOfAsset(); - $workspaceNamesByContentStreamId = []; foreach ($this->globalAssetUsageService->findByFilter($filter) as $contentRepositoryId => $usages) { $contentRepository = $this->contentRepositoryRegistry->get(ContentRepositoryId::fromString($contentRepositoryId)); + foreach ($usages as $usage) { - // TODO: Remove when WorkspaceName is part of the AssetUsageProjection - $workspaceName = $workspaceNamesByContentStreamId[$contentRepositoryId][$usage->contentStreamId->value] ?? null; - if ($workspaceName === null) { - $workspace = $contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($usage->contentStreamId); - if ($workspace === null) { + $workspaceNames = $this->getWorkspaceNameAndChildWorkspaceNames($contentRepository, $usage->workspaceName); + + foreach ($workspaceNames as $workspaceName) { + $nodeAggregate = $contentRepository->getContentGraph($workspaceName)->findNodeAggregateById($usage->nodeAggregateId); + if ($nodeAggregate === null) { continue; } - $workspaceName = $workspace->workspaceName; - $workspaceNamesByContentStreamId[$contentRepositoryId][$usage->contentStreamId->value] = $workspaceName; - } - // + $flushNodeAggregateRequest = FlushNodeAggregateRequest::create( + $contentRepository->id, + $workspaceName, + $nodeAggregate->nodeAggregateId, + $nodeAggregate->nodeTypeName, + $this->determineAncestorNodeAggregateIds($contentRepository, $workspaceName, $nodeAggregate->nodeAggregateId), + ); - $nodeAggregate = $contentRepository->getContentGraph($workspaceName)->findNodeAggregateById($usage->nodeAggregateId); - if ($nodeAggregate === null) { - continue; + $this->contentCacheFlusher->flushNodeAggregate($flushNodeAggregateRequest, CacheFlushingStrategy::ON_SHUTDOWN); } - $flushNodeAggregateRequest = FlushNodeAggregateRequest::create( - $contentRepository->id, - $workspaceName, - $nodeAggregate->nodeAggregateId, - $nodeAggregate->nodeTypeName, - $this->determineAncestorNodeAggregateIds($contentRepository, $workspaceName, $nodeAggregate->nodeAggregateId), - ); - - $this->contentCacheFlusher->flushNodeAggregate($flushNodeAggregateRequest, CacheFlushingStrategy::ON_SHUTDOWN); } } } @@ -95,4 +92,28 @@ private function determineAncestorNodeAggregateIds(ContentRepository $contentRep return NodeAggregateIds::fromArray($ancestorNodeAggregateIds); } + + /** + * @return WorkspaceName[] + */ + private function getWorkspaceNameAndChildWorkspaceNames(ContentRepository $contentRepository, WorkspaceName $workspaceName): array + { + if (!isset($this->workspaceRuntimeCache[$contentRepository->id->value][$workspaceName->value])) { + $workspaceNames = []; + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); + if ($workspace !== null) { + $stack[] = $workspace; + + while ($stack !== []) { + $workspace = array_shift($stack); + $workspaceNames[] = $workspace->workspaceName; + + $stack = array_merge($stack, array_values($contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName))); + } + } + $this->workspaceRuntimeCache[$contentRepository->id->value][$workspaceName->value] = $workspaceNames; + } + + return $this->workspaceRuntimeCache[$contentRepository->id->value][$workspaceName->value]; + } } diff --git a/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php b/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php index e7b54a1cab1..3e44e068bab 100644 --- a/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php +++ b/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php @@ -79,7 +79,7 @@ public function removeUnusedImageVariant(Node $node, $propertyName, $oldValue, $ if ( $usageItem instanceof AssetUsageReference /** @phpstan-ignore-next-line todo needs repair see https://github.com/neos/neos-development-collection/issues/5145 */ - && $usageItem->getContentStreamId()->equals($node->subgraphIdentity->contentStreamId) + && $usageItem->getWorkspaceName()->equals($node->workspaceName) && $usageItem->getOriginDimensionSpacePoint()->equals($node->originDimensionSpacePoint) && $usageItem->getNodeAggregateId()->equals($node->aggregateId) ) { diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml index fe26202594d..a3ae6ab53c0 100644 --- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml @@ -19,5 +19,5 @@ Neos: catchUpHooks: 'Neos.Neos:FlushContentCache': factoryObjectName: Neos\Neos\Fusion\Cache\GraphProjectorCatchUpHookForCacheFlushingFactory - 'Neos.Neos:AssetUsage': - factoryObjectName: Neos\Neos\AssetUsage\Projection\AssetUsageProjectionFactory + 'Neos.Neos:AssetUsage': + factoryObjectName: Neos\Neos\AssetUsage\CatchUpHook\AssetUsageCatchUpHookFactory diff --git a/Neos.Neos/Migrations/Mysql/Version20240906102606.php b/Neos.Neos/Migrations/Mysql/Version20240906102606.php new file mode 100644 index 00000000000..a442cac274d --- /dev/null +++ b/Neos.Neos/Migrations/Mysql/Version20240906102606.php @@ -0,0 +1,56 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\AbstractMySQLPlatform'." + ); + + $sql = <<addSql($sql); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\AbstractMySQLPlatform'." + ); + + $this->addSql('DROP TABLE IF EXISTS `neos_asset_usage`'); + } +} diff --git a/Neos.Neos/Resources/Private/Partials/Module/Shared/DocumentBreadcrumb.html b/Neos.Neos/Resources/Private/Partials/Module/Shared/DocumentBreadcrumb.html index b467d49ebdd..b52da74d4ba 100644 --- a/Neos.Neos/Resources/Private/Partials/Module/Shared/DocumentBreadcrumb.html +++ b/Neos.Neos/Resources/Private/Partials/Module/Shared/DocumentBreadcrumb.html @@ -1 +1 @@ -{namespace neos=Neos\Neos\ViewHelpers}/ {documentNode.label} +{namespace neos=Neos\Neos\ViewHelpers}/ {neos:node.label(node: documentNode)} diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/01-CreateNodeAggregateWithNode_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/01-CreateNodeAggregateWithNode_WithoutDimensions.feature new file mode 100644 index 00000000000..276437c37de --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/01-CreateNodeAggregateWithNode_WithoutDimensions.feature @@ -0,0 +1,78 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node aggregate with node without dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Scenario: Nodes on live workspace have been created + Given I am in workspace "live" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | live | {} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {} | + + Scenario: Nodes on user workspace have been created + Given I am in workspace "user-workspace" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/02-CreateNodeAggregateWithNode_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/02-CreateNodeAggregateWithNode_WithDimensions.feature new file mode 100644 index 00000000000..bd793a4e12d --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/01-NodeCreation/02-CreateNodeAggregateWithNode_WithDimensions.feature @@ -0,0 +1,89 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node aggregate with node with dimensions + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {"language": "de"} + + Scenario: Nodes on live workspace have been created + Given I am in workspace "live" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language":"de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language":"de"} | + | asset-1 | sir-nodeward-nodington-iii | asset | live | {"language":"fr"} | + + Scenario: Nodes on user workspace have been created + Given I am in workspace "user-workspace" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language":"de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language":"de"} | + | asset-1 | sir-nodeward-nodington-iii | asset | user-workspace | {"language":"fr"} | + diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/01-CreateNodeGeneralizationVariant.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/01-CreateNodeGeneralizationVariant.feature new file mode 100644 index 00000000000..7703848549c --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/01-CreateNodeGeneralizationVariant.feature @@ -0,0 +1,83 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node generalization variant + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"], "asset": "Asset:asset-1"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Scenario: Create node generalization variant of node with asset in property + When I am in workspace "user-workspace" and dimension space point {"language":"de"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"en"} | + + Then 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 | user-workspace | {"language": "en"} | + | asset-1 | nody-mc-nodeface | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-workspace-cs-id" | + + Then 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-1 | nody-mc-nodeface | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/02-CreateNodeSpecializationVariant.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/02-CreateNodeSpecializationVariant.feature new file mode 100644 index 00000000000..b32ef98d8ab --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/02-CreateNodeSpecializationVariant.feature @@ -0,0 +1,71 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node specialization variant + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"], "asset": "Asset:asset-1"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Scenario: Create node specialization variant of node with asset in property + When I am in workspace "user-workspace" and dimension space point {"language":"de"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + Then 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 | nody-mc-nodeface | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-1 | nody-mc-nodeface | asset | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/03-CreateNodeSpecializationVariant_InternalWorkspace.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/03-CreateNodeSpecializationVariant_InternalWorkspace.feature new file mode 100644 index 00000000000..c7c75163ea9 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/03-CreateNodeSpecializationVariant_InternalWorkspace.feature @@ -0,0 +1,80 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node peer variant with internal workspace between live and user workspace + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "internal-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "internal-cs-id" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "internal-workspace" | + | newContentStreamId | "user-cs-id" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"], "asset": "Asset:asset-1"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "internal-workspace" | + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Scenario: Create node peer variant of node with asset in property + When I am in workspace "user-workspace" and dimension space point {"language":"de"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + Then 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 | nody-mc-nodeface | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-1 | nody-mc-nodeface | asset | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/04-CreateNodePeerVariant.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/04-CreateNodePeerVariant.feature new file mode 100644 index 00000000000..da52b6839b3 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/02-NodeVariation/04-CreateNodePeerVariant.feature @@ -0,0 +1,70 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node peer variant + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"], "asset": "Asset:asset-1"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Scenario: Create node peer variant of node with asset in property + When I am in workspace "user-workspace" and dimension space point {"language":"de"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"fr"} | + + Then 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 | user-workspace | {"language": "fr"} | + | asset-1 | nody-mc-nodeface | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/01-SetNodeProperties_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/01-SetNodeProperties_WithoutDimensions.feature new file mode 100644 index 00000000000..4bee209b5b3 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/01-SetNodeProperties_WithoutDimensions.feature @@ -0,0 +1,155 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node aggregate with node without dimensions + + Background: Create node aggregate with initial node + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + Then the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And I am in workspace "live" + And I am in dimension space point {} + + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2", "Asset:asset-3"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3"} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I am in workspace "user-workspace" + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in dimension space point {} + + Scenario: Set node properties without dimension and publish in user workspace + Given the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {} | + | propertyValues | {"asset": "Asset:asset-2"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | sir-david-nodenborough | asset-2 | asset | user-workspace | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Remove an asset from an existing property + Given the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {} | + | propertyValues | {"asset": null} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Remove an asset from an existing property from the live workspaces + Given I am in workspace "live" + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {} | + | propertyValues | {"asset": null} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Add an asset in a property + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {} | + | propertyValues | {"asset": "Asset:asset-3"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | sir-nodeward-nodington-iii | asset-3 | asset | user-workspace | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Add new asset property to the assets array + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"assets": ["Asset:asset-1", "Asset:asset-2", "Asset:asset-3"]} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | nody-mc-nodeface | asset-1 | assets | user-workspace | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Removes an asset entry from an assets array (no user-workspace entry, as the removal doesn't get tracked intentionally) + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {} | + | propertyValues | {"assets": ["Asset:asset-3"]} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/02-SetNodeProperties_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/02-SetNodeProperties_WithDimensions.feature new file mode 100644 index 00000000000..dc11b39459a --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/03-NodeModification/02-SetNodeProperties_WithDimensions.feature @@ -0,0 +1,162 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Create node aggregate with node with dimensions + + Background: Create node aggregate with initial node + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + Then the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2", "Asset:asset-3"]} | + + And I am in dimension space point {"language": "fr"} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3"} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I am in workspace "user-workspace" + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in dimension space point {"language": "de"} + + Scenario: Set node properties without dimension and publish in user workspace + Given the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"asset": "Asset:asset-2"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {"language": "de"} | + | sir-david-nodenborough | asset-2 | asset | user-workspace | {"language": "de"} | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | + + Scenario: Remove an asset from an existing property + Given the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"asset": null} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {"language": "de"} | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | + + Scenario: Remove an asset from an existing property from the live workspaces + Given I am in workspace "live" + And the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"asset": null} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | + + Scenario: Add an asset in a property + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-nodeward-nodington-iii" | + | originDimensionSpacePoint | {"language": "fr"} | + | propertyValues | {"asset": "Asset:asset-3"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | asset | user-workspace | {"language": "fr"} | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | + + Scenario: Add new asset property to the assets array + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"assets": ["Asset:asset-1", "Asset:asset-2", "Asset:asset-3"]} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {"language": "de"} | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-1 | assets | user-workspace | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | + + Scenario: Removes an asset entry from an assets array (no user-workspace entry, as the removal doesn't get tracked intentionally) + Given I am in workspace "user-workspace" + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"assets": ["Asset:asset-3"]} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {"language": "de"} | + | nody-mc-nodeface | asset-2 | assets | live | {"language": "de"} | + | nody-mc-nodeface | asset-3 | assets | live | {"language": "de"} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {"language": "fr"} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/04-NodeRemoval/01-RemoveNodeAggregate_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/04-NodeRemoval/01-RemoveNodeAggregate_WithoutDimensions.feature new file mode 100644 index 00000000000..9544a808a79 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/04-NodeRemoval/01-RemoveNodeAggregate_WithoutDimensions.feature @@ -0,0 +1,99 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Remove node aggregate with node without dimensions + + Background: Create node aggregate with initial node + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + Then the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And I am in workspace "live" + And I am in dimension space point {} + + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2", "Asset:asset-3"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3"} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I am in workspace "user-workspace" + + Then the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in dimension space point {} + + Scenario: Remove node aggregate in user-workspace + And the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "nody-mc-nodeface" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allSpecializations" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | nody-mc-nodeface | asset-2 | assets | live | {} | + | nody-mc-nodeface | asset-3 | assets | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Remove node aggregate in live workspace + And the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "nody-mc-nodeface" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allSpecializations" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-david-nodenborough | asset-1 | asset | live | {} | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | + + Scenario: Remove node aggregate with children in live workspace + And the command RemoveNodeAggregate is executed with payload: + | Key | Value | + | workspaceName | "live" | + | nodeAggregateId | "sir-david-nodenborough" | + | coveredDimensionSpacePoint | {} | + | nodeVariantSelectionStrategy | "allSpecializations" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | nodeAggregateId | assetId | propertyName | workspaceName | originDimensionSpacePoint | + | sir-nodeward-nodington-iii | asset-3 | text | live | {} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/DimensionSpacePoints/01-MoveDimensionSpacePoints.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/DimensionSpacePoints/01-MoveDimensionSpacePoints.feature new file mode 100644 index 00000000000..db04d6f5765 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/DimensionSpacePoints/01-MoveDimensionSpacePoints.feature @@ -0,0 +1,187 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Move DimensionSpacePoints + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + Then the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And I am in workspace "user-workspace" + + Then the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language": "de"} | + | propertyValues | {"asset": "Asset:asset-2"} | + + 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-2 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "fr"} | + + And I am in workspace "live" + + + Scenario: Rename a dimension value in live workspace + Given I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | de_DE,gsw,fr,en | gsw->de_DE->en, fr | + + And I run the following node migration for workspace "live", creating target workspace "migration-cs" on contentStreamId "migration-cs", with publishing on success: + """yaml + migration: + - + transformations: + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"de"} + to: {"language":"de_DE"} + """ + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de_DE"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de_DE"} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "fr"} | + + + Scenario: Rename a dimension value in user workspace + Given I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | de_DE,gsw,fr,en | gsw->de_DE->en, fr | + + And I run the following node migration for workspace "user-workspace", creating target workspace "migration-cs" on contentStreamId "migration-cs", with publishing on success: + """yaml + migration: + - + transformations: + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"de"} + to: {"language":"de_DE"} + """ + + 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-2 | sir-david-nodenborough | asset | user-workspace | {"language": "de_DE"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "fr"} | + + + Scenario: Adding a dimension in live workspace + Given I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + | market | DE, FR | DE, FR | + + And I run the following node migration for workspace "live", creating target workspace "migration-cs" on contentStreamId "migration-cs", with publishing on success: + """yaml + migration: + - + transformations: + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"de"} + to: {"language":"de", "market": "DE"} + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"fr"} + to: {"language":"fr", "market": "FR"} + """ + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language":"de", "market": "DE"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language":"de", "market": "DE"} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language":"fr", "market": "FR"} | + + + Scenario: Adding a dimension in user workspace + Given I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + | market | DE, FR | DE, FR | + + And I run the following node migration for workspace "user-workspace", creating target workspace "migration-cs" on contentStreamId "migration-cs", with publishing on success: + """yaml + migration: + - + transformations: + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"de"} + to: {"language":"de", "market": "DE"} + - + type: 'MoveDimensionSpacePoint' + settings: + from: {"language":"fr"} + to: {"language":"fr", "market": "FR"} + """ + + 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-2 | sir-david-nodenborough | asset | user-workspace | {"language":"de", "market": "DE"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "fr"} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/01-Indexing_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/01-Indexing_WithoutDimensions.feature new file mode 100644 index 00000000000..6918fc7104d --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/01-Indexing_WithoutDimensions.feature @@ -0,0 +1,87 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Build index for existing nodes without dimensions + + Scenario: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-ii | curador | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + When I am in workspace "user-workspace" + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iv | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + | sir-nodeward-nodington-v | quatilde | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + When the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {} | + | propertyValues | {"asset": "Asset:asset-2"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | live | {} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {} | + | asset-2 | sir-nodeward-nodington-v | assets | user-workspace | {} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {} | + + When I run the AssetUsageIndexingProcessor with rootNodeTypeName "Neos.ContentRepository:Root" + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | live | {} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {} | + | asset-2 | sir-nodeward-nodington-v | assets | user-workspace | {} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {} | + diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/02-Indexing_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/02-Indexing_WithDimensions.feature new file mode 100644 index 00000000000..ce66c4f7324 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/Indexing/02-Indexing_WithDimensions.feature @@ -0,0 +1,104 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Build index for existing nodes with dimensions + + Scenario: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr | gsw->de, fr | + | market | EU, DE | EU, DE->EU | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de", "market": "DE"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-ii | curador | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de", "market": "DE"} | + | targetOrigin | {"language":"gsw", "market": "EU"} | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + When I am in workspace "user-workspace" + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iv | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + | sir-nodeward-nodington-v | quatilde | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + When the command SetNodeProperties is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language": "gsw", "market": "EU"} | + | propertyValues | {"asset": "Asset:asset-2"} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de", "market": "DE"} | + | targetOrigin | {"language":"gsw", "market": "EU"} | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de", "market": "DE"} | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "gsw", "market": "EU"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de", "market": "DE"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de", "market": "DE"} | + | asset-2 | sir-nodeward-nodington-v | assets | user-workspace | {"language": "de", "market": "DE"} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {"language": "gsw", "market": "EU"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw", "market": "EU"} | + + When I run the AssetUsageIndexingProcessor with rootNodeTypeName "Neos.ContentRepository:Root" + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de", "market": "DE"} | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "gsw", "market": "EU"} | + | asset-2 | nody-mc-nodeface | assets | live | {"language": "de", "market": "DE"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de", "market": "DE"} | + | asset-2 | sir-nodeward-nodington-v | assets | user-workspace | {"language": "de", "market": "DE"} | + | asset-2 | sir-david-nodenborough | asset | user-workspace | {"language": "gsw", "market": "EU"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw", "market": "EU"} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/01-PublishWorkspace_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/01-PublishWorkspace_WithoutDimensions.feature new file mode 100644 index 00000000000..4b538399636 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/01-PublishWorkspace_WithoutDimensions.feature @@ -0,0 +1,121 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes without dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Scenario: Publish nodes from user workspace to live + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-cs-id" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | live | {} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {} | + + Scenario: Publish nodes from user workspace to a non live workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + And I am in dimension space point {} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-workspace-cs-id" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | review-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | review-workspace | {} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/02-PublishWorkspace_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/02-PublishWorkspace_WithDimensions.feature new file mode 100644 index 00000000000..d2b09329dcf --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/02-PublishWorkspace_WithDimensions.feature @@ -0,0 +1,191 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes with dimensions + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Scenario: Publish nodes from user workspace to live + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-cs-id" | + + 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-2 | nody-mc-nodeface | assets | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "fr"} | + + Scenario: Publish nodes from user workspace to a non live workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "gsw"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"gsw"} | + | propertyValues | {"assets": ["Asset:asset-2", "Asset:asset-1"], "text": "Some text"} | + + And I am in dimension space point {"language": "fr"} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-workspace-cs-id" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | review-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | review-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | review-workspace | {"language": "fr"} | + + Scenario: Publish nodes from user workspace to live with new generalization + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"en"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "en"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | newContentStreamId | "new-user-cs-id" | + + 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 | live | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | live | {"language": "de"} | \ No newline at end of file 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 new file mode 100644 index 00000000000..d7c95053274 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/03-PublishIndividualNodesFromWorkspace_WithoutDimensions.feature @@ -0,0 +1,123 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes partially without dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Scenario: Publish nodes partially from user workspace to live + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | 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 | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + Scenario: Publish nodes partially from user workspace to a non live workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + And I am in dimension space point {} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | 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 | + | asset-1 | sir-david-nodenborough | asset | review-workspace | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | 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 new file mode 100644 index 00000000000..1d039f580be --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W01-WorkspacePublication/04-PublishIndividualNodesFromWorkspace_WithDimensions.feature @@ -0,0 +1,194 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes partially with dimensions + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Scenario: Publish nodes partially from user workspace to live + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | 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 | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + Scenario: Publish nodes partially from user workspace to a non live workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "gsw"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"gsw"} | + | propertyValues | {"assets": ["Asset:asset-2", "Asset:asset-1"]} | + + And I am in dimension space point {"language": "fr"} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | 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 | + | asset-1 | sir-david-nodenborough | asset | review-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + Scenario: Publish nodes partially from user workspace to live with new generalization + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"en"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "en"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | + + When the command PublishIndividualNodesFromWorkspace is executed with payload: + | 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 diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/01-DiscardWorkspace_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/01-DiscardWorkspace_WithoutDimensions.feature new file mode 100644 index 00000000000..63909ce8de8 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/01-DiscardWorkspace_WithoutDimensions.feature @@ -0,0 +1,125 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Discard workspace without dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + + + Scenario: Discard changes in user workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + + + Scenario: Discard changes in user workspace with a non-live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And I am in workspace "review-workspace" + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + And I am in dimension space point {} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/02-DiscardWorkspace_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/02-DiscardWorkspace_WithDimensions.feature new file mode 100644 index 00000000000..c3c6559a152 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/02-DiscardWorkspace_WithDimensions.feature @@ -0,0 +1,216 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Discard workspace with dimensions + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + + Scenario: Discard user workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + 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-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | + + Scenario: Discard user workspace with a non-live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "gsw"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"gsw"} | + | propertyValues | {"assets": ["Asset:asset-2", "Asset:asset-1"], "text": "Some text"} | + + And I am in dimension space point {"language": "fr"} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + 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-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | + + Scenario: Discard user workspace with new generalization + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"en"} | + + 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 | user-workspace | {"language": "en"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | + + + Scenario: Discard user workspace after change to an existing asseet usage of a property + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | originDimensionSpacePoint | {"language":"de"} | + | propertyValues | {"asset": "Asset:asset-2", "assets": ["Asset:asset-2", "Asset:asset-1"]} | + + 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-2 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | sir-david-nodenborough | assets | user-workspace | {"language": "de"} | + | asset-1 | sir-david-nodenborough | assets | user-workspace | {"language": "de"} | + + When the command DiscardWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/03-DiscardIndividualNodesFromWorkspace_WithoutDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/03-DiscardIndividualNodesFromWorkspace_WithoutDimensions.feature new file mode 100644 index 00000000000..059182db7bb --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/03-DiscardIndividualNodesFromWorkspace_WithoutDimensions.feature @@ -0,0 +1,220 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes partially without dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + + Scenario: Discards nodes partially from user workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-2", "text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + | asset-2 | sir-nodeward-nodington-iiii | asset | user-workspace | {} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iiii"}] | + | newContentStreamId | "user-cs-identifier-new" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + Scenario: Discards multiple nodes partially from user workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + And I am in dimension space point {} + + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-2", "text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + | asset-2 | sir-nodeward-nodington-iiii | asset | user-workspace | {} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"},{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iiii"}] | + | newContentStreamId | "user-cs-identifier-new" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {} | + + Scenario: Discards nodes partially from user workspace with a non-live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And I am in workspace "review-workspace" + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + And I am in dimension space point {} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset", "asset": "Asset:asset-1"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + | asset-1 | sir-nodeward-nodington-iiii | asset | user-workspace | {} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iiii"}] | + | newContentStreamId | "user-cs-identifier-new" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + + Scenario: Discards multiple nodes partially from user workspace with a non-live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And I am in workspace "review-workspace" + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + And I am in dimension space point {} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | sir-nodeward-nodington-iii | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset", "asset": "Asset:asset-1"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {} | + | asset-1 | sir-nodeward-nodington-iiii | asset | user-workspace | {} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iii"},{"workspaceName": "user-workspace", "dimensionSpacePoint": {}, "nodeAggregateId": "sir-nodeward-nodington-iiii"}] | + | newContentStreamId | "user-cs-identifier-new" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | live | {} | + | asset-2 | nody-mc-nodeface | assets | review-workspace | {} | + diff --git a/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/04-DiscardIndividualNodesFromWorkspace_WithDimensions.feature b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/04-DiscardIndividualNodesFromWorkspace_WithDimensions.feature new file mode 100644 index 00000000000..e6b4bbb2628 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/AssetUsage/W02-WorkspaceDiscarding/04-DiscardIndividualNodesFromWorkspace_WithDimensions.feature @@ -0,0 +1,188 @@ +@contentrepository @adapters=DoctrineDBAL +@flowEntities +Feature: Publish nodes partially with dimensions + + Background: + Given using the following content dimensions: + | Identifier | Values | Generalizations | + | language | de,gsw,fr,en | gsw->de->en, fr | + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:NodeWithAssetProperties': + properties: + text: + type: string + asset: + type: Neos\Media\Domain\Model\Asset + assets: + type: array + """ + 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" | + | workspaceTitle | "Live" | + | workspaceDescription | "The live workspace" | + | newContentStreamId | "cs-identifier" | + + And I am in workspace "live" + And I am in dimension space point {"language": "de"} + And I am user identified by "initiating-user-identifier" + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "lady-eleonode-rootford" | + | nodeTypeName | "Neos.ContentRepository:Root" | + + When an asset exists with id "asset-1" + And an asset exists with id "asset-2" + And an asset exists with id "asset-3" + + Scenario: Discards nodes partially from user workspace with live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "fr"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"}, "nodeAggregateId": "nody-mc-nodeface"}] | + | newContentStreamId | "user-cs-identifier-new" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + Scenario: Discards nodes partially from user workspace with non live base workspace + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "review-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "review-workspace" | + + And the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "review-workspace" | + | newContentStreamId | "user-workspace-cs-id" | + + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + And I am in workspace "user-workspace" + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + + Then I am in dimension space point {"language": "gsw"} + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"gsw"} | + And the command SetNodeProperties is executed with payload: + | Key | Value | + | nodeAggregateId | "nody-mc-nodeface" | + | originDimensionSpacePoint | {"language":"gsw"} | + | propertyValues | {"assets": ["Asset:asset-2", "Asset:asset-1"]} | + + And I am in dimension space point {"language": "fr"} + Then the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-1 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "gsw"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "gsw"}, "nodeAggregateId": "nody-mc-nodeface"}] | + | newContentStreamId | "user-cs-identifier-new" | + + Then I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "fr"} | + + Scenario: Discard nodes partially from user workspace with live base workspace with new generalization + Given the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | baseWorkspaceName | "live" | + | newContentStreamId | "user-cs-id" | + And I am in workspace "user-workspace" + And the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + + Then I am in dimension space point {"language": "de"} + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeName | parentNodeAggregateId | nodeTypeName | initialPropertyValues | + | sir-david-nodenborough | node | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"asset": "Asset:asset-1"} | + | nody-mc-nodeface | child-node | sir-david-nodenborough | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"assets": ["Asset:asset-2"]} | + | sir-nodeward-nodington-iii | esquire | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Link to asset://asset-3."} | + | sir-nodeward-nodington-iiii | bakura | lady-eleonode-rootford | Neos.ContentRepository.Testing:NodeWithAssetProperties | {"text": "Text Without Asset"} | + + And the command CreateNodeVariant is executed with payload: + | Key | Value | + | nodeAggregateId | "sir-david-nodenborough" | + | sourceOrigin | {"language":"de"} | + | targetOrigin | {"language":"en"} | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "en"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | + | asset-3 | sir-nodeward-nodington-iii | text | user-workspace | {"language": "de"} | + + When the command DiscardIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "user-workspace" | + | nodesToDiscard | [{"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "en"} , "nodeAggregateId": "sir-david-nodenborough"}, {"workspaceName": "user-workspace", "dimensionSpacePoint": {"language": "de"} , "nodeAggregateId": "sir-nodeward-nodington-iii"}] | + | newContentStreamId | "user-cs-identifier-new" | + + And I expect the AssetUsageService to have the following AssetUsages: + | assetId | nodeAggregateId | propertyName | workspaceName | originDimensionSpacePoint | + | asset-1 | sir-david-nodenborough | asset | user-workspace | {"language": "de"} | + | asset-2 | nody-mc-nodeface | assets | user-workspace | {"language": "de"} | \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/AssetUsageTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/AssetUsageTrait.php new file mode 100644 index 00000000000..53d5e61c49f --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/AssetUsageTrait.php @@ -0,0 +1,78 @@ + $className + * + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @Then I expect the AssetUsageService to have the following AssetUsages: + */ + public function iExpectTheAssetUsageServiceToHaveTheFollowingAssetUsages(TableNode $table) + { + $assetUsageService = $this->getObject(AssetUsageService::class); + $assetUsages = $assetUsageService->findByFilter($this->currentContentRepository->id, AssetUsageFilter::create()); + + $tableRows = $table->getHash(); + foreach ($assetUsages as $assetUsage) { + foreach ($tableRows as $tableRowIndex => $tableRow) { + if ($assetUsage->assetId !== $tableRow['assetId'] + || $assetUsage->propertyName !== $tableRow['propertyName'] + || !$assetUsage->workspaceName->equals(WorkspaceName::fromString($tableRow['workspaceName'])) + || !$assetUsage->nodeAggregateId->equals(NodeAggregateId::fromString($tableRow['nodeAggregateId'])) + || !$assetUsage->originDimensionSpacePoint->equals(DimensionSpacePoint::fromJsonString($tableRow['originDimensionSpacePoint'])) + ) { + continue; + } + unset($tableRows[$tableRowIndex]); + continue 2; + } + } + + Assert::assertEmpty($tableRows, "Not all given asset usages where found."); + Assert::assertSame($assetUsages->count(), count($table->getHash()), "More asset usages found as given."); + + } + + /** + * @When I run the AssetUsageIndexingProcessor with rootNodeTypeName ":rootNodeTypeName" + */ + public function iRunTheAssetUsageIndexingProcessor(string $rootNodeTypeName) + { + $this->getObject(AssetUsageIndexingProcessor::class)->buildIndex( + $this->currentContentRepository, + NodeTypeName::fromString($rootNodeTypeName), + ); + } +} \ No newline at end of file diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index ac1b37f4ba5..5e2c525774d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -42,6 +42,7 @@ class FeatureContext implements BehatContext use FusionTrait; use ContentCacheTrait; + use AssetUsageTrait; use AssetTrait; protected Environment $environment; From 5b783ef5796055e793ce2e5c64dc049ae653916d Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 23 Sep 2024 20:32:51 +0200 Subject: [PATCH 02/39] FEATURE: AssetUsage as CatchUpHook --- Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php b/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php index 3e44e068bab..8c6470d4e59 100644 --- a/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php +++ b/Neos.Neos/Classes/Service/ImageVariantGarbageCollector.php @@ -78,7 +78,6 @@ public function removeUnusedImageVariant(Node $node, $propertyName, $oldValue, $ // then we are safe to remove the asset here. if ( $usageItem instanceof AssetUsageReference - /** @phpstan-ignore-next-line todo needs repair see https://github.com/neos/neos-development-collection/issues/5145 */ && $usageItem->getWorkspaceName()->equals($node->workspaceName) && $usageItem->getOriginDimensionSpacePoint()->equals($node->originDimensionSpacePoint) && $usageItem->getNodeAggregateId()->equals($node->aggregateId) From bd2a534d9f648fa0d84915b55778db63357d74e6 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 23 Sep 2024 23:35:08 +0200 Subject: [PATCH 03/39] FEATURE: AssetUsage as CatchUpHook --- .../CatchUpHook/AssetUsageCatchUpHook.php | 3 ++- .../Service/AssetUsageIndexingService.php | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index 8e86b7be36c..6a770e6d06c 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -129,7 +129,8 @@ private function updateNode(WorkspaceName $workspaceName, NodeAggregateId $nodeA $node = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())->findNodeById($nodeAggregateId); if ($node === null) { - throw new \Exception("Node not found"); + // Node not found, nothing to do here. + return; } $this->assetUsageIndexingService->updateIndex( diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php index bbc107a291e..6f72c856742 100644 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -13,6 +13,7 @@ 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\Node\PropertyName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Persistence\Doctrine\PersistenceManager; @@ -242,16 +243,21 @@ private function getAssetIdsByProperty(NodeType $nodeType, PropertyCollection $p { /** @var array> $assetIds */ $assetIds = []; - foreach ($propertyValues as $propertyName => $propertyValue) { + foreach ($propertyValues->serialized() as $propertyName => $propertyValue) { if (!$nodeType->hasProperty($propertyName)) { continue; } $propertyType = $nodeType->getPropertyType($propertyName); - $extractedAssetIds = $this->extractAssetIds( - $propertyType, - $propertyValue, - ); + try { + $extractedAssetIds = $this->extractAssetIds( + $propertyType, + $propertyValues->offsetGet($propertyName instanceof PropertyName ? $propertyName->value : $propertyName), + ); + } catch (\Exception) { + $extractedAssetIds = []; + // We can't deserialize the property, so skip. + } $assetIds[$propertyName] = array_map( fn ($assetId) => new AssetIdAndOriginalAssetId($assetId, $this->findOriginalAssetId($assetId)), From b169e3d82ee5cf644974b7e69e580bf1298dfeef Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 23 Sep 2024 23:35:46 +0200 Subject: [PATCH 04/39] TASK: Create database in behat testing context --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a157ca8538..2ab48800296 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -244,6 +244,7 @@ jobs: touch ${{ github.workspace }}/Data/DebugDatabaseDumps/keep ./flow package:list --loading-order + FLOW_CONTEXT=Testing/Behat ./flow doctrine:migrate --quiet cd Packages/Neos # composer test:behavioral From 0ab0d8c808661563468e9c804e50539ae65f6a37 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 23 Sep 2024 23:41:52 +0200 Subject: [PATCH 05/39] FEATURE: AssetUsage as CatchUpHook --- .../Classes/AssetUsage/Service/AssetUsageIndexingService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php index 6f72c856742..770e7fe3532 100644 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -252,7 +252,7 @@ private function getAssetIdsByProperty(NodeType $nodeType, PropertyCollection $p try { $extractedAssetIds = $this->extractAssetIds( $propertyType, - $propertyValues->offsetGet($propertyName instanceof PropertyName ? $propertyName->value : $propertyName), + $propertyValues->offsetGet($propertyName), ); } catch (\Exception) { $extractedAssetIds = []; From 09232ad308a6b5ce4f44bd4a2383c977bf290f78 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Fri, 27 Sep 2024 14:38:12 +0200 Subject: [PATCH 06/39] FEATURE: AssetUsage as CatchUpHook --- .../Classes/AssetUsage/AssetUsageIndexingProcessor.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php index 4e50288ec2a..6d30b1d5031 100644 --- a/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php +++ b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php @@ -43,18 +43,18 @@ public function buildIndex(ContentRepository $contentRepository, NodeTypeName $n $workspace = array_shift($workspaces); $contentGraph = $contentRepository->getContentGraph($workspace->workspaceName); + $this->dispatchMessage($callback, sprintf(' Workspace: %s', $contentGraph->getWorkspaceName()->value)); + $dimensionSpacePoints = $variationGraph->getDimensionSpacePoints(); $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType( $nodeTypeName ); if ($rootNodeAggregate === null) { - throw new NodeAggregateCurrentlyDoesNotExist("RootNode aggregate not found"); + $this->dispatchMessage($callback, sprintf(' ERROR: %s', "Root node aggregate was not found.")); } $rootNodeAggregateId = $rootNodeAggregate->nodeAggregateId; - $this->dispatchMessage($callback, sprintf(' Workspace: %s', $contentGraph->getWorkspaceName()->value)); - foreach ($dimensionSpacePoints as $dimensionSpacePoint) { $this->dispatchMessage($callback, sprintf(' DimensionSpacePoint: %s', $dimensionSpacePoint->toJson())); @@ -68,7 +68,6 @@ public function buildIndex(ContentRepository $contentRepository, NodeTypeName $n continue; } $this->assetUsageIndexingService->updateIndex($contentRepository->id, $childNode); - array_push($childNodes, ...iterator_to_array($subgraph->findChildNodes($childNode->aggregateId, FindChildNodesFilter::create()))); } } From 4f058e3460cab9eb8362dc100b788a1b38599fe6 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Fri, 27 Sep 2024 15:03:04 +0200 Subject: [PATCH 07/39] BUGFIX: Improve performance on find*Aggregates --- .../src/Domain/Repository/ContentGraph.php | 7 +++---- .../src/NodeQueryBuilder.php | 12 ++++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php index b0d9193364b..68b0fd85fc0 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentGraph.php @@ -175,10 +175,9 @@ public function findNodeAggregateById( public function findParentNodeAggregates( NodeAggregateId $childNodeAggregateId ): NodeAggregates { - $queryBuilder = $this->nodeQueryBuilder->buildBasicNodeAggregateQuery() - ->innerJoin('n', $this->nodeQueryBuilder->tableNames->hierarchyRelation(), 'ch', 'ch.parentnodeanchor = n.relationanchorpoint') - ->innerJoin('ch', $this->nodeQueryBuilder->tableNames->node(), 'cn', 'cn.relationanchorpoint = ch.childnodeanchor') - ->andWhere('ch.contentstreamid = :contentStreamId') + $queryBuilder = $this->nodeQueryBuilder->buildParentNodeAggregateQuery() + ->innerJoin('h', $this->nodeQueryBuilder->tableNames->node(), 'cn', 'cn.relationanchorpoint = h.childnodeanchor') + ->andWhere('h.contentstreamid = :contentStreamId') ->andWhere('cn.nodeaggregateid = :nodeAggregateId') ->setParameters([ 'nodeAggregateId' => $childNodeAggregateId->value, diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php index cfac70743cc..6e73a70b3bc 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/NodeQueryBuilder.php @@ -56,12 +56,10 @@ public function buildChildNodeAggregateQuery(NodeAggregateId $parentNodeAggregat return $this->createQueryBuilder() ->select('cn.*, ch.contentstreamid, ch.subtreetags, cdsp.dimensionspacepoint AS covereddimensionspacepoint') ->from($this->tableNames->node(), 'pn') - ->innerJoin('pn', $this->tableNames->hierarchyRelation(), 'ph', 'ph.childnodeanchor = pn.relationanchorpoint') ->innerJoin('pn', $this->tableNames->hierarchyRelation(), 'ch', 'ch.parentnodeanchor = pn.relationanchorpoint') ->innerJoin('ch', $this->tableNames->dimensionSpacePoints(), 'cdsp', 'cdsp.hash = ch.dimensionspacepointhash') ->innerJoin('ch', $this->tableNames->node(), 'cn', 'cn.relationanchorpoint = ch.childnodeanchor') ->where('pn.nodeaggregateid = :parentNodeAggregateId') - ->andWhere('ph.contentstreamid = :contentStreamId') ->andWhere('ch.contentstreamid = :contentStreamId') ->orderBy('ch.position') ->setParameters([ @@ -70,6 +68,16 @@ public function buildChildNodeAggregateQuery(NodeAggregateId $parentNodeAggregat ]); } + public function buildParentNodeAggregateQuery(): QueryBuilder + { + return $this->createQueryBuilder() + ->select('n.*, h.contentstreamid, h.subtreetags, dsp.dimensionspacepoint AS covereddimensionspacepoint') + ->from($this->tableNames->node(), 'n') + ->innerJoin('n', $this->tableNames->hierarchyRelation(), 'h', 'h.parentnodeanchor = n.relationanchorpoint') + ->innerJoin('h', $this->tableNames->dimensionSpacePoints(), 'dsp', 'dsp.hash = h.dimensionspacepointhash') + ->where('h.contentstreamid = :contentStreamId'); + } + public function buildFindRootNodeAggregatesQuery(ContentStreamId $contentStreamId, FindRootNodeAggregatesFilter $filter): QueryBuilder { $queryBuilder = $this->buildBasicNodeAggregateQuery() From 5b90f663f78dc7db5a08595367e3a5b82fcd9477 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Fri, 27 Sep 2024 15:42:29 +0200 Subject: [PATCH 08/39] FEATURE: AssetUsage as CatchUpHook --- Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php index 6d30b1d5031..9a05c7260c8 100644 --- a/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php +++ b/Neos.Neos/Classes/AssetUsage/AssetUsageIndexingProcessor.php @@ -52,6 +52,7 @@ public function buildIndex(ContentRepository $contentRepository, NodeTypeName $n ); if ($rootNodeAggregate === null) { $this->dispatchMessage($callback, sprintf(' ERROR: %s', "Root node aggregate was not found.")); + continue; } $rootNodeAggregateId = $rootNodeAggregate->nodeAggregateId; From 75b34d01ed2f060f8e39bd609d39ac8576070b6b Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 30 Sep 2024 12:09:46 +0200 Subject: [PATCH 09/39] TASK: Cleanup and add code documentation --- .../CatchUpHook/AssetUsageCatchUpHook.php | 32 ++--------------- .../Domain/AssetUsageRepository.php | 13 ------- .../Service/AssetUsageIndexingService.php | 35 ++++++++++++++----- 3 files changed, 29 insertions(+), 51 deletions(-) diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index 6a770e6d06c..be33ae889d6 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -8,7 +8,6 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\EventInterface; -use Neos\ContentRepository\Core\Feature\Common\EmbedsContentStreamAndNodeAggregateId; use Neos\ContentRepository\Core\Feature\Common\EmbedsContentStreamId; use Neos\ContentRepository\Core\Feature\Common\EmbedsWorkspaceName; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Event\DimensionSpacePointWasMoved; @@ -35,8 +34,6 @@ */ class AssetUsageCatchUpHook implements CatchUpHookInterface { - private static bool $enabled = true; - public function __construct( private readonly ContentRepository $contentRepository, @@ -44,36 +41,16 @@ public function __construct( ) { } - public function canHandle(EventInterface $event): bool - { - return in_array($event::class, [ - DimensionSpacePointWasMoved::class, - NodeAggregateWasRemoved::class, - NodeAggregateWithNodeWasCreated::class, - NodeGeneralizationVariantWasCreated::class, - NodeSpecializationVariantWasCreated::class, - NodePeerVariantWasCreated::class, - NodePropertiesWereSet::class, - WorkspaceWasDiscarded::class, - WorkspaceWasPartiallyDiscarded::class - ]); - } - public function onBeforeCatchUp(): void { } public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if (!self::$enabled) { - // performance optimization: on full replay, we assume all caches to be flushed anyways - // - so we do not need to do it individually here. - return; - } - if ( $eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId + // Safeguard for temporary content streams beeing created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) ) { return; @@ -88,15 +65,10 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $eventEnvelope): void { - if (!self::$enabled) { - // performance optimization: on full replay, we assume all caches to be flushed anyways - // - so we do not need to do it individually here. - return; - } - if ( $eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId + // Safeguard for temporary content streams beeing created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) ) { return; diff --git a/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php index 44692ee79b6..23e13ffcec6 100644 --- a/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php +++ b/Neos.Neos/Classes/AssetUsage/Domain/AssetUsageRepository.php @@ -237,21 +237,8 @@ public function removeAssetUsagesForNodeAggregateIdAndDimensionSpacePointWithAss ]); } - public function remove(AssetUsage $usage): void - { - $this->dbal->delete(self::TABLE, [ - 'contentrepositoryid' => $usage->contentRepositoryId->value, - 'assetid' => $usage->assetId, - 'workspacename' => $usage->workspaceName->value, - 'nodeaggregateid' => $usage->nodeAggregateId->value, - 'origindimensionspacepointhash' => $usage->originDimensionSpacePoint->hash, - 'propertyname' => $usage->propertyName, - ]); - } - public function removeAsset(ContentRepositoryId $contentRepositoryId, string $assetId): void { - // TODO: What about OriginalAssetId? $this->dbal->delete(self::TABLE, [ 'contentrepositoryid' => $contentRepositoryId->value, 'assetId' => $assetId, diff --git a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php index 770e7fe3532..56c8aa7bff4 100644 --- a/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php +++ b/Neos.Neos/Classes/AssetUsage/Service/AssetUsageIndexingService.php @@ -13,7 +13,6 @@ 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\Node\PropertyName; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Persistence\Doctrine\PersistenceManager; @@ -26,6 +25,15 @@ use Neos\Neos\AssetUsage\Dto\AssetIdsByProperty; use Neos\Utility\TypeHandling; +/** + * The package stores the usage of an asset per node and property in a dimension space point. It only stores the usage + * in the highest/base workspace (usually "live") as long as there are no changes in the same property in a + * lower/dependent workspace (e.g. user workspace). This allows to get easily an answer to the following questions: + * + * 1. Which nodes do I need to edit, to remove all usages for a safe asset removal (Neos Asset Usage/Media Browser) + * 2. Which cache entries do I need to flush on a change to an asset (this requires an additional traversal over all + * dependent workspaces). + */ class AssetUsageIndexingService { /** @var array */ @@ -55,9 +63,13 @@ public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node return; } + // 1. Get all asset usages of given node. $assetIdsByPropertyOfNode = $this->getAssetIdsByProperty($nodeType, $node->properties); + + // 2. Get all existing asset usages of ancestor workspaces. $assetUsagesInAncestorWorkspaces = $this->assetUsageRepository->findUsageForNodeInWorkspaces($contentRepositoryId, $node, $workspaceBases); + // 3a. Filter only asset usages of given node, which are NOT already in place in ancestor workspaces. This way we get new asset usages in this particular workspace. $propertiesAndAssetIdsNotExistingInAncestors = []; foreach ($assetIdsByPropertyOfNode as $propertyName => $assetIdAndOriginalAssetIds) { foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { @@ -66,6 +78,7 @@ public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node $assetUsage->assetId === $assetIdAndOriginalAssetId->assetId && $assetUsage->propertyName === $propertyName ) { + // Found the asset usage in at least one ancestor workspace continue 2; } } @@ -74,10 +87,11 @@ public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node } $assetIdsByPropertyNotExistingInAncestors = new AssetIdsByProperty($propertiesAndAssetIdsNotExistingInAncestors); + // 3b. Filter all asset usages, which are existing in ancestor workspaces, but not in current workspace anymore (e.g. asset removed from property). $removedPropertiesAndAssetIds = []; foreach ($assetUsagesInAncestorWorkspaces as $assetUsage) { $assetUsageFound = false; - $assetIds = []; + $removedAssetIds = []; foreach ($assetIdsByPropertyOfNode as $property => $assetIdAndOriginalAssetIds) { if ($assetUsage->propertyName === $property) { foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { @@ -88,26 +102,29 @@ public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node continue 2; } } - $assetIds[] = $assetUsage->assetId; + // No matching asset usage for the property found in the given node, but are existing in the ancestor workspaces. + $removedAssetIds[] = $assetUsage->assetId; } } + // No asset usage for the property found in the given node, but are existing in the ancestor workspaces. if (!$assetUsageFound) { - $assetIds[] = $assetUsage->assetId; + $removedAssetIds[] = $assetUsage->assetId; } $removedPropertiesAndAssetIds[$assetUsage->propertyName] = array_map( fn ($removedAssetIds) => new AssetIdAndOriginalAssetId($removedAssetIds, $this->findOriginalAssetId($removedAssetIds)), - $assetIds + $removedAssetIds ); } $removedAssetIdsByProperty = new AssetIdsByProperty($removedPropertiesAndAssetIds); - - // TODO: TEST something is changed in child workspace ... and afterwards changed in a workspace between - + // 4. Actual execution to the index + // 4a. Handle new asset usages foreach ($assetIdsByPropertyNotExistingInAncestors as $propertyName => $assetIdAndOriginalAssetIds) { /** @var AssetIdAndOriginalAssetId $assetIdAndOriginalAssetId */ foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { + // Add usage to current workspace. $this->assetUsageRepository->addUsagesForNodeWithAssetOnProperty($contentRepositoryId, $node, $propertyName, $assetIdAndOriginalAssetId->assetId, $assetIdAndOriginalAssetId->originalAssetId); + // Cleanup: Remove asset usage on all dependent workspaces. $this->assetUsageRepository->removeAssetUsagesForNodeAggregateIdAndDimensionSpacePointWithAssetOnPropertyInWorkspaces( $contentRepositoryId, $node->aggregateId, @@ -116,8 +133,10 @@ public function updateIndex(ContentRepositoryId $contentRepositoryId, Node $node $assetIdAndOriginalAssetId->assetId, $workspaceDependents ); + // => During publish the asset usage moves from dependent workspace to base workspace. } } + // 4b. Handle removed asset usages foreach ($removedAssetIdsByProperty as $propertyName => $assetIdAndOriginalAssetIds) { /** @var AssetIdAndOriginalAssetId $assetIdAndOriginalAssetId */ foreach ($assetIdAndOriginalAssetIds as $assetIdAndOriginalAssetId) { From a2b9e63f608dc61a971f84ad56d4b895c8564eac Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 30 Sep 2024 12:12:44 +0200 Subject: [PATCH 10/39] TASK: Cleanup and add code documentation --- .../Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index be33ae889d6..4bc8ca01e38 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -50,7 +50,7 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even if ( $eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId - // Safeguard for temporary content streams beeing created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. + // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) ) { return; @@ -68,7 +68,7 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event if ( $eventInstance instanceof EmbedsWorkspaceName && $eventInstance instanceof EmbedsContentStreamId - // Safeguard for temporary content streams beeing created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. + // Safeguard for temporary content streams created during partial publish -> We want to skip these events, because their workspace doesn't match current contentstream. && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) ) { return; From f83a7fc53c02be5fe72b73b370bf30366d04944c Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 30 Sep 2024 12:13:47 +0200 Subject: [PATCH 11/39] TASK: Cleanup and add code documentation --- .../Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index 4bc8ca01e38..09f42ba99d6 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -34,7 +34,6 @@ */ class AssetUsageCatchUpHook implements CatchUpHookInterface { - public function __construct( private readonly ContentRepository $contentRepository, private readonly AssetUsageIndexingService $assetUsageIndexingService From a5bd5797b393d4121d575fa01c50cd3f370d1b29 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Mon, 30 Sep 2024 12:19:29 +0200 Subject: [PATCH 12/39] BUGFIX: Handle non-existing workspaces gracefully --- ...aphProjectorCatchUpHookForCacheFlushing.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php index 321f6308afc..e3e8bcfe461 100644 --- a/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php +++ b/Neos.Neos/Classes/Fusion/Cache/GraphProjectorCatchUpHookForCacheFlushing.php @@ -38,6 +38,7 @@ use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Event\WorkspaceWasRebased; use Neos\ContentRepository\Core\Projection\CatchUpHookInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -159,7 +160,12 @@ public function onBeforeEvent(EventInterface $eventInstance, EventEnvelope $even // cleared, leading to presumably duplicate nodes in the UI. || $eventInstance instanceof NodeAggregateWasMoved ) { - $contentGraph = $this->contentRepository->getContentGraph($eventInstance->workspaceName); + try { + $contentGraph = $this->contentRepository->getContentGraph($eventInstance->workspaceName); + } catch (WorkspaceDoesNotExist) { + return; + } + $nodeAggregate = $contentGraph->findNodeAggregateById( $eventInstance->getNodeAggregateId() ); @@ -197,9 +203,13 @@ public function onAfterEvent(EventInterface $eventInstance, EventEnvelope $event && $eventInstance instanceof EmbedsContentStreamId && $eventInstance instanceof EmbedsWorkspaceName ) { - $nodeAggregate = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName())->findNodeAggregateById( - $eventInstance->getNodeAggregateId() - ); + try { + $nodeAggregate = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName())->findNodeAggregateById( + $eventInstance->getNodeAggregateId() + ); + } catch (WorkspaceDoesNotExist) { + return; + } if ($nodeAggregate) { $this->scheduleCacheFlushJobForNodeAggregate( From 2930a50b913d368ff841ef7492c01cf8350cc789 Mon Sep 17 00:00:00 2001 From: Jenkins Date: Tue, 1 Oct 2024 10:48:57 +0000 Subject: [PATCH 13/39] TASK: Update references [skip ci] --- Neos.Neos/Documentation/References/CommandReference.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Form.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Media.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Neos.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index 442c6702a56..2d8a8e1f1c0 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-09-26 +The following reference was automatically generated from code on 2024-10-01 .. _`Neos Command Reference: NEOS.FLOW`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index 0217289419d..a95f2132dc7 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-09-26 +This reference was automatically generated from code on 2024-10-01 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index 3e1d91b0c12..74f02777c01 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-09-26 +This reference was automatically generated from code on 2024-10-01 .. _`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 5e70b8810d3..44666afc031 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-09-26 +This reference was automatically generated from code on 2024-10-01 .. _`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 f381fd96079..86448b390eb 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-09-26 +This reference was automatically generated from code on 2024-10-01 .. _`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 2c8d86c5054..411d770af5d 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-09-26 +This reference was automatically generated from code on 2024-10-01 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: From b11026d6bed5dee1cbfb514dfea3a90039b47100 Mon Sep 17 00:00:00 2001 From: Denny Lubitz Date: Tue, 1 Oct 2024 15:52:44 +0200 Subject: [PATCH 14/39] TASK: Don't use workspaceFinder --- .../CatchUpHook/AssetUsageCatchUpHook.php | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php index 09f42ba99d6..e240adb41bc 100644 --- a/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php +++ b/Neos.Neos/Classes/AssetUsage/CatchUpHook/AssetUsageCatchUpHook.php @@ -24,6 +24,7 @@ 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\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\EventStore\Model\EventEnvelope; @@ -46,13 +47,16 @@ 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 contentstream. - && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) - ) { - return; + 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. + try { + $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + } catch (WorkspaceDoesNotExist) { + return; + } + if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { + return; + } } match ($eventInstance::class) { @@ -64,13 +68,16 @@ 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 contentstream. - && !$this->contentRepository->getWorkspaceFinder()->findOneByCurrentContentStreamId($eventInstance->getContentStreamId())?->workspaceName->equals($eventInstance->getWorkspaceName()) - ) { - return; + 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. + try { + $contentGraph = $this->contentRepository->getContentGraph($eventInstance->getWorkspaceName()); + } catch (WorkspaceDoesNotExist) { + return; + } + if (!$contentGraph->getContentStreamId()->equals($eventInstance->getContentStreamId())) { + return; + } } match ($eventInstance::class) { @@ -114,7 +121,6 @@ private function removeNodes(WorkspaceName $workspaceName, NodeAggregateId $node { $contentGraph = $this->contentRepository->getContentGraph($workspaceName); - foreach ($dimensionSpacePoints as $dimensionSpacePoint) { $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); $node = $subgraph->findNodeById($nodeAggregateId); From 4226dbfd2646fcf6078b5c9aecf014d2573cb7a2 Mon Sep 17 00:00:00 2001 From: Jenkins Date: Mon, 7 Oct 2024 21:05:27 +0000 Subject: [PATCH 15/39] TASK: Update references [skip ci] --- Neos.Neos/Documentation/References/CommandReference.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Form.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Media.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Neos.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index 2d8a8e1f1c0..cc3092a7294 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-01 +The following reference was automatically generated from code on 2024-10-07 .. _`Neos Command Reference: NEOS.FLOW`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index a95f2132dc7..fc81d30b20b 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-01 +This reference was automatically generated from code on 2024-10-07 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index 74f02777c01..4666d878d99 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-01 +This reference was automatically generated from code on 2024-10-07 .. _`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 44666afc031..610975671e1 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-01 +This reference was automatically generated from code on 2024-10-07 .. _`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 86448b390eb..a2861061a47 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-01 +This reference was automatically generated from code on 2024-10-07 .. _`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 411d770af5d..8e225f3e8fc 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-01 +This reference was automatically generated from code on 2024-10-07 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: From 25e9957d9840eef590075845400fd25eb9e7f3d9 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 14:19:49 +0200 Subject: [PATCH 16/39] Rename `WorkspaceSubjectType` to `WorkspaceRoleSubjectType` --- .../Classes/Service/EventMigrationService.php | 8 ++++---- ...kspaceSubjectType.php => WorkspaceRoleSubjectType.php} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename Neos.Neos/Classes/Domain/Model/{WorkspaceSubjectType.php => WorkspaceRoleSubjectType.php} (77%) diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index c4ba07641e8..32dbba18330 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -33,7 +33,7 @@ use Neos\EventStore\Model\EventStream\VirtualStreamName; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceRole; -use Neos\Neos\Domain\Model\WorkspaceSubjectType; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; /** * Content Repository service to perform migrations of events. @@ -616,7 +616,7 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): $this->connection->insert('neos_neos_workspace_role', [ 'content_repository_id' => $this->contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => WorkspaceSubjectType::GROUP->value, + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:LivePublisher', 'role' => WorkspaceRole::COLLABORATOR->value, ]); @@ -624,7 +624,7 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): $this->connection->insert('neos_neos_workspace_role', [ 'content_repository_id' => $this->contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => WorkspaceSubjectType::GROUP->value, + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:AbstractEditor', 'role' => WorkspaceRole::COLLABORATOR->value, ]); @@ -632,7 +632,7 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): $this->connection->insert('neos_neos_workspace_role', [ 'content_repository_id' => $this->contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => WorkspaceSubjectType::USER->value, + 'subject_type' => WorkspaceRoleSubjectType::USER->value, 'subject' => $workspaceOwner, 'role' => WorkspaceRole::COLLABORATOR->value, ]); diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php similarity index 77% rename from Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php rename to Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php index 09334960f63..78777a834bd 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php @@ -7,7 +7,7 @@ /** * @api */ -enum WorkspaceSubjectType : string +enum WorkspaceRoleSubjectType : string { case USER = 'USER'; case GROUP = 'GROUP'; From 2dde2cfc41100e3ad3c74ffc985c4f2a91d247c6 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 14:22:30 +0200 Subject: [PATCH 17/39] Make `WorkspaceService` more explicit --- .../Command/WorkspaceCommandController.php | 160 ++++++++---------- .../Domain/Model/WorkspaceRoleSubject.php | 39 +++++ .../Domain/Service/WorkspaceService.php | 111 +++++++----- .../Bootstrap/WorkspaceServiceTrait.php | 22 +-- .../Controller/WorkspaceController.php | 8 +- 5 files changed, 185 insertions(+), 155 deletions(-) create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index cc9742a49c5..f474189f0fc 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,8 +14,7 @@ namespace Neos\Neos\Command; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\BaseWorkspaceDoesNotExist; +use Doctrine\DBAL\Exception as DbalException; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; @@ -24,19 +23,17 @@ use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; -use Neos\Flow\Persistence\PersistenceManagerInterface; -use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; -use Neos\Neos\Domain\Model\WorkspaceDescription as NeosWorkspaceDescription; -use Neos\Neos\Domain\Model\WorkspaceTitle as NeosWorkspaceTitle; +use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; +use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\Domain\Service\WorkspaceService; @@ -50,9 +47,6 @@ class WorkspaceCommandController extends CommandController #[Flow\Inject] protected UserService $userService; - #[Flow\Inject] - protected PersistenceManagerInterface $persistenceManager; - #[Flow\Inject] protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -78,7 +72,7 @@ public function publishCommand(string $workspace, string $contentRepository = 'd ); $this->outputLine( - 'Published all nodes in workspace %s to its base workspace', + 'Published all nodes in workspace "%s" to its base workspace', [$workspace] ); } @@ -99,11 +93,11 @@ public function discardCommand(string $workspace, string $contentRepository = 'd ContentRepositoryId::fromString($contentRepository), WorkspaceName::fromString($workspace) ); - } catch (WorkspaceDoesNotExist $exception) { - $this->outputLine('Workspace "%s" does not exist', [$workspace]); + } catch (WorkspaceDoesNotExist) { + $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } - $this->outputLine('Discarded all nodes in workspace %s', [$workspace]); + $this->outputLine('Discarded all nodes in workspace "%s"', [$workspace]); } /** @@ -125,14 +119,14 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de $force ? RebaseErrorHandlingStrategy::STRATEGY_FORCE : RebaseErrorHandlingStrategy::STRATEGY_FAIL, ); } catch (WorkspaceDoesNotExist $exception) { - $this->outputLine('Workspace "%s" does not exist', [$workspace]); + $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } catch (WorkspaceRebaseFailed $exception) { - $this->outputLine('Rebasing of workspace %s is not possible due to conflicts. You can try the --force option.', [$workspace]); + $this->outputLine('Rebasing of workspace "%s" is not possible due to conflicts. You can try the --force option.', [$workspace]); $this->quit(1); } - $this->outputLine('Rebased workspace %s', [$workspace]); + $this->outputLine('Rebased workspace "%s"', [$workspace]); } /** @@ -140,91 +134,75 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de * * @param string $name Name of the new root * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string|null $title Optional title of the workspace * @param string|null $description Optional description of the workspace + * @throws WorkspaceAlreadyExists */ - public function createRootCommand(string $name, string $contentRepository = 'default', string $description = null): void + public function createRootCommand(string $name, string $contentRepository = 'default', string $title = null, string $description = null): void { + $workspaceName = WorkspaceName::fromString($name); $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $workspaceName = $this->workspaceService->createRootWorkspace( + $this->workspaceService->createRootWorkspace( $contentRepositoryId, - NeosWorkspaceTitle::fromString($name), - NeosWorkspaceDescription::fromString($description ?? $name) + $workspaceName, + WorkspaceTitle::fromString($title ?? $name), + WorkspaceDescription::fromString($description ?? '') ); - $this->outputLine('Created root workspace "%s" in content repository "%s"', [$workspaceName->value, $contentRepositoryId->value]); + $this->outputLine('Created root workspace "%s" in content repository "%s"', [$workspaceName->value, $contentRepositoryId->value]); } /** - * Create a new workspace - * - * This command creates a new personal workspace. + * Create a new personal workspace for the specified user * * @param string $workspace Name of the workspace, for example "christmas-campaign" + * @param string $owner The username (aka account identifier) of a User to own the workspace * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. * @param string|null $title Human friendly title of the workspace, for example "Christmas Campaign" * @param string|null $description A description explaining the purpose of the new workspace - * @param string $owner The identifier of a User to own the workspace * @param string $contentRepository Identifier of the content repository. (Default: 'default') * @throws StopCommandException */ - public function createCommand( - string $workspace, - string $baseWorkspace = 'live', - string $title = null, - string $description = null, - string $owner = '', - string $contentRepository = 'default' - ): void { + public function createPersonalCommand(string $workspace, string $owner, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - + $workspaceOwner = $this->userService->getUser($owner); + if ($workspaceOwner === null) { + $this->outputLine('The user "%s" specified as owner does not exist', [$owner]); + $this->quit(1); + } + $workspaceName = WorkspaceName::fromString($workspace); $this->workspaceService->createPersonalWorkspace( $contentRepositoryId, - NeosWorkspaceTitle::fromString($title ?? $workspace), - NeosWorkspaceDescription::fromString($description ?? ''), + $workspaceName, + WorkspaceTitle::fromString($title ?? $workspaceName->value), + WorkspaceDescription::fromString($description ?? ''), WorkspaceName::fromString($baseWorkspace), - UserId::fromString($owner), + $workspaceOwner->getId(), ); + $this->outputLine('Created personal workspace "%s" for user "%s"', [$workspaceName->value, (string)$workspaceOwner->getName()]); + } - $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); - - if ($owner === '') { - $workspaceOwnerUserId = null; - } else { - $workspaceOwnerUserId = UserId::fromString($owner); - $workspaceOwner = $this->userService->findUserById($workspaceOwnerUserId); - if ($workspaceOwner === null) { - $this->outputLine('The user "%s" specified as owner does not exist', [$owner]); - $this->quit(3); - } - } - - try { - $contentRepositoryInstance->handle(CreateWorkspace::create( - WorkspaceName::fromString($workspace), - WorkspaceName::fromString($baseWorkspace), - WorkspaceTitle::fromString($title ?: $workspace), - WorkspaceDescription::fromString($description ?: $workspace), - ContentStreamId::create(), - $workspaceOwnerUserId !== null ? \Neos\ContentRepository\Core\SharedModel\User\UserId::fromString($workspaceOwnerUserId->value) : null, - )); - } catch (WorkspaceAlreadyExists $workspaceAlreadyExists) { - $this->outputLine('Workspace "%s" already exists', [$workspace]); - $this->quit(1); - } catch (BaseWorkspaceDoesNotExist $baseWorkspaceDoesNotExist) { - $this->outputLine('The base workspace "%s" does not exist', [$baseWorkspace]); - $this->quit(2); - } - - if ($workspaceOwnerUserId !== null) { - $this->outputLine( - 'Created a new workspace "%s", based on workspace "%s", owned by "%s".', - [$workspace, $baseWorkspace, $owner] - ); - } else { - $this->outputLine( - 'Created a new workspace "%s", based on workspace "%s".', - [$workspace, $baseWorkspace] - ); - } + /** + * Create a new shared workspace + * + * @param string $workspace Name of the workspace, for example "christmas-campaign" + * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. + * @param string|null $title Human friendly title of the workspace, for example "Christmas Campaign" + * @param string|null $description A description explaining the purpose of the new workspace + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function createSharedCommand(string $workspace, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->createSharedWorkspace( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($title ?? $workspaceName->value), + WorkspaceDescription::fromString($description ?? ''), + WorkspaceName::fromString($baseWorkspace), + ); + $this->outputLine('Created shared workspace "%s"', [$workspaceName->value]); } /** @@ -236,7 +214,7 @@ public function createCommand( * @param string $workspace Name of the workspace, for example "christmas-campaign" * @param boolean $force Delete the workspace and all of its contents * @param string $contentRepository The name of the content repository. (Default: 'default') - * @see neos.neos:workspace:discard + * @throws StopCommandException */ public function deleteCommand(string $workspace, bool $force = false, string $contentRepository = 'default'): void { @@ -257,20 +235,18 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { - $this->outputLine( - 'Did not delete workspace "%s" because it is a personal workspace.' - . ' Personal workspaces cannot be deleted manually.', - [$workspaceName->value] - ); + $this->outputLine('Did not delete workspace "%s" because it is a personal workspace. Personal workspaces cannot be deleted manually.', [$workspaceName->value]); $this->quit(2); } - $dependentWorkspaces = $contentRepositoryInstance->getWorkspaceFinder()->findByBaseWorkspace($workspaceName); + try { + $dependentWorkspaces = $contentRepositoryInstance->getWorkspaceFinder()->findByBaseWorkspace($workspaceName); + } catch (DbalException $e) { + $this->outputLine('Failed to determine dependant workspaces: %s', [$e->getMessage()]); + $this->quit(1); + } if (count($dependentWorkspaces) > 0) { - $this->outputLine( - 'Workspace "%s" cannot be deleted because the following workspaces are based on it:', - [$workspaceName->value] - ); + $this->outputLine('Workspace "%s" cannot be deleted because the following workspaces are based on it:', [$workspaceName->value]); $this->outputLine(); $tableRows = []; diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php new file mode 100644 index 00000000000..4fe05827d85 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -0,0 +1,39 @@ +value) !== 1) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid workspace role subject.', $value), 1728384932); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index f32e36864d2..edf84c028de 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -17,9 +17,9 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DbalException; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -36,7 +36,8 @@ use Neos\Neos\Domain\Model\WorkspaceMetadata; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; -use Neos\Neos\Domain\Model\WorkspaceSubjectType; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; /** @@ -60,12 +61,15 @@ public function __construct( */ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { - $this->requireWorkspace($contentRepositoryId, $workspaceName); + $crWorkspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($metadata === null) { - throw new \RuntimeException(sprintf('Failed to load metadata for workspace "%s" (Content Repository "%s"). Maybe workspace metadata and roles have to be synchronized', $workspaceName->value, $contentRepositoryId->value), 1726736384); - } - return $metadata; + return $metadata ?? new WorkspaceMetadata( + $workspaceName, + WorkspaceTitle::fromString($workspaceName->value), + WorkspaceDescription::fromString(''), + $crWorkspace->baseWorkspaceName !== null ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, + null, + ); } /** @@ -96,11 +100,12 @@ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentReposito /** * Create a new root (aka base) workspace with the specified metadata + * + * @throws WorkspaceAlreadyExists */ - public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description): WorkspaceName + public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $workspaceName = self::getUniqueWorkspaceName($contentRepository, $title->value); $contentRepository->handle( CreateRootWorkspace::create( $workspaceName, @@ -110,23 +115,22 @@ public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, Wo ) ); $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); - return $workspaceName; } /** * Create a new, personal, workspace for the specified user */ - public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId $ownerId): WorkspaceName + public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId $ownerId): void { - return $this->createWorkspace($contentRepositoryId, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::PERSONAL); + $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::PERSONAL); } /** * Create a new, potentially shared, workspace */ - public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId = null): WorkspaceName + public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName): void { - return $this->createWorkspace($contentRepositoryId, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::SHARED); + $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, null, WorkspaceClassification::SHARED); } /** @@ -139,8 +143,10 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); return; } + $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); $this->createPersonalWorkspace( $contentRepositoryId, + $workspaceName, WorkspaceTitle::fromString($user->getLabel()), WorkspaceDescription::empty(), WorkspaceName::forLive(), @@ -173,6 +179,29 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos ); } + public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, string $candidate): WorkspaceName + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspaceNameCandidate = WorkspaceName::transliterateFromString($candidate); + $workspaceName = $workspaceNameCandidate; + $attempt = 1; + do { + if ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) === null) { + return $workspaceName; + } + if ($attempt === 1) { + $suffix = ''; + } else { + $suffix = '-' . ($attempt - 1); + } + $workspaceName = WorkspaceName::fromString( + substr($workspaceNameCandidate->value, 0, WorkspaceName::MAX_LENGTH - strlen($suffix)) . $suffix + ); + $attempt++; + } while ($attempt <= 10); + throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); + } + // ------------------ private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata @@ -203,10 +232,6 @@ private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, if (!is_array($metadataRow)) { return null; } - assert(is_string($metadataRow['title'])); - assert(is_string($metadataRow['description'])); - assert(is_string($metadataRow['classification'])); - assert(is_null($metadataRow['owner_user_id']) || is_string($metadataRow['owner_user_id'])); return new WorkspaceMetadata( $workspaceName, WorkspaceTitle::fromString($metadataRow['title']), @@ -231,10 +256,9 @@ private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryI } } - private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): WorkspaceName + private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $workspaceName = self::getUniqueWorkspaceName($contentRepository, $title->value); $contentRepository->handle( CreateWorkspace::create( $workspaceName, @@ -245,7 +269,6 @@ private function createWorkspace(ContentRepositoryId $contentRepositoryId, Works ) ); $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); - return $workspaceName; } private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void @@ -256,7 +279,7 @@ private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, 'workspace_name' => $workspaceName->value, 'title' => $title->value, 'description' => $description->value, - 'classification' => $classification->name, + 'classification' => $classification->value, 'owner_user_id' => $ownerUserId?->value, ]); } catch (DbalException $e) { @@ -279,12 +302,27 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep SQL; $workspaceName = $this->dbal->fetchOne($query, [ 'contentRepositoryId' => $contentRepositoryId->value, - 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->name, + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, 'userId' => $userId->value, ]); return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } + public function addWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role): void + { + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subjectType->value, + 'subject' => $subject->value, + 'role' => $role->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); + } + } + private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): ?WorkspaceRole { try { @@ -307,6 +345,7 @@ private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryI (subject_type = :groupSubjectType AND subject IN (:groupSubjects)) ) ORDER BY + /* We only want to return the most specific role so we order them and return the first row */ CASE WHEN role='MANAGER' THEN 1 WHEN role='COLLABORATOR' THEN 2 @@ -316,9 +355,9 @@ private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryI $role = $this->dbal->fetchOne($query, [ 'contentRepositoryId' => $contentRepositoryId->value, 'workspaceName' => $workspaceName->value, - 'userSubjectType' => WorkspaceSubjectType::USER->name, + 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, 'userId' => $user->getId()->value, - 'groupSubjectType' => WorkspaceSubjectType::GROUP->name, + 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, 'groupSubjects' => $userRoles, ], [ 'groupSubjects' => ArrayParameterType::STRING, @@ -329,28 +368,6 @@ private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryI return WorkspaceRole::from($role); } - private static function getUniqueWorkspaceName(ContentRepository $contentRepository, string $candidate): WorkspaceName - { - $workspaceNameCandidate = WorkspaceName::transliterateFromString($candidate); - $workspaceName = $workspaceNameCandidate; - $attempt = 1; - do { - if ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) === null) { - return $workspaceName; - } - if ($attempt === 1) { - $suffix = ''; - } else { - $suffix = '-' . ($attempt - 1); - } - $workspaceName = WorkspaceName::fromString( - substr($workspaceNameCandidate->value, 0, WorkspaceName::MAX_LENGTH - strlen($suffix)) . $suffix - ); - $attempt++; - } while ($attempt <= 10); - throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); - } - private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace { $workspace = $this->contentRepositoryRegistry diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 1462d889cc4..9ad574f4a3b 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -39,25 +39,27 @@ trait WorkspaceServiceTrait abstract private function getObject(string $className): object; /** - * @When the root workspace :workspaceTitle is created + * @When the root workspace :workspaceName is created */ - public function theRootWorkspaceIsCreated(string $workspaceTitle): void + public function theRootWorkspaceIsCreated(string $workspaceName): void { $this->getObject(WorkspaceService::class)->createRootWorkspace( $this->currentContentRepository->id, - WorkspaceTitle::fromString($workspaceTitle), + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), ); } /** - * @When the personal workspace :workspaceTitle is created with the target workspace :targetWorkspace + * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace */ - public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceTitle, string $targetWorkspace): void + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void { $this->getObject(WorkspaceService::class)->createPersonalWorkspace( $this->currentContentRepository->id, - WorkspaceTitle::fromString($workspaceTitle), + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), UserId::fromString(FakeUserIdProvider::$userId?->value ?? ''), @@ -65,16 +67,16 @@ public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $work } /** - * @When the shared workspace :workspaceTitle is created with the target workspace :targetWorkspace + * @When the shared workspace :workspaceName is created with the target workspace :targetWorkspace */ - public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceTitle, string $targetWorkspace): void + public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void { $this->getObject(WorkspaceService::class)->createSharedWorkspace( $this->currentContentRepository->id, - WorkspaceTitle::fromString($workspaceTitle), + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), - null ); } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index f07732d5d96..38e853f867e 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -17,9 +17,7 @@ use Doctrine\DBAL\Exception as DBALException; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeWorkspaceOwner; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\RenameWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; @@ -41,11 +39,9 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\I18n\Exception\IndexOutOfBoundsException; use Neos\Flow\I18n\Exception\InvalidFormatPlaceholderException; -use Neos\Flow\Mvc\ActionRequest; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; -use Neos\Flow\Security\Account; use Neos\Flow\Security\Context; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -208,14 +204,14 @@ public function createAction( if ($currentUser === null) { throw new \RuntimeException('No user authenticated', 1718303756); } - + $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value); try { $this->workspaceService->createSharedWorkspace( $contentRepositoryId, + $workspaceName, $title, $description, $baseWorkspace, - $currentUser->getId(), ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( From 1e3e2f721f4a7c1e7d0e9a585f5a6ad421503c64 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 14:23:37 +0200 Subject: [PATCH 18/39] Workspace UI: Workspace role preset Automatically set the current user as `Manager` and role `Neos.Neos:AbstractEditor` as `Collaborator` for new shared workspaces --- .../Classes/Controller/WorkspaceController.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 38e853f867e..48501aa296a 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -51,6 +51,9 @@ use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; @@ -221,6 +224,20 @@ public function createAction( ); $this->redirect('new'); } + $this->workspaceService->addWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($currentUser->getId()->value), + WorkspaceRole::MANAGER, + ); + $this->workspaceService->addWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleSubjectType::GROUP, + WorkspaceRoleSubject::fromString('Neos.Neos:AbstractEditor'), + WorkspaceRole::COLLABORATOR, + ); $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); $this->redirect('index'); } From 904c716bb4bb5e62f963a98e74f635956ba252ba Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 16:09:00 +0200 Subject: [PATCH 19/39] `WorkspacePermissions` doc comments --- .../Domain/Model/WorkspacePermissions.php | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index 9b08db77ccf..faf543259cf 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -7,18 +7,22 @@ use Neos\Flow\Annotations as Flow; /** + * Calculated permissions a specific user has on a workspace + * + * - read: Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) + * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it + * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + * * @api */ #[Flow\Proxy(false)] final readonly class WorkspacePermissions { - private function __construct( - public bool $read, - public bool $write, - public bool $manage, - ) { - } - + /** + * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) + * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it + * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + */ public static function create( bool $read, bool $write, @@ -27,13 +31,20 @@ public static function create( return new self($read, $write, $manage); } - public static function all(): self - { - return new self(true, true, true); + /** + * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) + * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it + * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + */ + private function __construct( + public bool $read, + public bool $write, + public bool $manage, + ) { } - public static function none(): self + public static function all(): self { - return new self(false, false, false); + return new self(true, true, true); } } From 8bafb5a509e574c3c01ccdc0b211550d52e1bdd7 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 16:16:41 +0200 Subject: [PATCH 20/39] Add more doc comments --- .../Classes/Domain/Model/WorkspaceClassification.php | 10 ++++++---- Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php | 2 ++ Neos.Neos/Classes/Domain/Model/WorkspaceRole.php | 2 +- .../Classes/Domain/Model/WorkspaceRoleSubject.php | 2 +- .../Classes/Domain/Model/WorkspaceRoleSubjectType.php | 4 ++++ 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php index 47f162a0ff3..6d24240d5df 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php @@ -5,13 +5,15 @@ namespace Neos\Neos\Domain\Model; /** + * The classification of a workspace – A workspace is always of one of the covered cases + * * @api */ enum WorkspaceClassification : string { - case PERSONAL = 'PERSONAL'; - case SHARED = 'SHARED'; - case ROOT = 'ROOT'; + case PERSONAL = 'PERSONAL'; // The personal workspace of a Neos user + case SHARED = 'SHARED'; // A workspace that can potentially be shared by multiple Neos users + case ROOT = 'ROOT'; // A workspace without a target, e.g. the "live" workspace - case UNKNOWN = 'UNKNOWN'; + case UNKNOWN = 'UNKNOWN'; // This case represents a classification that could not be determined (i.e. no corresponding workspace metadata exists) } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php index 0f6e7932e59..46b931d33a9 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -8,6 +8,8 @@ use Neos\Flow\Annotations as Flow; /** + * Neos-specific metadata of a workspace + * * @api */ #[Flow\Proxy(false)] diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index dcb444599fc..3a9ce920e08 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -5,7 +5,7 @@ namespace Neos\Neos\Domain\Model; /** - * Role, a user can have in one workspace + * Role, a user or user group can have in one workspace * Note: "Owner" is not a role, owners implicitly always have all permissions * * @api diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php index 4fe05827d85..fb80329b09d 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -7,7 +7,7 @@ use Neos\Flow\Annotations as Flow; /** - * The identifier of a workspace role subject that identifiers a group of users or a single user + * The identifier of a workspace role subject that identifiers a group of users or a single user {@see WorkspaceRoleSubjectType} * * @api */ diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php index 78777a834bd..f4b3eb71e0a 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php @@ -5,6 +5,10 @@ namespace Neos\Neos\Domain\Model; /** + * Type of workspace role subject + * + * A workspace role can be assigned to a single user or a group (defined by the Flow role identifier) + * * @api */ enum WorkspaceRoleSubjectType : string From f68300a61217d54a6eebfd1e05352c85b40b915d Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 8 Oct 2024 17:07:29 +0200 Subject: [PATCH 21/39] wip --- .../Command/WorkspaceCommandController.php | 139 +++++++++++++++++- .../Classes/Domain/Model/WorkspaceRole.php | 4 +- .../Domain/Service/WorkspaceService.php | 87 ++++++++--- .../Controller/WorkspaceController.php | 10 +- 4 files changed, 215 insertions(+), 25 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index f474189f0fc..e9fb7b86003 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -130,7 +130,9 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de } /** - * Create a new root workspace for a content repository. + * Create a new root workspace for a content repository + * + * NOTE: By default, only administrators can access workspaces without role assignments. Use workspace:assignrole to add workspace permissions * * @param string $name Name of the new root * @param string $contentRepository Identifier of the content repository. (Default: 'default') @@ -162,7 +164,8 @@ public function createRootCommand(string $name, string $contentRepository = 'def * @param string $contentRepository Identifier of the content repository. (Default: 'default') * @throws StopCommandException */ - public function createPersonalCommand(string $workspace, string $owner, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void { + public function createPersonalCommand(string $workspace, string $owner, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void + { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); $workspaceOwner = $this->userService->getUser($owner); if ($workspaceOwner === null) { @@ -184,6 +187,8 @@ public function createPersonalCommand(string $workspace, string $owner, string $ /** * Create a new shared workspace * + * NOTE: By default, only administrators can access workspaces without role assignments. Use workspace:assignrole to add workspace permissions + * * @param string $workspace Name of the workspace, for example "christmas-campaign" * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. * @param string|null $title Human friendly title of the workspace, for example "Christmas Campaign" @@ -205,6 +210,136 @@ public function createSharedCommand(string $workspace, string $baseWorkspace = ' $this->outputLine('Created shared workspace "%s"', [$workspaceName->value]); } + /** + * Set/change the title of a workspace + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $newTitle Human friendly title of the workspace, for example "Some workspace" + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function setTitleCommand(string $workspace, string $newTitle, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->setWorkspaceTitle( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($newTitle), + ); + $this->outputLine('Set title of workspace "%s" to "%s"', [$workspaceName->value, $newTitle]); + } + + /** + * Set/change the description of a workspace + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $newDescription Human friendly description of the workspace + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function setDescriptionCommand(string $workspace, string $newDescription, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->setWorkspaceDescription( + $contentRepositoryId, + $workspaceName, + WorkspaceDescription::fromString($newDescription), + ); + $this->outputLine('Set description of workspace "%s"', [$workspaceName->value]); + } + + /** + * Assign a workspace role to the given user/user group + * + * Without explicit workspace roles, only administrators can change the corresponding workspace. + * With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: + * - collaborator: Can read from and write to the workspace + * - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) + * + * Examples: + * + * To grant editors read and write access to a (shared) workspace: ./flow workspace:assignrole some-workspace "Neos.Neos:AbstractEditor" collaborator + * + * To grant a specific user read, write and manage access to a workspace: ./flow workspace:assignrole some-workspace admin manager --type user + * + * {@see WorkspaceRole} + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $subject The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user + * @param string $role Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + * @throws StopCommandException + */ + public function assignRoleCommand(string $workspace, string $subject, string $role, string $contentRepository = 'default', string $type = 'group'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + + $subjectType = match ($type) { + 'group' => WorkspaceRoleSubjectType::GROUP, + 'user' => WorkspaceRoleSubjectType::USER, + default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), + }; + $workspaceRole = match ($role) { + 'collaborator' => WorkspaceRole::COLLABORATOR, + 'manager' => WorkspaceRole::MANAGER, + default => throw new \InvalidArgumentException(sprintf('role must be "collaborator" or "manager", given "%s"', $role), 1728398880), + }; + if ($subjectType === WorkspaceRoleSubjectType::USER) { + $neosUser = $this->userService->getUser($subject); + if ($neosUser === null) { + $this->outputLine('The user "%s" specified as subject does not exist', [$subject]); + $this->quit(1); + } + $roleSubject = WorkspaceRoleSubject::fromString($neosUser->getId()->value); + } else { + $roleSubject = WorkspaceRoleSubject::fromString($subject); + } + $this->workspaceService->assignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + $subjectType, + $roleSubject, + $workspaceRole, + ); + $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]); + } + + /** + * Unassign a workspace role from the given user/user group + * + * @see assignRoleCommand() + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $subject The user/group that should be unassigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + * @throws StopCommandException + */ + public function unassignRoleCommand(string $workspace, string $subject, string $contentRepository = 'default', string $type = 'group'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + + $subjectType = match ($type) { + 'group' => WorkspaceRoleSubjectType::GROUP, + 'user' => WorkspaceRoleSubjectType::USER, + default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), + }; + $roleSubject = WorkspaceRoleSubject::fromString($subject); + $this->workspaceService->unassignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + $subjectType, + $roleSubject, + ); + $this->outputLine('Removed role assignment from subject "%s" for workspace "%s"', [$roleSubject->value, $workspaceName->value]); + } + + /** * Deletes a workspace * diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index 3a9ce920e08..1361eaa9188 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -12,8 +12,8 @@ */ enum WorkspaceRole : string { - case COLLABORATOR = 'COLLABORATOR'; - case MANAGER = 'MANAGER'; + case COLLABORATOR = 'COLLABORATOR'; // Can read from and write to the workspace + case MANAGER = 'MANAGER'; // Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) public function isAtLeast(self $role): bool { diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index edf84c028de..7ebc4d7f48c 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -41,6 +41,8 @@ use Neos\Neos\Domain\Model\WorkspaceTitle; /** + * Central authority to interact with Content Repository Workspaces within Neos + * * @api */ #[Flow\Scope('singleton')] @@ -58,6 +60,8 @@ public function __construct( /** * Load metadata for the specified workspace + * + * Note: If no metadata exists for the specified workspace, an instance with classification {@see WorkspaceClassification::UNKNOWN} is returned! */ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { @@ -73,13 +77,23 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W } /** - * Change title and/or description metadata for the specified workspace + * Update/set title metadata for the specified workspace */ - public function updateWorkspaceTitleAndDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle, WorkspaceDescription $newWorkspaceDescription): void + public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'title' => $newWorkspaceTitle->value, + ]); + } + + /** + * Update/set description metadata for the specified workspace + */ + public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'description' => $newWorkspaceDescription->value, ]); } @@ -154,6 +168,48 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con ); } + /** + * Assign a workspace role to the given user/user group + * + * Without explicit workspace roles, only administrators can change the corresponding workspace. + * With this method, the subject (i.e. a Neos user or group represented by a Flow role identifier) can be granted a {@see WorkspaceRole} for the specified workspace + */ + public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subjectType->value, + 'subject' => $subject->value, + 'role' => $role->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); + } + } + + /** + * Remove a workspace role assignment for the given subject + * + * @see self::assignWorkspaceRole() + */ + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subjectType->value, + 'subject' => $subject->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); + } + } + /** * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} */ @@ -247,10 +303,20 @@ private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void { try { - $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ + $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, ]); + if ($affectedRows === 0) { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'description' => '', + 'title' => '', + 'classification' => WorkspaceClassification::UNKNOWN->value, + ...$data, + ]); + } } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); } @@ -308,21 +374,6 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } - public function addWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role): void - { - try { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject_type' => $subjectType->value, - 'subject' => $subject->value, - 'role' => $role->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); - } - } - private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): ?WorkspaceRole { try { diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 48501aa296a..5f4588cfb7f 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -224,14 +224,14 @@ public function createAction( ); $this->redirect('new'); } - $this->workspaceService->addWorkspaceRole( + $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, WorkspaceRoleSubjectType::USER, WorkspaceRoleSubject::fromString($currentUser->getId()->value), WorkspaceRole::MANAGER, ); - $this->workspaceService->addWorkspaceRole( + $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, WorkspaceRoleSubjectType::GROUP, @@ -297,10 +297,14 @@ public function updateAction( ); $this->redirect('index'); } - $this->workspaceService->updateWorkspaceTitleAndDescription( + $this->workspaceService->setWorkspaceTitle( $contentRepositoryId, $workspaceName, $title, + ); + $this->workspaceService->setWorkspaceDescription( + $contentRepositoryId, + $workspaceName, $description, ); $this->addFlashMessage($this->translator->translateById( From b86d445b208ba6869ab0e5517f00adee994a6623 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:31:19 +0200 Subject: [PATCH 22/39] TASK: Remove obsolete todo after merge of #5258 --- Neos.Media.Browser/Classes/Controller/UsageController.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 89b826814c6..8126757b9ef 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -100,15 +100,10 @@ public function relatedNodesAction(AssetInterface $asset) $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($usage->getWorkspaceName()); - // FIXME: AssetUsageReference->workspaceName ? $nodeAggregate = $contentRepository->getContentGraph($workspace->workspaceName)->findNodeAggregateById( $usage->getNodeAggregateId() ); - try { - $nodeType = $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName); - } catch (NodeTypeNotFound $e) { - $nodeType = null; - } + $nodeType = $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName); $accessible = $this->domainUserService->currentUserCanReadWorkspace($workspace); From eb40e734e081afc1749120201e03c0abc0f2fe8e Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:32:32 +0200 Subject: [PATCH 23/39] TASK: Remove obsolete import statement after merge of #5258 (never push on 9.0 directly) --- Neos.Media.Browser/Classes/Controller/UsageController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 8126757b9ef..baa056ffe36 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -14,7 +14,6 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFound; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; From f7bcf222a1283749c60a29215006481c851433a1 Mon Sep 17 00:00:00 2001 From: Jenkins Date: Tue, 8 Oct 2024 15:35:11 +0000 Subject: [PATCH 24/39] TASK: Update references [skip ci] --- Neos.Neos/Documentation/References/CommandReference.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Form.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Media.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Neos.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index cc3092a7294..79231e0a51b 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-07 +The following reference was automatically generated from code on 2024-10-08 .. _`Neos Command Reference: NEOS.FLOW`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index fc81d30b20b..24082ab1862 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-07 +This reference was automatically generated from code on 2024-10-08 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index 4666d878d99..97c93057e1a 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-07 +This reference was automatically generated from code on 2024-10-08 .. _`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 610975671e1..a7c8e246d4d 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-07 +This reference was automatically generated from code on 2024-10-08 .. _`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 a2861061a47..f0b83165434 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-07 +This reference was automatically generated from code on 2024-10-08 .. _`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 8e225f3e8fc..0c8ca3606f1 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-07 +This reference was automatically generated from code on 2024-10-08 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: From 0c382442e0c409a0c26ad5d7996a298e8c60313f Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:39:06 +0200 Subject: [PATCH 25/39] BUGFIX: Prevent augmenter from applying data of multiple nodes into the same element 9.0 port of https://github.com/neos/neos-ui/pull/3856 Original Commit MSG: This fixes a regression introduced in #b56135a01ecf59ae3a4990e3fd54ac766732e0e6 which removed the script tag, causing the augmenter to add the data of multiple nodes into the same html element in certain cases instead of adding an outer div. With this change this behaviour is now more explicit instead of relying on the inner workings of the augmenter. --- .../Classes/Service/ContentElementWrappingService.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Service/ContentElementWrappingService.php b/Neos.Neos/Classes/Service/ContentElementWrappingService.php index 6a8f60d57f3..72b5aa0f218 100644 --- a/Neos.Neos/Classes/Service/ContentElementWrappingService.php +++ b/Neos.Neos/Classes/Service/ContentElementWrappingService.php @@ -81,7 +81,15 @@ public function wrapContentObject( $attributes['data-__neos-fusion-path'] = $fusionPath; $attributes['data-__neos-node-contextpath'] = $nodeAddress->serializeForUri(); - return $this->htmlAugmenter->addAttributes($content, $attributes, 'div'); + // Define all attribute names as exclusive via the `exclusiveAttributes` parameter, to prevent the data of + // two different nodes to be concatenated into the attributes of a single html node. + // This way an outer div is added, if the wrapped content already has node related data-attributes set. + return $this->htmlAugmenter->addAttributes( + $content, + $attributes, + 'div', + array_keys($attributes) + ); } /** From 3e48aba7a9b450032840606023f3c21f7f0785e3 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:40:29 +0200 Subject: [PATCH 26/39] TASK: Remove obsolete method `addCssClasses` This was missed with: https://github.com/neos/neos-development-collection/pull/4831 The css class is still added via JS, see https://github.com/neos/neos-ui/pull/1892 --- .../Service/ContentElementWrappingService.php | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/Neos.Neos/Classes/Service/ContentElementWrappingService.php b/Neos.Neos/Classes/Service/ContentElementWrappingService.php index 72b5aa0f218..7da3e721dd4 100644 --- a/Neos.Neos/Classes/Service/ContentElementWrappingService.php +++ b/Neos.Neos/Classes/Service/ContentElementWrappingService.php @@ -17,8 +17,6 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; -use Neos\Flow\Session\SessionInterface; use Neos\Fusion\Service\HtmlAugmenter as FusionHtmlAugmenter; use Neos\Neos\FrontendRouting\NodeAddressFactory; @@ -31,24 +29,12 @@ */ class ContentElementWrappingService { - /** - * @Flow\Inject - * @var PrivilegeManagerInterface - */ - protected $privilegeManager; - /** * @Flow\Inject * @var FusionHtmlAugmenter */ protected $htmlAugmenter; - /** - * @Flow\Inject - * @var SessionInterface - */ - protected $session; - /** * @Flow\Inject * @var ContentRepositoryRegistry @@ -91,25 +77,4 @@ public function wrapContentObject( array_keys($attributes) ); } - - /** - * Add required CSS classes to the attributes. - * - * @param array $attributes - * @param array $initialClasses - * @return array - */ - protected function addCssClasses(array $attributes, Node $node, array $initialClasses = []): array - { - $classNames = $initialClasses; - if (!$node->dimensionSpacePoint->equals($node->originDimensionSpacePoint)) { - $classNames[] = 'neos-contentelement-shine-through'; - } - - if ($classNames !== []) { - $attributes['class'] = implode(' ', $classNames); - } - - return $attributes; - } } From 6889251229d7b18f365c61325ce6a63cf7cf6368 Mon Sep 17 00:00:00 2001 From: Jenkins Date: Wed, 9 Oct 2024 09:35:34 +0000 Subject: [PATCH 27/39] TASK: Update references [skip ci] --- Neos.Neos/Documentation/References/CommandReference.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Form.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Media.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/Neos.rst | 2 +- Neos.Neos/Documentation/References/ViewHelpers/TYPO3Fluid.rst | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index 79231e0a51b..3038ff239f3 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-08 +The following reference was automatically generated from code on 2024-10-09 .. _`Neos Command Reference: NEOS.FLOW`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst b/Neos.Neos/Documentation/References/ViewHelpers/FluidAdaptor.rst index 24082ab1862..8e10a397f9d 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-08 +This reference was automatically generated from code on 2024-10-09 .. _`FluidAdaptor ViewHelper Reference: f:debug`: diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Form.rst b/Neos.Neos/Documentation/References/ViewHelpers/Form.rst index 97c93057e1a..b48e8f1754d 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-08 +This reference was automatically generated from code on 2024-10-09 .. _`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 a7c8e246d4d..40445a018a3 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-08 +This reference was automatically generated from code on 2024-10-09 .. _`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 f0b83165434..54c7abc420b 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-08 +This reference was automatically generated from code on 2024-10-09 .. _`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 0c8ca3606f1..73aaecac4d3 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-08 +This reference was automatically generated from code on 2024-10-09 .. _`TYPO3 Fluid ViewHelper Reference: f:alias`: From 7d6a819a97fd9958a7a9b852a7776106a6bf91ff Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 12:05:31 +0200 Subject: [PATCH 28/39] Fix and improve Behat tests for WorkspaceService ...and fix the discovered bugs along the way o.O --- .../Domain/Service/WorkspaceService.php | 11 +- .../Features/Bootstrap/ExceptionsTrait.php | 59 ++++++++ .../Bootstrap/WorkspaceServiceTrait.php | 114 +++++++++++---- .../WorkspaceService.feature | 137 +++++++++--------- 4 files changed, 215 insertions(+), 106 deletions(-) create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 7ebc4d7f48c..d79001c2ca2 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -65,13 +65,13 @@ public function __construct( */ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { - $crWorkspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); return $metadata ?? new WorkspaceMetadata( $workspaceName, WorkspaceTitle::fromString($workspaceName->value), WorkspaceDescription::fromString(''), - $crWorkspace->baseWorkspaceName !== null ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, + $workspace->baseWorkspaceName === null ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, null, ); } @@ -81,7 +81,6 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'title' => $newWorkspaceTitle->value, ]); @@ -92,7 +91,6 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { - $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'description' => $newWorkspaceDescription->value, ]); @@ -302,6 +300,7 @@ private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, */ private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void { + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); try { $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ 'content_repository_id' => $contentRepositoryId->value, @@ -312,8 +311,8 @@ private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryI 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, 'description' => '', - 'title' => '', - 'classification' => WorkspaceClassification::UNKNOWN->value, + 'title' => $workspaceName->value, + 'classification' => $workspace->baseWorkspaceName === null ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, ...$data, ]); } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php new file mode 100644 index 00000000000..fabfcd12608 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -0,0 +1,59 @@ +lastCaughtException !== null) { + throw new \RuntimeException(sprintf('Can\'t execute new commands before catching previous exception: %s', $this->lastCaughtException->getMessage()), 1728464381, $this->lastCaughtException); + } + try { + return $callback(); + } catch (\Exception $exception) { + $this->lastCaughtException = $exception; + return null; + } + } + + /** + * @Then an exception :exceptionMessage should be thrown + */ + public function anExceptionShouldBeThrown(string $exceptionMessage): void + { + Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown'); + Assert::assertSame($exceptionMessage, $this->lastCaughtException->getMessage()); + $this->lastCaughtException = null; + } + + /** + * @BeforeScenario + * @AfterScenario + */ + public function afterScenarioExceptionsTrait(): void + { + if ($this->lastCaughtException !== null) { + throw new \RuntimeException(sprintf('Previous exception was not handled: %s', $this->lastCaughtException->getMessage()), 1728464379, $this->lastCaughtException); + } + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 9ad574f4a3b..d8e044c5ff9 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -1,8 +1,9 @@ getObject(WorkspaceService::class)->createRootWorkspace( + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createRootWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - WorkspaceTitle::fromString($workspaceName), - WorkspaceDescription::fromString(''), - ); + WorkspaceTitle::fromString($title ?? $workspaceName), + WorkspaceDescription::fromString($description ?? ''), + )); } /** - * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace + * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :ownerUserId */ - public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $ownerUserId): void { - $this->getObject(WorkspaceService::class)->createPersonalWorkspace( + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createPersonalWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), - UserId::fromString(FakeUserIdProvider::$userId?->value ?? ''), - ); + UserId::fromString($ownerUserId), + )); } /** @@ -71,33 +78,78 @@ public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $work */ public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void { - $this->getObject(WorkspaceService::class)->createSharedWorkspace( + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createSharedWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), - ); + )); + } + + /** + * @When a root workspace :workspaceName exists without metadata + */ + public function aRootWorkspaceExistsWithoutMetadata(string $workspaceName): void + { + $this->currentContentRepository->handle(CreateRootWorkspace::create( + WorkspaceName::fromString($workspaceName), + DeprecatedWorkspaceTitle::fromString($workspaceName), + DeprecatedWorkspaceDescription::fromString(''), + ContentStreamId::create(), + )); + } + + /** + * @When a workspace :arg1 with base workspace :arg2 exists without metadata + */ + public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspaceName, string $baseWorkspaceName): void + { + $this->currentContentRepository->handle(CreateWorkspace::create( + WorkspaceName::fromString($workspaceName), + WorkspaceName::fromString($baseWorkspaceName), + DeprecatedWorkspaceTitle::fromString($workspaceName), + DeprecatedWorkspaceDescription::fromString(''), + ContentStreamId::create(), + )); + } + + /** + * @When the title of workspace :workspaceName is set to :newTitle + */ + public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($newTitle), + )); + } + + /** + * @When the description of workspace :workspaceName is set to :newDescription + */ + public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceDescription::fromString($newDescription), + )); } /** - * @Then the following workspaces should exist: + * @Then the workspace :workspaceName should have the following metadata: */ - public function theFollowingWorkspacesShouldExist(TableNode $expectedWorkspacesTable): void + public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, TableNode $expectedMetadata): void { - $expectedWorkspaces = $expectedWorkspacesTable->getHash(); - $actualWorkspaces = []; - $workspaceFinder = $this->currentContentRepository->getWorkspaceFinder(); - $workspaceService = $this->getObject(WorkspaceService::class); - foreach ($workspaceFinder->findAll() as $workspace) { - $workspaceMetadata = $workspaceService->getWorkspaceMetadata($this->currentContentRepository->id, $workspace->workspaceName); - $actualWorkspaces[] = [ - 'Name' => $workspace->workspaceName->value, - 'Base workspace' => $workspace->baseWorkspaceName?->value ?? '', - 'Title' => $workspaceMetadata->title->value, - 'Classification' => $workspaceMetadata->classification->value, - ]; - } - Assert::same($expectedWorkspaces, $actualWorkspaces); + $workspaceMetadata = $this->getObject(WorkspaceService::class)->getWorkspaceMetadata($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + Assert::assertSame($expectedMetadata->getHash()[0], [ + 'Workspace name' => $workspaceMetadata->workspaceName->value, + 'Title' => $workspaceMetadata->title->value, + 'Description' => $workspaceMetadata->description->value, + 'Classification' => $workspaceMetadata->classification->value, + 'Owner user id' => $workspaceMetadata->ownerUserId?->value ?? '', + ]); } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 5c8eaac6faa..bea118232bf 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -11,80 +11,79 @@ Feature: Neos WorkspaceService related features And I am in content repository "default" And I am user identified by "editor" - Scenario: Create single root workspace - When the root workspace "Some root workspace" is created - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | some-root-workspace | | Some root workspace | ROOT | + Scenario: Create single root workspace without specifying title and description + When the root workspace "some-root-workspace" is created + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | some-root-workspace | | ROOT | | - Scenario: Create root workspace with a title that exceeds the workspace name max length - When the root workspace "Some root workspace with a title that exceeds the max name length" is created - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | some-root-workspace-with-a-title-tha | | Some root workspace with a title that exceeds the max name length | ROOT | + Scenario: Create single root workspace with title and description + When the root workspace "some-root-workspace" with title "Some root workspace" and description "Some description" is created + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | Some root workspace | Some description | ROOT | | - Scenario: Create multiple root workspaces with the same derived name - When the root workspace "Root" is created - And the root workspace "Root 5" is created - And the root workspace "root" is created - And the root workspace "-Root" is created - And the root workspace "Root" is created - And the root workspace "Root" is created - And the root workspace "Root" is created - And the root workspace "Root" is created - And the root workspace "Root" is created - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | root | | Root | ROOT | - | root-1 | | root | ROOT | - | root-2 | | -Root | ROOT | - | root-3 | | Root | ROOT | - | root-4 | | Root | ROOT | - | root-5 | | Root 5 | ROOT | - | root-6 | | Root | ROOT | - | root-7 | | Root | ROOT | - | root-8 | | Root | ROOT | + Scenario: Create root workspace with a name that exceeds the workspace name max length + When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created + Then an exception 'Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters' should be thrown + + Scenario: Create root workspace with a name that is already used + Given the root workspace "some-root-workspace" is created + When the root workspace "some-root-workspace" is created + Then an exception "The workspace some-root-workspace already exists" should be thrown + + Scenario: Get metadata of non-existing root workspace + When a root workspace "some-root-workspace" exists without metadata + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | some-root-workspace | | ROOT | | + + Scenario: Change title of root workspace + When the root workspace "some-root-workspace" is created + And the title of workspace "some-root-workspace" is set to "Some new workspace title" + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace title | | ROOT | | + + Scenario: Set title of root workspace without metadata + When a root workspace "some-root-workspace" exists without metadata + And the title of workspace "some-root-workspace" is set to "Some new workspace title" + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace title | | ROOT | | + + Scenario: Change description of root workspace + When the root workspace "some-root-workspace" is created + And the description of workspace "some-root-workspace" is set to "Some new workspace description" + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | some-root-workspace | Some new workspace description | ROOT | | + + Scenario: Change description of root workspace without metadata + When a root workspace "some-root-workspace" exists without metadata + And the description of workspace "some-root-workspace" is set to "Some new workspace description" + Then the workspace "some-root-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-root-workspace | some-root-workspace | Some new workspace description | ROOT | | - Scenario: Create multiple root workspaces with the same derived name with a lenght that exceeds the allowed max length - And the root workspace "some-root-workspace-with-a-long-title" is created - And the root workspace "some-root-workspace-with-a-long-title" is created - And the root workspace "some-root-workspace-with-a-long-title" is created - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | some-root-workspace-with-a-long-ti-1 | | some-root-workspace-with-a-long-title | ROOT | - | some-root-workspace-with-a-long-ti-2 | | some-root-workspace-with-a-long-title | ROOT | - | some-root-workspace-with-a-long-titl | | some-root-workspace-with-a-long-title | ROOT | Scenario: Create a single personal workspace - When the root workspace "Some root workspace" is created - And the personal workspace "Some user workspace" is created with the target workspace "some-root-workspace" - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | some-root-workspace | | Some root workspace | ROOT | - | some-user-workspace | some-root-workspace | Some user workspace | PERSONAL | + When the root workspace "some-root-workspace" is created + And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id" + Then the workspace "some-user-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-user-workspace | some-user-workspace | | PERSONAL | some-user-id | Scenario: Create a single shared workspace - When the root workspace "Some root workspace" is created - And the shared workspace "Some shared workspace" is created with the target workspace "some-root-workspace" - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | some-root-workspace | | Some root workspace | ROOT | - | some-shared-workspace | some-root-workspace | Some shared workspace | SHARED | + When the root workspace "some-root-workspace" is created + And the shared workspace "some-shared-workspace" is created with the target workspace "some-root-workspace" + Then the workspace "some-shared-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-shared-workspace | some-shared-workspace | | SHARED | | - Scenario: Creating several workspaces with the same derived names - When the root workspace "root 1" is created - And the root workspace "Root 2" is created - And the personal workspace "User 1" is created with the target workspace "root-1" - And the personal workspace "User 2" is created with the target workspace "root-2" - And the personal workspace "root 1" is created with the target workspace "root-2" - And the shared workspace "Root 1" is created with the target workspace "root-2" - And the shared workspace "Shared 2" is created with the target workspace "root-1" - Then the following workspaces should exist: - | Name | Base workspace | Title | Classification | - | root-1 | | root 1 | ROOT | - | root-1-1 | root-2 | root 1 | PERSONAL | - | root-1-2 | root-2 | Root 1 | SHARED | - | root-2 | | Root 2 | ROOT | - | shared-2 | root-1 | Shared 2 | SHARED | - | user-1 | root-1 | User 1 | PERSONAL | - | user-2 | root-2 | User 2 | PERSONAL | + Scenario: Get metadata of non-existing sub workspace + Given the root workspace "some-root-workspace" is created + When a workspace "some-workspace" with base workspace "some-root-workspace" exists without metadata + Then the workspace "some-workspace" should have the following metadata: + | Workspace name | Title | Description | Classification | Owner user id | + | some-workspace | some-workspace | | UNKNOWN | | From 3b3d718ae3c6a45c899da2cf8f3deb1c8f0975fa Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 14:06:39 +0200 Subject: [PATCH 29/39] Remove `workspaceName` from `WorkspaceMetadata` --- .../Domain/Model/WorkspaceMetadata.php | 17 -------- .../Domain/Service/WorkspaceService.php | 2 - .../Bootstrap/WorkspaceServiceTrait.php | 1 - .../WorkspaceService.feature | 40 +++++++++---------- 4 files changed, 20 insertions(+), 40 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php index 46b931d33a9..7421014d386 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -16,27 +16,10 @@ final readonly class WorkspaceMetadata { public function __construct( - public WorkspaceName $workspaceName, public WorkspaceTitle $title, public WorkspaceDescription $description, public WorkspaceClassification $classification, public UserId|null $ownerUserId, ) { } - - /** - * Note: To be used with named arguments! - */ - public function with( - WorkspaceTitle $title = null, - WorkspaceDescription $description = null, - ): self { - return new self( - $this->workspaceName, - $title ?? $this->title, - $description ?? $this->description, - $this->classification, - $this->ownerUserId, - ); - } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index d79001c2ca2..11276aac95a 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -68,7 +68,6 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); return $metadata ?? new WorkspaceMetadata( - $workspaceName, WorkspaceTitle::fromString($workspaceName->value), WorkspaceDescription::fromString(''), $workspace->baseWorkspaceName === null ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, @@ -287,7 +286,6 @@ private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, return null; } return new WorkspaceMetadata( - $workspaceName, WorkspaceTitle::fromString($metadataRow['title']), WorkspaceDescription::fromString($metadataRow['description']), WorkspaceClassification::from($metadataRow['classification']), diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index d8e044c5ff9..71ab1a2a94a 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -145,7 +145,6 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table { $workspaceMetadata = $this->getObject(WorkspaceService::class)->getWorkspaceMetadata($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); Assert::assertSame($expectedMetadata->getHash()[0], [ - 'Workspace name' => $workspaceMetadata->workspaceName->value, 'Title' => $workspaceMetadata->title->value, 'Description' => $workspaceMetadata->description->value, 'Classification' => $workspaceMetadata->classification->value, diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index bea118232bf..205b9c32968 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -14,14 +14,14 @@ Feature: Neos WorkspaceService related features Scenario: Create single root workspace without specifying title and description When the root workspace "some-root-workspace" is created Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | some-root-workspace | | ROOT | | + | Title | Description | Classification | Owner user id | + | some-root-workspace | | ROOT | | Scenario: Create single root workspace with title and description When the root workspace "some-root-workspace" with title "Some root workspace" and description "Some description" is created Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | Some root workspace | Some description | ROOT | | + | Title | Description | Classification | Owner user id | + | Some root workspace | Some description | ROOT | | Scenario: Create root workspace with a name that exceeds the workspace name max length When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created @@ -35,55 +35,55 @@ Feature: Neos WorkspaceService related features Scenario: Get metadata of non-existing root workspace When a root workspace "some-root-workspace" exists without metadata Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | some-root-workspace | | ROOT | | + | Title | Description | Classification | Owner user id | + | some-root-workspace | | ROOT | | Scenario: Change title of root workspace When the root workspace "some-root-workspace" is created And the title of workspace "some-root-workspace" is set to "Some new workspace title" Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | Some new workspace title | | ROOT | | + | Title | Description | Classification | Owner user id | + | Some new workspace title | | ROOT | | Scenario: Set title of root workspace without metadata When a root workspace "some-root-workspace" exists without metadata And the title of workspace "some-root-workspace" is set to "Some new workspace title" Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | Some new workspace title | | ROOT | | + | Title | Description | Classification | Owner user id | + | Some new workspace title | | ROOT | | Scenario: Change description of root workspace When the root workspace "some-root-workspace" is created And the description of workspace "some-root-workspace" is set to "Some new workspace description" Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | some-root-workspace | Some new workspace description | ROOT | | + | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace description | ROOT | | Scenario: Change description of root workspace without metadata When a root workspace "some-root-workspace" exists without metadata And the description of workspace "some-root-workspace" is set to "Some new workspace description" Then the workspace "some-root-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-root-workspace | some-root-workspace | Some new workspace description | ROOT | | + | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace description | ROOT | | Scenario: Create a single personal workspace When the root workspace "some-root-workspace" is created And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id" Then the workspace "some-user-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-user-workspace | some-user-workspace | | PERSONAL | some-user-id | + | Title | Description | Classification | Owner user id | + | some-user-workspace | | PERSONAL | some-user-id | Scenario: Create a single shared workspace When the root workspace "some-root-workspace" is created And the shared workspace "some-shared-workspace" is created with the target workspace "some-root-workspace" Then the workspace "some-shared-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-shared-workspace | some-shared-workspace | | SHARED | | + | Title | Description | Classification | Owner user id | + | some-shared-workspace | | SHARED | | Scenario: Get metadata of non-existing sub workspace Given the root workspace "some-root-workspace" is created When a workspace "some-workspace" with base workspace "some-root-workspace" exists without metadata Then the workspace "some-workspace" should have the following metadata: - | Workspace name | Title | Description | Classification | Owner user id | - | some-workspace | some-workspace | | UNKNOWN | | + | Title | Description | Classification | Owner user id | + | some-workspace | | UNKNOWN | | From e284cf007f468c407ef6f35bc399163dc1356b70 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 14:07:33 +0200 Subject: [PATCH 30/39] Fix typo in private method --- Neos.Neos/Classes/Domain/Model/WorkspaceRole.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index 1361eaa9188..f91bb5acbc2 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -17,10 +17,10 @@ enum WorkspaceRole : string public function isAtLeast(self $role): bool { - return $this->specifity() >= $role->specifity(); + return $this->specificity() >= $role->specificity(); } - private function specifity(): int + private function specificity(): int { return match ($this) { self::COLLABORATOR => 1, From cc85cbf6bd15ed50d983ab119a0c5c516191c2cb Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:19:33 +0200 Subject: [PATCH 31/39] TASK: Improve documentation --- .../Domain/Model/WorkspaceClassification.php | 26 ++++++++++++++++--- .../Domain/Model/WorkspaceMetadata.php | 16 ------------ .../Classes/Domain/Model/WorkspaceRole.php | 11 ++++++-- .../Domain/Service/WorkspaceService.php | 4 ++- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php index 6d24240d5df..63ee82f0d38 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php @@ -4,6 +4,8 @@ namespace Neos\Neos\Domain\Model; +use Neos\Neos\Domain\Service\WorkspaceService; + /** * The classification of a workspace – A workspace is always of one of the covered cases * @@ -11,9 +13,25 @@ */ enum WorkspaceClassification : string { - case PERSONAL = 'PERSONAL'; // The personal workspace of a Neos user - case SHARED = 'SHARED'; // A workspace that can potentially be shared by multiple Neos users - case ROOT = 'ROOT'; // A workspace without a target, e.g. the "live" workspace + /** + * The personal workspace of a Neos user + */ + case PERSONAL = 'PERSONAL'; + + /** + * A workspace that can potentially be shared by multiple Neos users + */ + case SHARED = 'SHARED'; + + /** + * A workspace without a target, e.g. the "live" workspace + */ + case ROOT = 'ROOT'; - case UNKNOWN = 'UNKNOWN'; // This case represents a classification that could not be determined (i.e. no corresponding workspace metadata exists) + /** + * A non-root content repository workspace without corresponding metadata + * + * In case workspaces were created through the content repository and not through Neos' {@see WorkspaceService} + */ + case UNKNOWN = 'UNKNOWN'; } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php index 46b931d33a9..b2bebc8147c 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -23,20 +23,4 @@ public function __construct( public UserId|null $ownerUserId, ) { } - - /** - * Note: To be used with named arguments! - */ - public function with( - WorkspaceTitle $title = null, - WorkspaceDescription $description = null, - ): self { - return new self( - $this->workspaceName, - $title ?? $this->title, - $description ?? $this->description, - $this->classification, - $this->ownerUserId, - ); - } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index 1361eaa9188..40842ecd4dd 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -12,8 +12,15 @@ */ enum WorkspaceRole : string { - case COLLABORATOR = 'COLLABORATOR'; // Can read from and write to the workspace - case MANAGER = 'MANAGER'; // Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) + /** + * Can read from and write to the workspace + */ + case COLLABORATOR = 'COLLABORATOR'; + + /** + * Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) + */ + case MANAGER = 'MANAGER'; public function isAtLeast(self $role): bool { diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index d79001c2ca2..b9c8cb3ca33 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -61,7 +61,9 @@ public function __construct( /** * Load metadata for the specified workspace * - * Note: If no metadata exists for the specified workspace, an instance with classification {@see WorkspaceClassification::UNKNOWN} is returned! + * Note: If no metadata exists for the specified workspace, metadata with title based on the name and classification + * according to the content repository workspace is returned. Root workspaces are of classification ROOT whereas simple ones will yield UNKNOWN. + * {@see WorkspaceClassification::UNKNOWN} */ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { From 5fc33d5cd3804c714757b38aedec2f9537818b82 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 14:35:12 +0200 Subject: [PATCH 32/39] Allow to determine Workspace role assignments --- .../Command/WorkspaceCommandController.php | 18 +++++++ .../Domain/Model/WorkspaceRoleAssignment.php | 23 ++++++++ .../Domain/Model/WorkspaceRoleAssignments.php | 48 +++++++++++++++++ .../Domain/Service/WorkspaceService.php | 49 ++++++++++++++++- .../Bootstrap/WorkspaceServiceTrait.php | 47 +++++++++++++++++ .../WorkspaceService.feature | 52 +++++++++++++++++++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index e9fb7b86003..a066a36452d 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -31,6 +31,7 @@ use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; @@ -519,5 +520,22 @@ public function showCommand(string $workspace, string $contentRepository = 'defa $this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]); $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); + + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); + $this->outputLine(); + $this->outputLine('Role assignments:'); + if ($workspaceRoleAssignments->isEmpty()) { + $this->outputLine('There are no role assignments for workspace "%s". Use the workspace:assignrole command to assign roles', [$workspaceName->value]); + return; + } + $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + $assignment->subjectType->value, + $assignment->subject->value, + $assignment->role->value, + ], iterator_to_array($workspaceRoleAssignments)), [ + 'Subject type', + 'Subject', + 'Role', + ]); } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php new file mode 100644 index 00000000000..ba6e6370138 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -0,0 +1,23 @@ + + * @api + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceRoleAssignments implements \IteratorAggregate, \Countable +{ + private array $assignments; + + private function __construct(WorkspaceRoleAssignment ...$assignments) + { + $this->assignments = $assignments; + } + + /** + * @param array $assignments + */ + public static function fromArray(array $assignments): self + { + return new self(...$assignments); + } + + public function isEmpty(): bool + { + return $this->assignments === []; + } + + public function getIterator(): Traversable + { + yield from $this->assignments; + } + + public function count(): int + { + return count($this->assignments); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index ecd51f928ff..1131422a81d 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DbalException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -36,6 +37,8 @@ use Neos\Neos\Domain\Model\WorkspaceMetadata; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; @@ -184,6 +187,8 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo 'subject' => $subject->value, 'role' => $role->value, ]); + } catch (UniqueConstraintViolationException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $subject->value, $contentRepositoryId->value), 1728476154, $e); } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); } @@ -198,7 +203,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, { $this->requireWorkspace($contentRepositoryId, $workspaceName); try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, 'subject_type' => $subjectType->value, @@ -207,6 +212,43 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); } + if ($affectedRows === 0) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); + } + } + + /** + * Get all role assignments for the specified workspace + * + * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used! + */ + public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchAllAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); + } + return WorkspaceRoleAssignments::fromArray( + array_map(static fn (array $row) => new WorkspaceRoleAssignment( + WorkspaceRoleSubjectType::from($row['subject_type']), + WorkspaceRoleSubject::fromString($row['subject']), + WorkspaceRole::from($row['role']), + ), $rows) + ); } /** @@ -234,6 +276,11 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos ); } + /** + * Builds a workspace name that is unique within the specified content repository. + * If $candidate already refers to a workspace name that is not used yet, it will be used (with transliteration to enforce a valid format) + * Otherwise a counter "-n" suffix is appended and increased until a unique name is found, or the maximum number of attempts has been reached (in which case an exception is thrown) + */ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, string $candidate): WorkspaceName { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 71ab1a2a94a..fa7c30b70c3 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -22,6 +22,10 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle as DeprecatedWorkspaceTitle; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\WorkspaceService; use PHPUnit\Framework\Assert; @@ -151,4 +155,47 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table 'Owner user id' => $workspaceMetadata->ownerUserId?->value ?? '', ]); } + + /** + * @When the role :role is assigned to workspace :workspaceName for group :groupName + * @When the role :role is assigned to workspace :workspaceName for user :username + */ + public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + WorkspaceRole::from($role) + )); + } + + /** + * @When the role for group :groupName is unassigned from workspace :workspaceName + * @When the role for user :username is unassigned from workspace :workspaceName + */ + public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + )); + } + + /** + * @Then the workspace :workspaceName should have the following role assignments: + */ + public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName, TableNode $expectedAssignments): void + { + $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + 'Subject type' => $assignment->subjectType->value, + 'Subject' => $assignment->subject->value, + 'Role' => $assignment->role->value, + ], iterator_to_array($workspaceAssignments)); + Assert::assertSame($expectedAssignments->getHash(), $actualAssignments); + } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 205b9c32968..073288a47b1 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -87,3 +87,55 @@ Feature: Neos WorkspaceService related features Then the workspace "some-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | | some-workspace | | UNKNOWN | | + + Scenario: Assign role to non-existing workspace + When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Assign group role to root workspace + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + + Scenario: Assign a role to the same group twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Assign user role to root workspace + Given the root workspace "some-root-workspace" is created + When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | + + Scenario: Assign a role to the same user twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Unassign role from non-existing workspace + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Unassign role from workspace that has not been assigned before + Given the root workspace "some-root-workspace" is created + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown + + Scenario: Assign two roles, then unassign one + Given the root workspace "some-root-workspace" is created + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + | USER | some-user-id | MANAGER | + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | From 31119ae4efc06f0a7b5fc8d9cc7206e34f0a61dd Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 14:35:12 +0200 Subject: [PATCH 33/39] Allow to determine Workspace role assignments --- .../Command/WorkspaceCommandController.php | 18 +++++++ .../Domain/Model/WorkspaceRoleAssignment.php | 23 ++++++++ .../Domain/Model/WorkspaceRoleAssignments.php | 51 ++++++++++++++++++ .../Domain/Service/WorkspaceService.php | 49 ++++++++++++++++- .../Bootstrap/WorkspaceServiceTrait.php | 47 +++++++++++++++++ .../WorkspaceService.feature | 52 +++++++++++++++++++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index e9fb7b86003..a066a36452d 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -31,6 +31,7 @@ use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; @@ -519,5 +520,22 @@ public function showCommand(string $workspace, string $contentRepository = 'defa $this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]); $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); + + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); + $this->outputLine(); + $this->outputLine('Role assignments:'); + if ($workspaceRoleAssignments->isEmpty()) { + $this->outputLine('There are no role assignments for workspace "%s". Use the workspace:assignrole command to assign roles', [$workspaceName->value]); + return; + } + $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + $assignment->subjectType->value, + $assignment->subject->value, + $assignment->role->value, + ], iterator_to_array($workspaceRoleAssignments)), [ + 'Subject type', + 'Subject', + 'Role', + ]); } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php new file mode 100644 index 00000000000..ba6e6370138 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -0,0 +1,23 @@ + + * @api + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceRoleAssignments implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $assignments; + + private function __construct(WorkspaceRoleAssignment ...$assignments) + { + $this->assignments = $assignments; + } + + /** + * @param array $assignments + */ + public static function fromArray(array $assignments): self + { + return new self(...$assignments); + } + + public function isEmpty(): bool + { + return $this->assignments === []; + } + + public function getIterator(): Traversable + { + yield from $this->assignments; + } + + public function count(): int + { + return count($this->assignments); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index ecd51f928ff..1131422a81d 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DbalException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -36,6 +37,8 @@ use Neos\Neos\Domain\Model\WorkspaceMetadata; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; @@ -184,6 +187,8 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo 'subject' => $subject->value, 'role' => $role->value, ]); + } catch (UniqueConstraintViolationException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $subject->value, $contentRepositoryId->value), 1728476154, $e); } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); } @@ -198,7 +203,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, { $this->requireWorkspace($contentRepositoryId, $workspaceName); try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, 'subject_type' => $subjectType->value, @@ -207,6 +212,43 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, } catch (DbalException $e) { throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); } + if ($affectedRows === 0) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); + } + } + + /** + * Get all role assignments for the specified workspace + * + * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used! + */ + public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchAllAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); + } + return WorkspaceRoleAssignments::fromArray( + array_map(static fn (array $row) => new WorkspaceRoleAssignment( + WorkspaceRoleSubjectType::from($row['subject_type']), + WorkspaceRoleSubject::fromString($row['subject']), + WorkspaceRole::from($row['role']), + ), $rows) + ); } /** @@ -234,6 +276,11 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos ); } + /** + * Builds a workspace name that is unique within the specified content repository. + * If $candidate already refers to a workspace name that is not used yet, it will be used (with transliteration to enforce a valid format) + * Otherwise a counter "-n" suffix is appended and increased until a unique name is found, or the maximum number of attempts has been reached (in which case an exception is thrown) + */ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, string $candidate): WorkspaceName { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 71ab1a2a94a..fa7c30b70c3 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -22,6 +22,10 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle as DeprecatedWorkspaceTitle; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\WorkspaceService; use PHPUnit\Framework\Assert; @@ -151,4 +155,47 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table 'Owner user id' => $workspaceMetadata->ownerUserId?->value ?? '', ]); } + + /** + * @When the role :role is assigned to workspace :workspaceName for group :groupName + * @When the role :role is assigned to workspace :workspaceName for user :username + */ + public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + WorkspaceRole::from($role) + )); + } + + /** + * @When the role for group :groupName is unassigned from workspace :workspaceName + * @When the role for user :username is unassigned from workspace :workspaceName + */ + public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + )); + } + + /** + * @Then the workspace :workspaceName should have the following role assignments: + */ + public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName, TableNode $expectedAssignments): void + { + $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + 'Subject type' => $assignment->subjectType->value, + 'Subject' => $assignment->subject->value, + 'Role' => $assignment->role->value, + ], iterator_to_array($workspaceAssignments)); + Assert::assertSame($expectedAssignments->getHash(), $actualAssignments); + } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 205b9c32968..073288a47b1 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -87,3 +87,55 @@ Feature: Neos WorkspaceService related features Then the workspace "some-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | | some-workspace | | UNKNOWN | | + + Scenario: Assign role to non-existing workspace + When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Assign group role to root workspace + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + + Scenario: Assign a role to the same group twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Assign user role to root workspace + Given the root workspace "some-root-workspace" is created + When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | + + Scenario: Assign a role to the same user twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Unassign role from non-existing workspace + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Unassign role from workspace that has not been assigned before + Given the root workspace "some-root-workspace" is created + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown + + Scenario: Assign two roles, then unassign one + Given the root workspace "some-root-workspace" is created + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + | USER | some-user-id | MANAGER | + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | From 51c720198bb6a7623bb191c1eda3b66c22f9f6d9 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 15:03:13 +0200 Subject: [PATCH 34/39] stabilize WorkspaceMetadata constructor --- Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php index 7421014d386..582b96f04f2 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -21,5 +21,8 @@ public function __construct( public WorkspaceClassification $classification, public UserId|null $ownerUserId, ) { + if ($this->classification === WorkspaceClassification::PERSONAL && $this->ownerUserId === null) { + throw new \InvalidArgumentException('The owner-user-id must be set if the workspace is personal.', 1728476633); + } } } From aa1655d400cb8b6a0068d57205c692ee250c1d1e Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 9 Oct 2024 15:03:27 +0200 Subject: [PATCH 35/39] Fix `WorkspaceService::getWorkspacePermissionsForUser()` --- .../Domain/Service/WorkspaceService.php | 21 +++++++++++-------- .../WorkspaceService.feature | 1 - 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 1131422a81d..19cbd5faac1 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -256,7 +256,12 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito */ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions { - $userIsAdministrator = $this->userService->currentUserIsAdministrator(); + try { + $userRoles = array_keys($this->userService->getAllRoles($user)); + } catch (NoSuchRoleException $e) { + throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e); + } + $userIsAdministrator = in_array('Neos.Neos:Administrator', $userRoles, true); $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); if ($workspaceMetadata === null) { return WorkspacePermissions::create(false, false, $userIsAdministrator); @@ -265,7 +270,7 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos return WorkspacePermissions::all(); } - $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user); + $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), $userRoles); if ($userWorkspaceRole === null) { return WorkspacePermissions::create(false, false, $userIsAdministrator); } @@ -420,13 +425,11 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } - private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): ?WorkspaceRole + /** + * @param array $userRoles + */ + private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): ?WorkspaceRole { - try { - $userRoles = array_keys($this->userService->getAllRoles($user)); - } catch (NoSuchRoleException $e) { - throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e); - } $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; $query = << $contentRepositoryId->value, 'workspaceName' => $workspaceName->value, 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, - 'userId' => $user->getId()->value, + 'userId' => $userId->value, 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, 'groupSubjects' => $userRoles, ], [ diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 073288a47b1..3aeea41fe92 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -9,7 +9,6 @@ Feature: Neos WorkspaceService related features """ And using identifier "default", I define a content repository And I am in content repository "default" - And I am user identified by "editor" Scenario: Create single root workspace without specifying title and description When the root workspace "some-root-workspace" is created From 5540c83b188c02b2adda7fcfab09b00943d3551b Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:23:33 +0200 Subject: [PATCH 36/39] TASK: Use `WorkspaceRoleAssignment` in `assignWorkspaceRole` signatur --- .../Command/WorkspaceCommandController.php | 8 ++++-- .../Domain/Model/WorkspaceRoleAssignment.php | 28 ++++++++++++++++++- .../Domain/Service/WorkspaceService.php | 15 +++++----- .../Bootstrap/WorkspaceServiceTrait.php | 8 ++++-- .../Controller/WorkspaceController.php | 17 +++++------ 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index a066a36452d..b55b5833658 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -302,9 +302,11 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, - $subjectType, - $roleSubject, - $workspaceRole, + WorkspaceRoleAssignment::create( + $subjectType, + $roleSubject, + $workspaceRole + ) ); $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]); } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php index ba6e6370138..fd7d5a7896f 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -14,10 +14,36 @@ #[Flow\Proxy(false)] final readonly class WorkspaceRoleAssignment { - public function __construct( + private function __construct( public WorkspaceRoleSubjectType $subjectType, public WorkspaceRoleSubject $subject, public WorkspaceRole $role, ) { } + + public static function create( + WorkspaceRoleSubjectType $subjectType, + WorkspaceRoleSubject $subject, + WorkspaceRole $role, + ): self { + return new self($subjectType, $subject, $role); + } + + public static function createForUser(UserId $userId, WorkspaceRole $role): self + { + return new self( + WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($userId->value), + $role + ); + } + + public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role): self + { + return new self( + WorkspaceRoleSubjectType::GROUP, + WorkspaceRoleSubject::fromString($flowRoleIdentifier), + $role + ); + } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 19cbd5faac1..5f854bc2689 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -151,6 +151,7 @@ public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, /** * Create a new, personal, workspace for the specified user if none exists yet + * @internal experimental api, until actually used by the Neos.Ui */ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void { @@ -176,21 +177,21 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con * Without explicit workspace roles, only administrators can change the corresponding workspace. * With this method, the subject (i.e. a Neos user or group represented by a Flow role identifier) can be granted a {@see WorkspaceRole} for the specified workspace */ - public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role): void + public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); try { $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => $subjectType->value, - 'subject' => $subject->value, - 'role' => $role->value, + 'subject_type' => $assignment->subjectType->value, + 'subject' => $assignment->subject->value, + 'role' => $assignment->role->value, ]); } catch (UniqueConstraintViolationException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $subject->value, $contentRepositoryId->value), 1728476154, $e); + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); } } @@ -243,7 +244,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); } return WorkspaceRoleAssignments::fromArray( - array_map(static fn (array $row) => new WorkspaceRoleAssignment( + array_map(static fn (array $row) => WorkspaceRoleAssignment::create( WorkspaceRoleSubjectType::from($row['subject_type']), WorkspaceRoleSubject::fromString($row['subject']), WorkspaceRole::from($row['role']), diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index fa7c30b70c3..0570d6ee3b9 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -165,9 +165,11 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), - WorkspaceRole::from($role) + WorkspaceRoleAssignment::create( + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + WorkspaceRole::from($role) + ) )); } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 5f4588cfb7f..c5710e14cfb 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -52,8 +52,7 @@ use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; -use Neos\Neos\Domain\Model\WorkspaceRoleSubject; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; @@ -227,16 +226,18 @@ public function createAction( $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, - WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($currentUser->getId()->value), - WorkspaceRole::MANAGER, + WorkspaceRoleAssignment::createForUser( + $currentUser->getId(), + WorkspaceRole::MANAGER, + ) ); $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, - WorkspaceRoleSubjectType::GROUP, - WorkspaceRoleSubject::fromString('Neos.Neos:AbstractEditor'), - WorkspaceRole::COLLABORATOR, + WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:AbstractEditor', + WorkspaceRole::COLLABORATOR, + ) ); $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); $this->redirect('index'); From eb2dfab574e86ed8199e51112551abfde9ac1fbd Mon Sep 17 00:00:00 2001 From: Jenkins Date: Wed, 9 Oct 2024 14:32:26 +0000 Subject: [PATCH 37/39] TASK: Update references [skip ci] --- .../References/CommandReference.rst | 221 ++++++++++++++++-- .../References/ViewHelpers/Neos.rst | 21 -- 2 files changed, 206 insertions(+), 36 deletions(-) diff --git a/Neos.Neos/Documentation/References/CommandReference.rst b/Neos.Neos/Documentation/References/CommandReference.rst index 3038ff239f3..435c45b0e1f 100644 --- a/Neos.Neos/Documentation/References/CommandReference.rst +++ b/Neos.Neos/Documentation/References/CommandReference.rst @@ -2739,20 +2739,66 @@ Options -.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:create`: +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:assignrole`: -``neos.neos:workspace:create`` -****************************** +``neos.neos:workspace:assignrole`` +********************************** + +**Assign a workspace role to the given user/user group** + +Without explicit workspace roles, only administrators can change the corresponding workspace. +With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: +- collaborator: Can read from and write to the workspace +- manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) + +Examples: + +To grant editors read and write access to a (shared) workspace: *./flow workspace:assignrole some-workspace "Neos.Neos:AbstractEditor" collaborator* + +To grant a specific user read, write and manage access to a workspace: *./flow workspace:assignrole some-workspace admin manager --type user* + +{@see WorkspaceRole} + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace, for example "some-workspace +``--subject`` + The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user +``--role`` + Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + + + +Options +^^^^^^^ + +``--content-repository`` + Identifier of the content repository. (Default: 'default') +``--type`` + Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:createpersonal`: + +``neos.neos:workspace:createpersonal`` +************************************** + +**Create a new personal workspace for the specified user** -**Create a new workspace** -This command creates a new workspace. Arguments ^^^^^^^^^ ``--workspace`` Name of the workspace, for example "christmas-campaign +``--owner`` + The username (aka account identifier) of a User to own the workspace @@ -2765,8 +2811,6 @@ Options Human friendly title of the workspace, for example "Christmas Campaign ``--description`` A description explaining the purpose of the new workspace -``--owner`` - The identifier of a User to own the workspace ``--content-repository`` Identifier of the content repository. (Default: 'default') @@ -2779,9 +2823,9 @@ Options ``neos.neos:workspace:createroot`` ********************************** -**Create a new root workspace for a content repository.** - +**Create a new root workspace for a content repository** +NOTE: By default, only administrators can access workspaces without role assignments. Use *workspace:assignrole* to add workspace permissions Arguments ^^^^^^^^^ @@ -2796,6 +2840,43 @@ Options ``--content-repository`` Identifier of the content repository. (Default: 'default') +``--title`` + Optional title of the workspace +``--description`` + Optional description of the workspace + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:createshared`: + +``neos.neos:workspace:createshared`` +************************************ + +**Create a new shared workspace** + +NOTE: By default, only administrators can access workspaces without role assignments. Use *workspace:assignrole* to add workspace permissions + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace, for example "christmas-campaign + + + +Options +^^^^^^^ + +``--base-workspace`` + Name of the base workspace. If none is specified, "live" is assumed. +``--title`` + Human friendly title of the workspace, for example "Christmas Campaign +``--description`` + A description explaining the purpose of the new workspace +``--content-repository`` + Identifier of the content repository. (Default: 'default') @@ -2829,12 +2910,6 @@ Options -Related commands -^^^^^^^^^^^^^^^^ - -``neos.neos:workspace:discard`` - Discard changes in workspace - .. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:discard`: @@ -2964,6 +3039,122 @@ Options +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:setdescription`: + +``neos.neos:workspace:setdescription`` +************************************** + +**Set/change the description of a workspace** + + + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace, for example "some-workspace +``--new-description`` + Human friendly description of the workspace + + + +Options +^^^^^^^ + +``--content-repository`` + Identifier of the content repository. (Default: 'default') + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:settitle`: + +``neos.neos:workspace:settitle`` +******************************** + +**Set/change the title of a workspace** + + + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace, for example "some-workspace +``--new-title`` + Human friendly title of the workspace, for example "Some workspace + + + +Options +^^^^^^^ + +``--content-repository`` + Identifier of the content repository. (Default: 'default') + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:show`: + +``neos.neos:workspace:show`` +**************************** + +**Display details for the specified workspace** + + + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace to show + + + +Options +^^^^^^^ + +``--content-repository`` + The name of the content repository. (Default: 'default') + + + + + +.. _`Neos Command Reference: NEOS.NEOS neos.neos:workspace:unassignrole`: + +``neos.neos:workspace:unassignrole`` +************************************ + +**Unassign a workspace role from the given user/user group** + + + +Arguments +^^^^^^^^^ + +``--workspace`` + Name of the workspace, for example "some-workspace +``--subject`` + The user/group that should be unassigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user + + + +Options +^^^^^^^ + +``--content-repository`` + Identifier of the content repository. (Default: 'default') +``--type`` + Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + + + + + .. _`Neos Command Reference: NEOS.SITEKICKSTARTER`: Package *NEOS.SITEKICKSTARTER* diff --git a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst index 54c7abc420b..ae36d6b5999 100644 --- a/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst +++ b/Neos.Neos/Documentation/References/ViewHelpers/Neos.rst @@ -26,27 +26,6 @@ Arguments -.. _`Neos ViewHelper Reference: neos:backend.changeStats`: - -neos:backend.changeStats ------------------------- - -Displays a text-based "bar graph" giving an indication of the amount and type of -changes done to something. Created for use in workspace management. - -:Implementation: Neos\\Neos\\ViewHelpers\\Backend\\ChangeStatsViewHelper - - - - -Arguments -********* - -* ``changeCounts`` (array): Expected keys: new, changed, removed - - - - .. _`Neos ViewHelper Reference: neos:backend.colorOfString`: neos:backend.colorOfString From 3a251f4812a1ad36cd784b81ca44ce1539880f27 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:32:40 +0200 Subject: [PATCH 38/39] TASK: Remove obsolete import --- Neos.Media.Browser/Classes/Controller/UsageController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 2e85ce29db6..1cf5cda4df5 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -14,7 +14,6 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; From 5d31a157f8a9264dd20a959816eaee307e64492c Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Wed, 9 Oct 2024 16:40:25 +0000 Subject: [PATCH 39/39] TASK: Translated using Weblate (Spanish) Currently translated at 100.0% (67 of 67 strings) Translation: Neos/Neos.Workspace.Ui - Main - 9.0 Translate-URL: https://hosted.weblate.org/projects/neos/neosworkspaceui-main-90/es/ --- Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf index 3872e1a1b00..49c41f42dd1 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf @@ -273,6 +273,10 @@ Do you really want to discard the selected changes in the "{0}" workspace? ¿Realmente desea descartar los cambios seleccionados en el espacio de trabajo "{0}"? + + The workspace "{0}" has been created. + Se ha creado el espacio de trabajo "{0}". +