diff --git a/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/ApiOrInternalAnnotationRule.php b/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/ApiOrInternalAnnotationRule.php index 1f76b0e16bd..3821a0f9f2e 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/ApiOrInternalAnnotationRule.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/ApiOrInternalAnnotationRule.php @@ -54,7 +54,7 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message( 'Class needs @api or @internal annotation.' - )->build(), + )->identifier('neos.cr.internal')->build(), ]; } return []; diff --git a/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/InternalMethodsNotAllowedOutsideContentRepositoryRule.php b/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/InternalMethodsNotAllowedOutsideContentRepositoryRule.php index 5dae4e82dfc..7334cfa78db 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/InternalMethodsNotAllowedOutsideContentRepositoryRule.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/PhpstanRules/InternalMethodsNotAllowedOutsideContentRepositoryRule.php @@ -74,7 +74,7 @@ public function processNode(Node $node, Scope $scope): array $targetClassName, $node->name->toString() ) - )->build(), + )->identifier('neos.cr.internal')->build(), ]; } } diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php index e603c12fd04..658c509834b 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/Bootstrap/CrImportExportTrait.php @@ -16,15 +16,17 @@ use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use League\Flysystem\FileAttributes; use League\Flysystem\Filesystem; use League\Flysystem\InMemory\InMemoryFilesystemAdapter; use Neos\ContentRepository\Core\EventStore\InitiatingEventMetadata; -use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; -use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepository\Export\Asset\ValueObject\SerializedImageVariant; use Neos\ContentRepository\Export\Event\ValueObject\ExportedEvents; -use Neos\ContentRepository\Export\ProcessorResult; +use Neos\ContentRepository\Export\Factory\EventExportProcessorFactory; +use Neos\ContentRepository\Export\Factory\EventStoreImportProcessorFactory; +use Neos\ContentRepository\Export\ProcessingContext; +use Neos\ContentRepository\Export\ProcessorInterface; use Neos\ContentRepository\Export\Processors\EventExportProcessor; use Neos\ContentRepository\Export\Processors\EventStoreImportProcessor; use Neos\ContentRepository\Export\Severity; @@ -40,7 +42,7 @@ trait CrImportExportTrait private Filesystem $crImportExportTrait_filesystem; - private ?ProcessorResult $crImportExportTrait_lastMigrationResult = null; + private \Throwable|null $crImportExportTrait_lastMigrationException = null; /** @var array */ private array $crImportExportTrait_loggedErrors = []; @@ -48,96 +50,71 @@ trait CrImportExportTrait /** @var array */ private array $crImportExportTrait_loggedWarnings = []; - public function setupCrImportExportTrait() + private function setupCrImportExportTrait(): void { $this->crImportExportTrait_filesystem = new Filesystem(new InMemoryFilesystemAdapter()); } /** - * @When /^the events are exported$/ + * @AfterScenario */ - public function theEventsAreExportedIExpectTheFollowingJsonl() + public function failIfLastMigrationHasErrors(): void { - $eventExporter = $this->getContentRepositoryService( - new class ($this->crImportExportTrait_filesystem) implements ContentRepositoryServiceFactoryInterface { - public function __construct(private readonly Filesystem $filesystem) - { - } - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventExportProcessor { - return new EventExportProcessor( - $this->filesystem, - $serviceFactoryDependencies->contentRepository->findWorkspaceByName(WorkspaceName::forLive()), - $serviceFactoryDependencies->eventStore - ); - } - } - ); - assert($eventExporter instanceof EventExportProcessor); + if ($this->crImportExportTrait_lastMigrationException !== null) { + throw new \RuntimeException(sprintf('The last migration run led to an exception: %s', $this->crImportExportTrait_lastMigrationException->getMessage())); + } + if ($this->crImportExportTrait_loggedErrors !== []) { + throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->crImportExportTrait_loggedErrors), count($this->crImportExportTrait_loggedErrors) === 1 ? '' : 's')); + } + } - $eventExporter->onMessage(function (Severity $severity, string $message) { + private function runCrImportExportProcessors(ProcessorInterface ...$processors): void + { + $processingContext = new ProcessingContext($this->crImportExportTrait_filesystem, function (Severity $severity, string $message) { if ($severity === Severity::ERROR) { $this->crImportExportTrait_loggedErrors[] = $message; } elseif ($severity === Severity::WARNING) { $this->crImportExportTrait_loggedWarnings[] = $message; } }); - $this->crImportExportTrait_lastMigrationResult = $eventExporter->run(); + foreach ($processors as $processor) { + assert($processor instanceof ProcessorInterface); + try { + $processor->run($processingContext); + } catch (\Throwable $e) { + $this->crImportExportTrait_lastMigrationException = $e; + break; + } + } } /** - * @When /^I import the events\.jsonl(?: into "([^"]*)")?$/ + * @When /^the events are exported$/ */ - public function iImportTheFollowingJson(?string $contentStreamId = null) - { - $eventImporter = $this->getContentRepositoryService( - new class ($this->crImportExportTrait_filesystem, $contentStreamId ? ContentStreamId::fromString($contentStreamId) : null) implements ContentRepositoryServiceFactoryInterface { - public function __construct( - private readonly Filesystem $filesystem, - private readonly ?ContentStreamId $contentStreamId - ) { - } - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventStoreImportProcessor { - return new EventStoreImportProcessor( - false, - $this->filesystem, - $serviceFactoryDependencies->eventStore, - $serviceFactoryDependencies->eventNormalizer, - $this->contentStreamId - ); - } - } - ); - assert($eventImporter instanceof EventStoreImportProcessor); - - $eventImporter->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->crImportExportTrait_loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->crImportExportTrait_loggedWarnings[] = $message; - } - }); - $this->crImportExportTrait_lastMigrationResult = $eventImporter->run(); + public function theEventsAreExported(): void + { + $eventExporter = $this->getContentRepositoryService(new EventExportProcessorFactory($this->currentContentRepository->findWorkspaceByName(WorkspaceName::forLive())->currentContentStreamId)); + assert($eventExporter instanceof EventExportProcessor); + $this->runCrImportExportProcessors($eventExporter); } /** - * @Given /^using the following events\.jsonl:$/ + * @When /^I import the events\.jsonl(?: into workspace "([^"]*)")?$/ */ - public function usingTheFollowingEventsJsonl(PyStringNode $string) + public function iImportTheEventsJsonl(?string $workspace = null): void { - $this->crImportExportTrait_filesystem->write('events.jsonl', $string->getRaw()); + $workspaceName = $workspace !== null ? WorkspaceName::fromString($workspace) : $this->currentWorkspaceName; + $eventImporter = $this->getContentRepositoryService(new EventStoreImportProcessorFactory($workspaceName, true)); + assert($eventImporter instanceof EventStoreImportProcessor); + $this->runCrImportExportProcessors($eventImporter); } /** - * @AfterScenario + * @Given /^using the following events\.jsonl:$/ */ - public function failIfLastMigrationHasErrors(): void + public function usingTheFollowingEventsJsonl(PyStringNode $string): void { - if ($this->crImportExportTrait_lastMigrationResult !== null && $this->crImportExportTrait_lastMigrationResult->severity === Severity::ERROR) { - throw new \RuntimeException(sprintf('The last migration run led to an error: %s', $this->crImportExportTrait_lastMigrationResult->message)); - } - if ($this->crImportExportTrait_loggedErrors !== []) { - throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->crImportExportTrait_loggedErrors), count($this->crImportExportTrait_loggedErrors) === 1 ? '' : 's')); - } + $this->crImportExportTrait_filesystem->write('events.jsonl', $string->getRaw()); } /** @@ -167,6 +144,66 @@ public function iExpectTheFollowingJsonL(PyStringNode $string): void Assert::assertSame($string->getRaw(), ExportedEvents::fromIterable($eventsWithoutRandomIds)->toJsonl()); } + /** + * @Then I expect the following events to be exported + */ + public function iExpectTheFollowingEventsToBeExported(TableNode $table): void + { + + if (!$this->crImportExportTrait_filesystem->has('events.jsonl')) { + Assert::fail('No events were exported'); + } + $eventsJson = $this->crImportExportTrait_filesystem->read('events.jsonl'); + $exportedEvents = iterator_to_array(ExportedEvents::fromJsonl($eventsJson)); + + $expectedEvents = $table->getHash(); + foreach ($exportedEvents as $exportedEvent) { + $expectedEventRow = array_shift($expectedEvents); + if ($expectedEventRow === null) { + Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); + } + if (!empty($expectedEventRow['Type'])) { + Assert::assertSame($expectedEventRow['Type'], $exportedEvent->type, 'Event: ' . $exportedEvent->toJson()); + } + try { + $expectedEventPayload = json_decode($expectedEventRow['Payload'], true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to decode expected JSON: %s', $expectedEventRow['Payload']), 1655811083); + } + $actualEventPayload = $exportedEvent->payload; + foreach (array_keys($actualEventPayload) as $key) { + if (!array_key_exists($key, $expectedEventPayload)) { + unset($actualEventPayload[$key]); + } + } + Assert::assertEquals($expectedEventPayload, $actualEventPayload, 'Actual event: ' . $exportedEvent->toJson()); + } + Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); + } + + /** + * @Then I expect the following sites to be exported + */ + public function iExpectTheFollowingSitesToBeExported(TableNode $table): void + { + if (!$this->crImportExportTrait_filesystem->has('sites.json')) { + Assert::fail('No events were exported'); + } + $actualSitesJson = $this->crImportExportTrait_filesystem->read('sites.json'); + $actualSiteRows = json_decode($actualSitesJson, true, 512, JSON_THROW_ON_ERROR); + + $expectedSites = $table->getHash(); + foreach ($expectedSites as $key => $expectedSiteData) { + $actualSiteData = $actualSiteRows[$key] ?? []; + $expectedSiteData = array_map( + fn(string $value) => json_decode($value, true, 512, JSON_THROW_ON_ERROR), + $expectedSiteData + ); + Assert::assertEquals($expectedSiteData, $actualSiteData, 'Actual site: ' . json_encode($actualSiteData, JSON_THROW_ON_ERROR)); + } + Assert::assertCount(count($table->getHash()), $actualSiteRows, 'Expected number of sites does not match actual number'); + } + /** * @Then I expect the following errors to be logged */ @@ -186,24 +223,82 @@ public function iExpectTheFollowingWarningsToBeLogged(TableNode $table): void } /** - * @Then I expect a MigrationError - * @Then I expect a MigrationError with the message + * @Then I expect a migration exception + * @Then I expect a migration exception with the message */ - public function iExpectAMigrationErrorWithTheMessage(PyStringNode $expectedMessage = null): void + public function iExpectAMigrationExceptionWithTheMessage(PyStringNode $expectedMessage = null): void { - Assert::assertNotNull($this->crImportExportTrait_lastMigrationResult, 'Expected the previous migration to contain errors, but no migration has been executed'); - Assert::assertSame(Severity::ERROR, $this->crImportExportTrait_lastMigrationResult->severity, sprintf('Expected the previous migration to contain errors, but it ended with severity "%s"', $this->crImportExportTrait_lastMigrationResult->severity->name)); + Assert::assertNotNull($this->crImportExportTrait_lastMigrationException, 'Expected the previous migration to lead to an exception, but no exception was thrown'); if ($expectedMessage !== null) { - Assert::assertSame($expectedMessage->getRaw(), $this->crImportExportTrait_lastMigrationResult->message); + Assert::assertSame($expectedMessage->getRaw(), $this->crImportExportTrait_lastMigrationException->getMessage()); } - $this->crImportExportTrait_lastMigrationResult = null; + $this->crImportExportTrait_lastMigrationException = null; } /** - * @template T of object - * @param class-string $className - * - * @return T + * @Given the following ImageVariants exist */ - abstract private function getObject(string $className): object; + public function theFollowingImageVariantsExist(TableNode $imageVariants): void + { + foreach ($imageVariants->getHash() as $variantData) { + try { + $variantData['imageAdjustments'] = json_decode($variantData['imageAdjustments'], true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf('Failed to JSON decode imageAdjustments for variant "%s"', $variantData['identifier']), 1659530081, $e); + } + $variantData['width'] = (int)$variantData['width']; + $variantData['height'] = (int)$variantData['height']; + $mockImageVariant = SerializedImageVariant::fromArray($variantData); + $this->mockAssets[$mockImageVariant->identifier] = $mockImageVariant; + } + } + + /** + * @Then /^I expect the following (Assets|ImageVariants) to be exported:$/ + */ + public function iExpectTheFollowingAssetsOrImageVariantsToBeExported(string $type, PyStringNode $expectedAssets): void + { + $actualAssets = []; + if (!$this->crImportExportTrait_filesystem->directoryExists($type)) { + Assert::fail(sprintf('No %1$s have been exported (Directory "/%1$s" does not exist)', $type)); + } + /** @var FileAttributes $file */ + foreach ($this->crImportExportTrait_filesystem->listContents($type) as $file) { + $actualAssets[] = json_decode($this->crImportExportTrait_filesystem->read($file->path()), true, 512, JSON_THROW_ON_ERROR); + } + Assert::assertJsonStringEqualsJsonString($expectedAssets->getRaw(), json_encode($actualAssets, JSON_THROW_ON_ERROR)); + } + + + /** + * @Then /^I expect no (Assets|ImageVariants) to be exported$/ + */ + public function iExpectNoAssetsToBeExported(string $type): void + { + Assert::assertFalse($this->crImportExportTrait_filesystem->directoryExists($type)); + } + + /** + * @Then I expect the following PersistentResources to be exported: + */ + public function iExpectTheFollowingPersistentResourcesToBeExported(TableNode $expectedResources): void + { + $actualResources = []; + if (!$this->crImportExportTrait_filesystem->directoryExists('Resources')) { + Assert::fail('No PersistentResources have been exported (Directory "/Resources" does not exist)'); + } + /** @var FileAttributes $file */ + foreach ($this->crImportExportTrait_filesystem->listContents('Resources') as $file) { + $actualResources[] = ['Filename' => basename($file->path()), 'Contents' => $this->crImportExportTrait_filesystem->read($file->path())]; + } + Assert::assertSame($expectedResources->getHash(), $actualResources); + } + + /** + * @Then /^I expect no PersistentResources to be exported$/ + */ + public function iExpectNoPersistentResourcesToBeExported(): void + { + Assert::assertFalse($this->crImportExportTrait_filesystem->directoryExists('Resources')); + } } diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature similarity index 58% rename from Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature rename to Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature index 06c9273ad54..7b139c617a0 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Export/Export.feature +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventExportProcessor.feature @@ -1,5 +1,5 @@ @contentrepository -Feature: As a user of the CR I want to export the event stream +Feature: As a user of the CR I want to export the event stream using the EventExportProcessor Background: Given using the following content dimensions: @@ -12,9 +12,9 @@ Feature: As a user of the CR I want to export the event stream And using identifier "default", I define a content repository And I am in content repository "default" And the command CreateRootWorkspace is executed with payload: - | Key | Value | - | workspaceName | "live" | - | newContentStreamId | "cs-identifier" | + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | And I am in workspace "live" And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | @@ -37,7 +37,7 @@ Feature: As a user of the CR I want to export the event stream When the events are exported Then I expect the following jsonl: """ - {"identifier":"random-event-uuid","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","coveredDimensionSpacePoints":[{"language":"de"},{"language":"gsw"},{"language":"fr"}],"nodeAggregateClassification":"root"},"metadata":{"commandClass":"Neos\\ContentRepository\\Core\\Feature\\RootNodeCreation\\Command\\CreateRootNodeAggregateWithNode","commandPayload":{"workspaceName":"live","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","tetheredDescendantNodeAggregateIds":[]},"initiatingUserId":"system","initiatingTimestamp":"random-time"}} - {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"live","contentStreamId":"cs-identifier","nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular","nodeReferences":[]},"metadata":{"initiatingTimestamp":"random-time"}} + {"identifier":"random-event-uuid","type":"RootNodeAggregateWithNodeWasCreated","payload":{"nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","coveredDimensionSpacePoints":[{"language":"de"},{"language":"gsw"},{"language":"fr"}],"nodeAggregateClassification":"root"},"metadata":{"commandClass":"Neos\\ContentRepository\\Core\\Feature\\RootNodeCreation\\Command\\CreateRootNodeAggregateWithNode","commandPayload":{"workspaceName":"live","nodeAggregateId":"lady-eleonode-rootford","nodeTypeName":"Neos.ContentRepository:Root","tetheredDescendantNodeAggregateIds":[]},"initiatingUserId":"system","initiatingTimestamp":"random-time"}} + {"identifier":"random-event-uuid","type":"NodeAggregateWithNodeWasCreated","payload":{"nodeAggregateId":"nody-mc-nodeface","nodeTypeName":"Neos.ContentRepository.Testing:Document","originDimensionSpacePoint":{"language":"de"},"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":{"language":"de"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"gsw"},"nodeAggregateId":null},{"dimensionSpacePoint":{"language":"fr"},"nodeAggregateId":null}],"parentNodeAggregateId":"lady-eleonode-rootford","nodeName":"child-document","initialPropertyValues":[],"nodeAggregateClassification":"regular","nodeReferences":[]},"metadata":{"initiatingTimestamp":"random-time"}} """ diff --git a/Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature similarity index 82% rename from Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature rename to Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature index 6c61f644b57..443ae5ff474 100644 --- a/Neos.ContentRepository.Export/Tests/Behavior/Features/Import/Import.feature +++ b/Neos.ContentRepository.Export/Tests/Behavior/Features/EventStoreImportProcessor.feature @@ -1,5 +1,5 @@ @contentrepository -Feature: As a user of the CR I want to export the event stream +Feature: As a user of the CR I want to import events using the EventStoreImportProcessor Background: Given using no content dimensions @@ -11,56 +11,59 @@ Feature: As a user of the CR I want to export the event stream """ And using identifier "default", I define a content repository And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" Scenario: Import the event stream into a specific content stream - Then I expect exactly 0 events to be published on stream with prefix "ContentStream:cs-identifier" Given using the following events.jsonl: """ {"identifier":"9f64c281-e5b0-48d9-900b-288a8faf92a9","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site-sites","nodeTypeName":"Neos.Neos:Sites","coveredDimensionSpacePoints":[[]],"nodeAggregateClassification":"root"},"metadata":[]} {"identifier":"1640ebbf-7ffe-4526-b0f4-7575cefabfab","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site","nodeTypeName":"Vendor.Site:HomePage","originDimensionSpacePoint":[],"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":[],"nodeAggregateId":null}],"parentNodeAggregateId":"acme-site-sites","nodeName":"acme-site","initialPropertyValues":{"title":{"value":"My Site","type":"string"},"uriPathSegment":{"value":"my-site","type":"string"}},"nodeAggregateClassification":"regular"},"metadata":[]} """ - And I import the events.jsonl into "cs-identifier" + And I import the events.jsonl into workspace "live" Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-identifier" And event at index 0 is of type "ContentStreamWasCreated" with payload: | Key | Expected | | contentStreamId | "cs-identifier" | And event at index 1 is of type "RootNodeAggregateWithNodeWasCreated" with payload: | Key | Expected | - | workspaceName | "workspace-name" | + | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "acme-site-sites" | | nodeTypeName | "Neos.Neos:Sites" | And event at index 2 is of type "NodeAggregateWithNodeWasCreated" with payload: | Key | Expected | - | workspaceName | "workspace-name" | + | workspaceName | "live" | | contentStreamId | "cs-identifier" | | nodeAggregateId | "acme-site" | | nodeTypeName | "Vendor.Site:HomePage" | Scenario: Import the event stream - Then I expect exactly 0 events to be published on stream with prefix "ContentStream:cs-imported-identifier" Given using the following events.jsonl: """ {"identifier":"9f64c281-e5b0-48d9-900b-288a8faf92a9","type":"RootNodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site-sites","nodeTypeName":"Neos.Neos:Sites","coveredDimensionSpacePoints":[[]],"nodeAggregateClassification":"root"},"metadata":[]} {"identifier":"1640ebbf-7ffe-4526-b0f4-7575cefabfab","type":"NodeAggregateWithNodeWasCreated","payload":{"workspaceName":"workspace-name","contentStreamId":"cs-imported-identifier","nodeAggregateId":"acme-site","nodeTypeName":"Vendor.Site:HomePage","originDimensionSpacePoint":[],"succeedingSiblingsForCoverage":[{"dimensionSpacePoint":[],"nodeAggregateId":null}],"parentNodeAggregateId":"acme-site-sites","nodeName":"acme-site","initialPropertyValues":{"title":{"value":"My Site","type":"string"},"uriPathSegment":{"value":"my-site","type":"string"}},"nodeAggregateClassification":"regular"},"metadata":[]} """ And I import the events.jsonl - Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-imported-identifier" + Then I expect exactly 3 events to be published on stream with prefix "ContentStream:cs-identifier" And event at index 0 is of type "ContentStreamWasCreated" with payload: - | Key | Expected | - | contentStreamId | "cs-imported-identifier" | + | Key | Expected | + | contentStreamId | "cs-identifier" | And event at index 1 is of type "RootNodeAggregateWithNodeWasCreated" with payload: - | Key | Expected | - | workspaceName | "workspace-name" | - | contentStreamId | "cs-imported-identifier" | - | nodeAggregateId | "acme-site-sites" | - | nodeTypeName | "Neos.Neos:Sites" | + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "acme-site-sites" | + | nodeTypeName | "Neos.Neos:Sites" | And event at index 2 is of type "NodeAggregateWithNodeWasCreated" with payload: - | Key | Expected | - | workspaceName | "workspace-name" | - | contentStreamId | "cs-imported-identifier" | - | nodeAggregateId | "acme-site" | - | nodeTypeName | "Vendor.Site:HomePage" | + | Key | Expected | + | workspaceName | "live" | + | contentStreamId | "cs-identifier" | + | nodeAggregateId | "acme-site" | + | nodeTypeName | "Vendor.Site:HomePage" | Scenario: Import faulty event stream with explicit "ContentStreamWasCreated" does not duplicate content-stream see issue https://github.com/neos/neos-development-collection/issues/4298 @@ -73,7 +76,7 @@ Feature: As a user of the CR I want to export the event stream """ And I import the events.jsonl - And I expect a MigrationError with the message + And I expect a migration exception with the message """ Failed to read events. ContentStreamWasCreated is not expected in imported event stream. """ diff --git a/Neos.ContentRepository.Export/src/Asset/AssetExporter.php b/Neos.ContentRepository.Export/src/Asset/AssetExporter.php index 3ce343eebb2..0dc915dbf03 100644 --- a/Neos.ContentRepository.Export/src/Asset/AssetExporter.php +++ b/Neos.ContentRepository.Export/src/Asset/AssetExporter.php @@ -1,5 +1,7 @@ data->value, true, 512, JSON_THROW_ON_ERROR); + // unset content stream id as this is overwritten during import + unset($payload['contentStreamId'], $payload['workspaceName']); return new self( $event->id->value, $event->type->value, - \json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR), + $payload, $event->metadata?->value ?? [], ); } diff --git a/Neos.ContentRepository.Export/src/ExportService.php b/Neos.ContentRepository.Export/src/ExportService.php deleted file mode 100644 index 6f7f6491531..00000000000 --- a/Neos.ContentRepository.Export/src/ExportService.php +++ /dev/null @@ -1,63 +0,0 @@ - $processors */ - $processors = [ - 'Exporting events' => new EventExportProcessor( - $this->filesystem, - $this->targetWorkspace, - $this->eventStore - ), - 'Exporting assets' => new AssetExportProcessor( - $this->contentRepositoryId, - $this->filesystem, - $this->assetRepository, - $this->targetWorkspace, - $this->assetUsageService - ) - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $verbose && $processor->onMessage( - fn(Severity $severity, string $message) => $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]) - ); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . ($result->message ?? '')); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - } -} diff --git a/Neos.ContentRepository.Export/src/ExportServiceFactory.php b/Neos.ContentRepository.Export/src/ExportServiceFactory.php deleted file mode 100644 index e36d50be983..00000000000 --- a/Neos.ContentRepository.Export/src/ExportServiceFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ -class ExportServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - - public function __construct( - private readonly Filesystem $filesystem, - private readonly Workspace $targetWorkspace, - private readonly AssetRepository $assetRepository, - private readonly AssetUsageService $assetUsageService, - ) { - } - - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ExportService - { - return new ExportService( - $serviceFactoryDependencies->contentRepositoryId, - $this->filesystem, - $this->targetWorkspace, - $this->assetRepository, - $this->assetUsageService, - $serviceFactoryDependencies->eventStore, - ); - } -} diff --git a/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php b/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php new file mode 100644 index 00000000000..636a3a5a1be --- /dev/null +++ b/Neos.ContentRepository.Export/src/Factory/EventExportProcessorFactory.php @@ -0,0 +1,29 @@ + + */ +final readonly class EventExportProcessorFactory implements ContentRepositoryServiceFactoryInterface +{ + public function __construct( + private ContentStreamId $contentStreamId, + ) { + } + + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventExportProcessor + { + return new EventExportProcessor( + $this->contentStreamId, + $serviceFactoryDependencies->eventStore, + ); + } +} diff --git a/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php b/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php new file mode 100644 index 00000000000..459a746c1e6 --- /dev/null +++ b/Neos.ContentRepository.Export/src/Factory/EventStoreImportProcessorFactory.php @@ -0,0 +1,33 @@ + + */ +final readonly class EventStoreImportProcessorFactory implements ContentRepositoryServiceFactoryInterface +{ + public function __construct( + private WorkspaceName $targetWorkspaceName, + private bool $keepEventIds, + ) { + } + + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): EventStoreImportProcessor + { + return new EventStoreImportProcessor( + $this->targetWorkspaceName, + $this->keepEventIds, + $serviceFactoryDependencies->eventStore, + $serviceFactoryDependencies->eventNormalizer, + $serviceFactoryDependencies->contentRepository, + ); + } +} diff --git a/Neos.ContentRepository.Export/src/ImportService.php b/Neos.ContentRepository.Export/src/ImportService.php deleted file mode 100644 index de06ced5b3c..00000000000 --- a/Neos.ContentRepository.Export/src/ImportService.php +++ /dev/null @@ -1,86 +0,0 @@ -liveWorkspaceContentStreamExists()) { - throw new LiveWorkspaceContentStreamExistsException(); - } - - /** @var ProcessorInterface[] $processors */ - $processors = [ - 'Importing assets' => new AssetRepositoryImportProcessor( - $this->filesystem, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - ), - 'Importing events' => new EventStoreImportProcessor( - false, - $this->filesystem, - $this->eventStore, - $this->eventNormalizer, - $this->contentStreamIdentifier, - ) - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $verbose && $processor->onMessage( - fn(Severity $severity, string $message) => $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]) - ); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . ($result->message ?? '')); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - } - - private function liveWorkspaceContentStreamExists(): bool - { - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName(WorkspaceName::forLive())->getEventStreamName(); - $eventStream = $this->eventStore->load($workspaceStreamName); - foreach ($eventStream as $event) { - return true; - } - return false; - } -} diff --git a/Neos.ContentRepository.Export/src/ImportServiceFactory.php b/Neos.ContentRepository.Export/src/ImportServiceFactory.php deleted file mode 100644 index b5504818664..00000000000 --- a/Neos.ContentRepository.Export/src/ImportServiceFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class ImportServiceFactory implements ContentRepositoryServiceFactoryInterface -{ - - public function __construct( - private readonly Filesystem $filesystem, - private readonly ContentStreamId $contentStreamIdentifier, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, - private readonly PersistenceManagerInterface $persistenceManager, - ) { - } - - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ImportService - { - return new ImportService( - $this->filesystem, - $this->contentStreamIdentifier, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - $serviceFactoryDependencies->eventNormalizer, - $serviceFactoryDependencies->eventStore, - ); - } -} diff --git a/Neos.ContentRepository.Export/src/ProcessingContext.php b/Neos.ContentRepository.Export/src/ProcessingContext.php new file mode 100644 index 00000000000..cd6fd16bba0 --- /dev/null +++ b/Neos.ContentRepository.Export/src/ProcessingContext.php @@ -0,0 +1,24 @@ +onEvent)($severity, $message); + } +} diff --git a/Neos.ContentRepository.Export/src/ProcessorInterface.php b/Neos.ContentRepository.Export/src/ProcessorInterface.php index 6ee2a5246d7..e033c200256 100644 --- a/Neos.ContentRepository.Export/src/ProcessorInterface.php +++ b/Neos.ContentRepository.Export/src/ProcessorInterface.php @@ -1,14 +1,13 @@ + */ +final readonly class Processors implements \IteratorAggregate, \Countable +{ + /** + * @param array $processors + */ + private function __construct( + private array $processors + ) { + } + + /** + * @param array $processors + */ + public static function fromArray(array $processors): self + { + return new self($processors); + } + + public function getIterator(): \Traversable + { + yield from $this->processors; + } + + public function count(): int + { + return count($this->processors); + } +} diff --git a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php index 3bceb6dc77f..78deda81ee3 100644 --- a/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/AssetExportProcessor.php @@ -1,14 +1,15 @@ */ - private array $callbacks = []; - public function __construct( private readonly ContentRepositoryId $contentRepositoryId, - private readonly Filesystem $files, private readonly AssetRepository $assetRepository, private readonly Workspace $targetWorkspace, private readonly AssetUsageService $assetUsageService, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $assetFilter = AssetUsageFilter::create()->withWorkspaceName($this->targetWorkspace->workspaceName)->groupByAsset(); - $numberOfExportedAssets = 0; - $numberOfExportedImageVariants = 0; - $numberOfErrors = 0; - foreach ($this->assetUsageService->findByFilter($this->contentRepositoryId, $assetFilter) as $assetUsage) { /** @var Asset|null $asset */ $asset = $this->assetRepository->findByIdentifier($assetUsage->assetId); if ($asset === null) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Skipping asset "%s" because it does not exist in the database', $assetUsage->assetId); + $context->dispatch(Severity::ERROR, "Skipping asset \"{$assetUsage->assetId}\" because it does not exist in the database"); continue; } @@ -63,64 +50,47 @@ public function run(): ProcessorResult /** @var Asset $originalAsset */ $originalAsset = $asset->getOriginalAsset(); try { - $this->exportAsset($originalAsset); - $numberOfExportedAssets ++; + $this->exportAsset($context, $originalAsset); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to export original asset "%s" (for variant "%s"): %s', $originalAsset->getIdentifier(), $asset->getIdentifier(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to export original asset \"{$originalAsset->getIdentifier()}\" (for variant \"{$asset->getIdentifier()}\"): {$e->getMessage()}"); } } try { - $this->exportAsset($asset); - if ($asset instanceof AssetVariantInterface) { - $numberOfExportedImageVariants ++; - } else { - $numberOfExportedAssets ++; - } + $this->exportAsset($context, $asset); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to export asset "%s": %s', $asset->getIdentifier(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to export asset \"{$asset->getIdentifier()}\": {$e->getMessage()}"); } } - return ProcessorResult::success(sprintf('Exported %d Asset%s and %d Image Variant%s. Errors: %d', $numberOfExportedAssets, $numberOfExportedAssets === 1 ? '' : 's', $numberOfExportedImageVariants, $numberOfExportedImageVariants === 1 ? '' : 's', $numberOfErrors)); } /** --------------------------------------- */ - private function exportAsset(Asset $asset): void + private function exportAsset(ProcessingContext $context, Asset $asset): void { $fileLocation = $asset instanceof ImageVariant ? "ImageVariants/{$asset->getIdentifier()}.json" : "Assets/{$asset->getIdentifier()}.json"; - if ($this->files->has($fileLocation)) { + if ($context->files->has($fileLocation)) { return; } if ($asset instanceof ImageVariant) { - $this->files->write($fileLocation, SerializedImageVariant::fromImageVariant($asset)->toJson()); + $context->files->write($fileLocation, SerializedImageVariant::fromImageVariant($asset)->toJson()); return; } /** @var PersistentResource|null $resource */ $resource = $asset->getResource(); if ($resource === null) { - $this->dispatch(Severity::ERROR, 'Skipping asset "%s" because the corresponding PersistentResource does not exist in the database', $asset->getIdentifier()); + $context->dispatch(Severity::ERROR, "Skipping asset \"{$asset->getIdentifier()}\" because the corresponding PersistentResource does not exist in the database"); return; } - $this->files->write($fileLocation, SerializedAsset::fromAsset($asset)->toJson()); - $this->exportResource($resource); + $context->files->write($fileLocation, SerializedAsset::fromAsset($asset)->toJson()); + $this->exportResource($context, $resource); } - private function exportResource(PersistentResource $resource): void + private function exportResource(ProcessingContext $context, PersistentResource $resource): void { $fileLocation = "Resources/{$resource->getSha1()}"; - if ($this->files->has($fileLocation)) { + if ($context->files->has($fileLocation)) { return; } - $this->files->writeStream($fileLocation, $resource->getStream()); - } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } + $context->files->writeStream($fileLocation, $resource->getStream()); } } diff --git a/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php b/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php index b389adababa..c7f7535069f 100644 --- a/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/AssetRepositoryImportProcessor.php @@ -1,15 +1,16 @@ */ - private array $callbacks = []; - public function __construct( - private readonly Filesystem $files, private readonly AssetRepository $assetRepository, private readonly ResourceRepository $resourceRepository, private readonly ResourceManager $resourceManager, private readonly PersistenceManagerInterface $persistenceManager, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $this->persistenceManager->clearState(); - $numberOfErrors = 0; - $numberOfImportedAssets = 0; - foreach ($this->files->listContents('/Assets') as $file) { + foreach ($context->files->listContents('/Assets') as $file) { if (!$file->isFile()) { continue; } try { - $this->importAsset($file); - $numberOfImportedAssets ++; + $this->importAsset($context, $file); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to import asset from file "%s": %s', $file->path(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to import asset from file \"{$file->path()}\": {$e->getMessage()}"); } } - $numberOfImportedImageVariants = 0; - foreach ($this->files->listContents('/ImageVariants') as $file) { + foreach ($context->files->listContents('/ImageVariants') as $file) { if (!$file->isFile()) { continue; } try { - $this->importImageVariant($file); - $numberOfImportedImageVariants ++; + $this->importImageVariant($context, $file); } catch (\Throwable $e) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to import image variant from file "%s": %s', $file->path(), $e->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to import image variant from file \"{$file->path()}\": {$e->getMessage()}"); } } - return ProcessorResult::success(sprintf('Imported %d Asset%s and %d Image Variant%s. Errors: %d', $numberOfImportedAssets, $numberOfImportedAssets === 1 ? '' : 's', $numberOfImportedImageVariants, $numberOfImportedImageVariants === 1 ? '' : 's', $numberOfErrors)); } /** --------------------------------------- */ - private function importAsset(StorageAttributes $file): void + private function importAsset(ProcessingContext $context, StorageAttributes $file): void { - $fileContents = $this->files->read($file->path()); + $fileContents = $context->files->read($file->path()); $serializedAsset = SerializedAsset::fromJson($fileContents); /** @var Asset|null $existingAsset */ $existingAsset = $this->assetRepository->findByIdentifier($serializedAsset->identifier); if ($existingAsset !== null) { if ($serializedAsset->matches($existingAsset)) { - $this->dispatch(Severity::NOTICE, 'Asset "%s" was skipped because it already exists!', $serializedAsset->identifier); + $context->dispatch(Severity::NOTICE, "Asset \"{$serializedAsset->identifier}\" was skipped because it already exists!"); } else { - $this->dispatch(Severity::ERROR, 'Asset "%s" has been changed in the meantime, it was NOT updated!', $serializedAsset->identifier); + $context->dispatch(Severity::ERROR, "Asset \"{$serializedAsset->identifier}\" has been changed in the meantime, it was NOT updated!"); } return; } /** @var PersistentResource|null $resource */ $resource = $this->resourceRepository->findBySha1AndCollectionName($serializedAsset->resource->sha1, $serializedAsset->resource->collectionName)[0] ?? null; if ($resource === null) { - $content = $this->files->read('/Resources/' . $serializedAsset->resource->sha1); + $content = $context->files->read('/Resources/' . $serializedAsset->resource->sha1); $resource = $this->resourceManager->importResourceFromContent($content, $serializedAsset->resource->filename, $serializedAsset->resource->collectionName); $resource->setMediaType($serializedAsset->resource->mediaType); } @@ -116,27 +101,28 @@ private function importAsset(StorageAttributes $file): void ObjectAccess::setProperty($asset, 'Persistence_Object_Identifier', $serializedAsset->identifier, true); $asset->setTitle($serializedAsset->title); $asset->setCaption($serializedAsset->caption); + $asset->setCopyrightNotice($serializedAsset->copyrightNotice); $this->assetRepository->add($asset); $this->persistenceManager->persistAll(); } - private function importImageVariant(StorageAttributes $file): void + private function importImageVariant(ProcessingContext $context, StorageAttributes $file): void { - $fileContents = $this->files->read($file->path()); + $fileContents = $context->files->read($file->path()); $serializedImageVariant = SerializedImageVariant::fromJson($fileContents); $existingImageVariant = $this->assetRepository->findByIdentifier($serializedImageVariant->identifier); assert($existingImageVariant === null || $existingImageVariant instanceof ImageVariant); if ($existingImageVariant !== null) { if ($serializedImageVariant->matches($existingImageVariant)) { - $this->dispatch(Severity::NOTICE, 'Image Variant "%s" was skipped because it already exists!', $serializedImageVariant->identifier); + $context->dispatch(Severity::NOTICE, "Image Variant \"{$serializedImageVariant->identifier}\" was skipped because it already exists!"); } else { - $this->dispatch(Severity::ERROR, 'Image Variant "%s" has been changed in the meantime, it was NOT updated!', $serializedImageVariant->identifier); + $context->dispatch(Severity::ERROR, "Image Variant \"{$serializedImageVariant->identifier}\" has been changed in the meantime, it was NOT updated!"); } return; } $originalImage = $this->assetRepository->findByIdentifier($serializedImageVariant->originalAssetIdentifier); if ($originalImage === null) { - $this->dispatch(Severity::ERROR, 'Failed to find original asset "%s", skipping image variant "%s"', $serializedImageVariant->originalAssetIdentifier, $serializedImageVariant->identifier); + $context->dispatch(Severity::ERROR, "Failed to find original asset \"{$serializedImageVariant->originalAssetIdentifier}\", skipping image variant \"{$serializedImageVariant->identifier}\""); return; } assert($originalImage instanceof Image); @@ -154,12 +140,4 @@ private function importImageVariant(StorageAttributes $file): void $this->assetRepository->add($imageVariant); $this->persistenceManager->persistAll(); } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } } diff --git a/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php b/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php index b7d0b486188..f32019a6e65 100644 --- a/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/EventExportProcessor.php @@ -1,49 +1,43 @@ */ - private array $callbacks = []; - + /** + * @param ContentStreamId $contentStreamId Identifier of the content stream to export + */ public function __construct( - private readonly Filesystem $files, - private readonly Workspace $targetWorkspace, - private readonly EventStoreInterface $eventStore, + private ContentStreamId $contentStreamId, + private EventStoreInterface $eventStore, ) { } - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { - $streamName = ContentStreamEventStreamName::fromContentStreamId($this->targetWorkspace->currentContentStreamId)->getEventStreamName(); + $streamName = ContentStreamEventStreamName::fromContentStreamId($this->contentStreamId)->getEventStreamName(); $eventStream = $this->eventStore->load($streamName); $eventFileResource = fopen('php://temp/maxmemory:5242880', 'rb+'); if ($eventFileResource === false) { - return ProcessorResult::error('Failed to create temporary event file resource'); + throw new \RuntimeException('Failed to create temporary event file resource', 1729506599); } - $numberOfExportedEvents = 0; foreach ($eventStream as $eventEnvelope) { if ($eventEnvelope->event->type->value === 'ContentStreamWasCreated') { // the content stream will be created in the import dynamically, so we prevent duplication here @@ -51,28 +45,12 @@ public function run(): ProcessorResult } $event = ExportedEvent::fromRawEvent($eventEnvelope->event); fwrite($eventFileResource, $event->toJson() . chr(10)); - $numberOfExportedEvents ++; } try { - $this->files->writeStream('events.jsonl', $eventFileResource); + $context->files->writeStream('events.jsonl', $eventFileResource); } catch (FilesystemException $e) { - return ProcessorResult::error(sprintf('Failed to write events.jsonl: %s', $e->getMessage())); + throw new \RuntimeException(sprintf('Failed to write events.jsonl: %s', $e->getMessage()), 1729506623, $e); } fclose($eventFileResource); - return ProcessorResult::success(sprintf('Exported %d event%s', $numberOfExportedEvents, $numberOfExportedEvents === 1 ? '' : 's')); - } - - /** --------------------------------------- */ - - - /** - * @phpstan-ignore-next-line currently this private method is unused ... but it does no harm keeping it - */ - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } } } diff --git a/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php b/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php index 50f4474f485..15e9c718427 100644 --- a/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php +++ b/Neos.ContentRepository.Export/src/Processors/EventStoreImportProcessor.php @@ -1,9 +1,10 @@ */ - private array $callbacks = []; - - private ?ContentStreamId $contentStreamId = null; - public function __construct( - private readonly bool $keepEventIds, - private readonly Filesystem $files, - private readonly EventStoreInterface $eventStore, - private readonly EventNormalizer $eventNormalizer, - ?ContentStreamId $overrideContentStreamId + private WorkspaceName $targetWorkspaceName, + private bool $keepEventIds, + private EventStoreInterface $eventStore, + private EventNormalizer $eventNormalizer, + private ContentRepository $contentRepository, ) { - if ($overrideContentStreamId) { - $this->contentStreamId = $overrideContentStreamId; - } } - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { /** @var array $domainEvents */ $domainEvents = []; - $eventFileResource = $this->files->readStream('events.jsonl'); + $eventFileResource = $context->files->readStream('events.jsonl'); /** @var array $eventIdMap */ $eventIdMap = []; - $keepStreamName = false; + $workspace = $this->contentRepository->findWorkspaceByName($this->targetWorkspaceName); + if ($workspace === null) { + throw new \InvalidArgumentException("Workspace {$this->targetWorkspaceName} does not exist", 1729530978); + } + while (($line = fgets($eventFileResource)) !== false) { - $event = ExportedEvent::fromJson(trim($line)); - if ($this->contentStreamId === null) { - $this->contentStreamId = self::extractContentStreamId($event->payload); - $keepStreamName = true; - } - if (!$keepStreamName) { - $event = $event->processPayload(fn(array $payload) => isset($payload['contentStreamId']) ? [...$payload, 'contentStreamId' => $this->contentStreamId->value] : $payload); - } + $event = + ExportedEvent::fromJson(trim($line)) + ->processPayload(fn (array $payload) => [...$payload, 'contentStreamId' => $workspace->currentContentStreamId->value, 'workspaceName' => $this->targetWorkspaceName->value]); if (!$this->keepEventIds) { try { $newEventId = Algorithms::generateUUID(); @@ -106,74 +90,17 @@ public function run(): ProcessorResult ) ); if (in_array($domainEvent::class, [ContentStreamWasCreated::class, ContentStreamWasForked::class, ContentStreamWasRemoved::class], true)) { - return ProcessorResult::error(sprintf('Failed to read events. %s is not expected in imported event stream.', $event->type)); + throw new \RuntimeException(sprintf('Failed to read events. %s is not expected in imported event stream.', $event->type), 1729506757); } $domainEvent = DecoratedEvent::create($domainEvent, eventId: EventId::fromString($event->identifier), metadata: $event->metadata); $domainEvents[] = $this->eventNormalizer->normalize($domainEvent); } - assert($this->contentStreamId !== null); - - $contentStreamStreamName = ContentStreamEventStreamName::fromContentStreamId($this->contentStreamId)->getEventStreamName(); - $events = Events::with( - $this->eventNormalizer->normalize( - new ContentStreamWasCreated( - $this->contentStreamId, - ) - ) - ); + $contentStreamStreamName = ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(); try { - $contentStreamCreationCommitResult = $this->eventStore->commit($contentStreamStreamName, $events, ExpectedVersion::NO_STREAM()); + $this->eventStore->commit($contentStreamStreamName, Events::fromArray($domainEvents), ExpectedVersion::fromVersion(Version::first())); } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish workspace events because the event stream "%s" already exists (1)', $this->contentStreamId->value)); - } - - $workspaceName = WorkspaceName::forLive(); - $workspaceStreamName = WorkspaceEventStreamName::fromWorkspaceName($workspaceName)->getEventStreamName(); - $events = Events::with( - $this->eventNormalizer->normalize( - new RootWorkspaceWasCreated( - $workspaceName, - $this->contentStreamId - ) - ) - ); - try { - $this->eventStore->commit($workspaceStreamName, $events, ExpectedVersion::NO_STREAM()); - } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish workspace events because the event stream "%s" already exists (2)', $workspaceStreamName->value)); - } - - try { - $this->eventStore->commit($contentStreamStreamName, Events::fromArray($domainEvents), ExpectedVersion::fromVersion($contentStreamCreationCommitResult->highestCommittedVersion)); - } catch (ConcurrencyException $e) { - return ProcessorResult::error(sprintf('Failed to publish %d events because the event stream "%s" already exists (3)', count($domainEvents), $contentStreamStreamName->value)); - } - return ProcessorResult::success(sprintf('Imported %d event%s into stream "%s"', count($domainEvents), count($domainEvents) === 1 ? '' : 's', $contentStreamStreamName->value)); - } - - /** --------------------------- */ - - /** - * @param array $payload - * @return ContentStreamId - */ - private static function extractContentStreamId(array $payload): ContentStreamId - { - if (!isset($payload['contentStreamId']) || !is_string($payload['contentStreamId'])) { - throw new \RuntimeException('Failed to extract "contentStreamId" from event', 1646404169); - } - return ContentStreamId::fromString($payload['contentStreamId']); - } - - /** - * @phpstan-ignore-next-line currently this private method is unused ... but it does no harm keeping it - */ - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); + throw new \RuntimeException(sprintf('Failed to publish %d events because the event stream "%s" for workspace "%s" already contains events.', count($domainEvents), $contentStreamStreamName->value, $workspace->workspaceName->value), 1729506818, $e); } } } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php similarity index 55% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php index 249908cc680..9f2cc2d4a93 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/CrCommandController.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Command/SiteCommandController.php @@ -18,38 +18,22 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception as DBALException; use Doctrine\DBAL\Exception\ConnectionException; -use Neos\ContentRepository\Core\Projection\CatchUpOptions; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\LegacyNodeMigration\LegacyMigrationService; -use Neos\ContentRepository\LegacyNodeMigration\LegacyMigrationServiceFactory; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Export\Severity; +use Neos\ContentRepository\LegacyNodeMigration\LegacyExportServiceFactory; use Neos\ContentRepository\LegacyNodeMigration\RootNodeTypeMapping; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; -use Neos\ContentRepositoryRegistry\Factory\EventStore\DoctrineEventStoreFactory; -use Neos\ContentRepositoryRegistry\Service\ProjectionReplayServiceFactory; use Neos\Flow\Cli\CommandController; -use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Property\PropertyMapper; -use Neos\Flow\ResourceManagement\ResourceManager; -use Neos\Flow\ResourceManagement\ResourceRepository; -use Neos\Flow\Utility\Environment; -use Neos\Media\Domain\Repository\AssetRepository; -use Neos\Neos\Domain\Model\Site; -use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Utility\Files; -class CrCommandController extends CommandController +class SiteCommandController extends CommandController { public function __construct( private readonly Connection $connection, - private readonly Environment $environment, - private readonly PersistenceManagerInterface $persistenceManager, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, private readonly PropertyMapper $propertyMapper, private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly SiteRepository $siteRepository, - private readonly ProjectionReplayServiceFactory $projectionReplayServiceFactory, ) { parent::__construct(); } @@ -57,12 +41,22 @@ public function __construct( /** * Migrate from the Legacy CR * - * @param bool $verbose If set, all notices will be rendered - * @param string|null $config JSON encoded configuration, for example '{"dbal": {"dbname": "some-other-db"}, "resourcesPath": "/some/absolute/path", "rootNodes": {"/sites": "Neos.Neos:Sites", "/other": "My.Package:SomeOtherRoot"}}' + * This command creates a Neos 9 export format based on the data from the specified legacy content repository database connection + * The export will be placed in the specified directory path, and can be imported via "site:importAll": + * + * ./flow site:exportLegacyData --path ./migratedContent + * ./flow site:importAll --path ./migratedContent + * + * Note that the dimension configuration and the node type schema must be migrated of the reference content repository + * + * @param string $contentRepository The reference content repository that can later be used for importing into + * @param string $path The path to the directory to export to, will be created if missing + * @param string|null $config JSON encoded configuration, for example --config '{"dbal": {"dbname": "some-other-db"}, "resourcesPath": "/absolute-path/Data/Persistent/Resources", "rootNodes": {"/sites": "Neos.Neos:Sites", "/other": "My.Package:SomeOtherRoot"}}' * @throws \Exception */ - public function migrateLegacyDataCommand(bool $verbose = false, string $config = null): void + public function exportLegacyDataCommand(string $path, string $contentRepository = 'default', string $config = null, bool $verbose = false): void { + Files::createDirectoryRecursively($path); if ($config !== null) { try { $parsedConfig = json_decode($config, true, 512, JSON_THROW_ON_ERROR); @@ -80,86 +74,36 @@ public function migrateLegacyDataCommand(bool $verbose = false, string $config = $resourcesPath = $this->determineResourcesPath(); $rootNodes = $this->getDefaultRootNodes(); if (!$this->output->askConfirmation(sprintf('Do you want to migrate nodes from the current database "%s@%s" (y/n)? ', $this->connection->getParams()['dbname'] ?? '?', $this->connection->getParams()['host'] ?? '?'))) { - $connection = $this->adjustDataBaseConnection($this->connection); + $connection = $this->adjustDatabaseConnection($this->connection); } else { $connection = $this->connection; } } $this->verifyDatabaseConnection($connection); - - $siteRows = $connection->fetchAllAssociativeIndexed('SELECT nodename, name, siteresourcespackagekey FROM neos_neos_domain_model_site'); - $siteNodeName = $this->output->select('Which site to migrate?', array_map(static fn (array $siteRow) => $siteRow['name'] . ' (' . $siteRow['siteresourcespackagekey'] . ')', $siteRows)); - assert(is_string($siteNodeName)); - $siteRow = $siteRows[$siteNodeName]; - - $site = $this->siteRepository->findOneByNodeName($siteNodeName); - if ($site !== null) { - if (!$this->output->askConfirmation(sprintf('Site "%s" already exists, update it? [n] ', $siteNodeName), false)) { - $this->outputLine('Cancelled...'); - $this->quit(); - } - - $site->setSiteResourcesPackageKey($siteRow['siteresourcespackagekey']); - $site->setState(Site::STATE_ONLINE); - $site->setName($siteRow['name']); - $this->siteRepository->update($site); - $this->persistenceManager->persistAll(); - } else { - $site = new Site($siteNodeName); - $site->setSiteResourcesPackageKey($siteRow['siteresourcespackagekey']); - $site->setState(Site::STATE_ONLINE); - $site->setName($siteRow['name']); - $this->siteRepository->add($site); - $this->persistenceManager->persistAll(); - } - - $contentRepositoryId = $site->getConfiguration()->contentRepositoryId; - - $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); - $confirmed = $this->output->askConfirmation(sprintf('We will clear the events from "%s". ARE YOU SURE [n]? ', $eventTableName), false); - if (!$confirmed) { - $this->outputLine('Cancelled...'); - $this->quit(); - } - $this->connection->executeStatement('TRUNCATE ' . $connection->quoteIdentifier($eventTableName)); - // we also need to reset the projections; in order to ensure the system runs deterministically - $projectionService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->projectionReplayServiceFactory); - $projectionService->resetAllProjections(); - $this->outputLine('Truncated events'); - - $liveContentStreamId = ContentStreamId::create(); - - $legacyMigrationService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new LegacyMigrationServiceFactory( + $legacyExportService = $this->contentRepositoryRegistry->buildService( + ContentRepositoryId::fromString($contentRepository), + new LegacyExportServiceFactory( $connection, $resourcesPath, - $this->environment, - $this->persistenceManager, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, $this->propertyMapper, - $liveContentStreamId, $rootNodes, ) ); - assert($legacyMigrationService instanceof LegacyMigrationService); - $legacyMigrationService->runAllProcessors($this->outputLine(...), $verbose); - - $this->outputLine(); + $legacyExportService->exportToPath( + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); - $this->outputLine('Replaying projections'); - $projectionService->replayAllProjections(CatchUpOptions::create()); $this->outputLine('Done'); } /** * @throws DBALException */ - private function adjustDataBaseConnection(Connection $connection): Connection + private function adjustDatabaseConnection(Connection $connection): Connection { $connectionParams = $connection->getParams(); $connectionParams['driver'] = $this->output->select(sprintf('Driver? [%s] ', $connectionParams['driver'] ?? ''), ['pdo_mysql', 'pdo_sqlite', 'pdo_pgsql'], $connectionParams['driver'] ?? null); @@ -184,7 +128,7 @@ private function verifyDatabaseConnection(Connection $connection): void } catch (ConnectionException $exception) { $this->outputLine('Failed to connect to database "%s": %s', [$connection->getDatabase(), $exception->getMessage()]); $this->outputLine('Please verify connection parameters...'); - $this->adjustDataBaseConnection($connection); + $this->adjustDatabaseConnection($connection); } } while (true); } @@ -208,6 +152,28 @@ private static function defaultResourcesPath(): string return FLOW_PATH_DATA . 'Persistent/Resources'; } + protected function createOnProcessorClosure(): \Closure + { + $onProcessor = function (string $processorLabel) { + $this->outputLine('%s...', [$processorLabel]); + }; + return $onProcessor; + } + + protected function createOnMessageClosure(bool $verbose): \Closure + { + return function (Severity $severity, string $message) use ($verbose) { + if (!$verbose && $severity === Severity::NOTICE) { + return; + } + $this->outputLine(match ($severity) { + Severity::NOTICE => $message, + Severity::WARNING => sprintf('Warning: %s', $message), + Severity::ERROR => sprintf('Error: %s', $message), + }); + }; + } + private function getDefaultRootNodes(): RootNodeTypeMapping { return RootNodeTypeMapping::fromArray(['/sites' => NodeTypeNameFactory::NAME_SITES]); diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php new file mode 100644 index 00000000000..beb74a87fee --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/DomainDataLoader.php @@ -0,0 +1,33 @@ +> + */ +final class DomainDataLoader implements \IteratorAggregate +{ + public function __construct( + private readonly Connection $connection, + ) { + } + + /** + * @return \Traversable> + */ + public function getIterator(): \Traversable + { + $query = $this->connection->executeQuery(' + SELECT + * + FROM + neos_neos_domain_model_domain + '); + return $query->iterateAssociative(); + } +} + + diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php new file mode 100644 index 00000000000..2d878f9302f --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Helpers/SiteDataLoader.php @@ -0,0 +1,33 @@ +> + */ +final class SiteDataLoader implements \IteratorAggregate +{ + public function __construct( + private readonly Connection $connection, + ) { + } + + /** + * @return \Traversable> + */ + public function getIterator(): \Traversable + { + $query = $this->connection->executeQuery(' + SELECT + * + FROM + neos_neos_domain_model_site + '); + return $query->iterateAssociative(); + } +} + + diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php new file mode 100644 index 00000000000..dc59b6a79ce --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportService.php @@ -0,0 +1,74 @@ +connection), new FileSystemResourceLoader($this->resourcesPath)); + + $processors = Processors::fromArray([ + 'Exporting assets' => new AssetExportProcessor($this->nodeTypeManager, $assetExporter, new NodeDataLoader($this->connection)), + 'Exporting node data' => new EventExportProcessor($this->nodeTypeManager, $this->propertyMapper, $this->propertyConverter, $this->interDimensionalVariationGraph, $this->eventNormalizer, $this->rootNodeTypeMapping, new NodeDataLoader($this->connection)), + 'Exporting sites data' => new SitesExportProcessor(new SiteDataLoader($this->connection), new DomainDataLoader($this->connection)), + ]); + + $processingContext = new ProcessingContext($filesystem, $onMessage); + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($processingContext); + } + } +} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php similarity index 54% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php index 2010bc989e0..884fc639620 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationServiceFactory.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyExportServiceFactory.php @@ -1,4 +1,5 @@ + * @implements ContentRepositoryServiceFactoryInterface */ -class LegacyMigrationServiceFactory implements ContentRepositoryServiceFactoryInterface +class LegacyExportServiceFactory implements ContentRepositoryServiceFactoryInterface { - public function __construct( private readonly Connection $connection, private readonly string $resourcesPath, - private readonly Environment $environment, - private readonly PersistenceManagerInterface $persistenceManager, - private readonly AssetRepository $assetRepository, - private readonly ResourceRepository $resourceRepository, - private readonly ResourceManager $resourceManager, private readonly PropertyMapper $propertyMapper, - private readonly ContentStreamId $contentStreamId, private readonly RootNodeTypeMapping $rootNodeTypeMapping, ) { } public function build( ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies - ): LegacyMigrationService - { - return new LegacyMigrationService( + ): LegacyExportService { + return new LegacyExportService( $this->connection, $this->resourcesPath, - $this->environment, - $this->persistenceManager, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, $serviceFactoryDependencies->interDimensionalVariationGraph, $serviceFactoryDependencies->nodeTypeManager, $this->propertyMapper, $serviceFactoryDependencies->eventNormalizer, $serviceFactoryDependencies->propertyConverter, - $serviceFactoryDependencies->eventStore, - $this->contentStreamId, $this->rootNodeTypeMapping, ); } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php deleted file mode 100644 index f2ef4113a04..00000000000 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/LegacyMigrationService.php +++ /dev/null @@ -1,98 +0,0 @@ -environment->getPathToTemporaryDirectory() . uniqid('Export', true); - Files::createDirectoryRecursively($temporaryFilePath); - $filesystem = new Filesystem(new LocalFilesystemAdapter($temporaryFilePath)); - - $assetExporter = new AssetExporter($filesystem, new DbalAssetLoader($this->connection), new FileSystemResourceLoader($this->resourcesPath)); - - /** @var ProcessorInterface[] $processors */ - $processors = [ - 'Exporting assets' => new NodeDataToAssetsProcessor($this->nodeTypeManager, $assetExporter, new NodeDataLoader($this->connection)), - 'Exporting node data' => new NodeDataToEventsProcessor($this->nodeTypeManager, $this->propertyMapper, $this->propertyConverter, $this->interDimensionalVariationGraph, $this->eventNormalizer, $filesystem, $this->rootNodeTypeMapping,new NodeDataLoader($this->connection)), - 'Importing assets' => new AssetRepositoryImportProcessor($filesystem, $this->assetRepository, $this->resourceRepository, $this->resourceManager, $this->persistenceManager), - 'Importing events' => new EventStoreImportProcessor(true, $filesystem, $this->eventStore, $this->eventNormalizer, $this->contentStreamId), - ]; - - foreach ($processors as $label => $processor) { - $outputLineFn($label . '...'); - $processor->onMessage(function (Severity $severity, string $message) use ($verbose, $outputLineFn) { - if ($severity !== Severity::NOTICE || $verbose) { - $outputLineFn('<%1$s>%2$s', [$severity === Severity::ERROR ? 'error' : 'comment', $message]); - } - }); - $result = $processor->run(); - if ($result->severity === Severity::ERROR) { - throw new \RuntimeException($label . ': ' . $result->message); - } - $outputLineFn(' ' . $result->message); - $outputLineFn(); - } - Files::unlink($temporaryFilePath); - } -} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php similarity index 63% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php index 86d38a6d8a5..57794d129eb 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToAssetsProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/AssetExportProcessor.php @@ -1,29 +1,25 @@ */ private array $processedAssetIds = []; - /** - * @var array<\Closure> - */ - private array $callbacks = []; /** * @param iterable> $nodeDataRows @@ -32,16 +28,11 @@ public function __construct( private readonly NodeTypeManager $nodeTypeManager, private readonly AssetExporter $assetExporter, private readonly iterable $nodeDataRows, - ) {} - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; + ) { } - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { - $numberOfErrors = 0; foreach ($this->nodeDataRows as $nodeDataRow) { if ($nodeDataRow['path'] === '/sites') { // the sites node has no properties and is unstructured @@ -50,21 +41,20 @@ public function run(): ProcessorResult $nodeTypeName = NodeTypeName::fromString($nodeDataRow['nodetype']); $nodeType = $this->nodeTypeManager->getNodeType($nodeTypeName); if (!$nodeType) { - $this->dispatch(Severity::ERROR, 'The node type "%s" is not available. Node: "%s"', $nodeTypeName->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::ERROR, "The node type \"{$nodeTypeName->value}\" is not available. Node: \"{$nodeDataRow['identifier']}\""); continue; } try { $properties = json_decode($nodeDataRow['properties'], true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to JSON-decode properties %s of node "%s" (type: "%s"): %s', $nodeDataRow['properties'], $nodeDataRow['identifier'], $nodeTypeName->value, $exception->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to JSON-decode properties {$nodeDataRow['properties']} of node \"{$nodeDataRow['identifier']}\" (type: \"{$nodeTypeName->value}\"): {$exception->getMessage()}"); continue; } foreach ($properties as $propertyName => $propertyValue) { try { $propertyType = $nodeType->getPropertyType($propertyName); - } catch (\InvalidArgumentException $exception) { - $this->dispatch(Severity::WARNING, 'Skipped node data processing for the property "%s". The property name is not part of the NodeType schema for the NodeType "%s". (Node: %s)', $propertyName, $nodeType->name->value, $nodeDataRow['identifier']); + } catch (\InvalidArgumentException $e) { + $context->dispatch(Severity::WARNING, "Skipped node data processing for the property \"{$propertyName}\". The property name is not part of the NodeType schema for the NodeType \"{$nodeType->name->value}\". (Node: {$nodeDataRow['identifier']})"); continue; } foreach ($this->extractAssetIdentifiers($propertyType, $propertyValue) as $assetId) { @@ -75,15 +65,11 @@ public function run(): ProcessorResult try { $this->assetExporter->exportAsset($assetId); } catch (\Exception $exception) { - $numberOfErrors ++; - $this->dispatch(Severity::ERROR, 'Failed to extract assets of property "%s" of node "%s" (type: "%s"): %s', $propertyName, $nodeDataRow['identifier'], $nodeTypeName->value, $exception->getMessage()); + $context->dispatch(Severity::ERROR, "Failed to extract assets of property \"{$propertyName}\" of node \"{$nodeDataRow['identifier']}\" (type: \"{$nodeTypeName->value}\"): {$exception->getMessage()}"); } } } } - $numberOfExportedAssets = count($this->processedAssetIds); - $this->processedAssetIds = []; - return ProcessorResult::success(sprintf('Exported %d asset%s. Errors: %d', $numberOfExportedAssets, $numberOfExportedAssets === 1 ? '' : 's', $numberOfErrors)); } /** ----------------------------- */ @@ -110,8 +96,7 @@ private function extractAssetIdentifiers(string $type, mixed $value): array if ($parsedType['elementType'] === null) { return []; } - if (!is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class, true) - && !is_subclass_of($parsedType['elementType'], \Stringable::class, true)) { + if (!is_subclass_of($parsedType['elementType'], ResourceBasedInterface::class) && !is_subclass_of($parsedType['elementType'], \Stringable::class)) { return []; } /** @var array> $assetIdentifiers */ @@ -122,13 +107,4 @@ private function extractAssetIdentifiers(string $type, mixed $value): array } return array_merge(...$assetIdentifiers); } - - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } - } diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php similarity index 85% rename from Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php rename to Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php index 4358241bcc4..6d547c385bc 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Classes/NodeDataToEventsProcessor.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/EventExportProcessor.php @@ -1,12 +1,11 @@ - */ - private array $callbacks = []; private WorkspaceName $workspaceName; private ContentStreamId $contentStreamId; private VisitedNodeAggregates $visitedNodes; @@ -73,8 +69,6 @@ final class NodeDataToEventsProcessor implements ProcessorInterface private int $numberOfExportedEvents = 0; - private bool $metaDataExported = false; - /** * @var resource|null */ @@ -89,7 +83,6 @@ public function __construct( private readonly PropertyConverter $propertyConverter, private readonly InterDimensionalVariationGraph $interDimensionalVariationGraph, private readonly EventNormalizer $eventNormalizer, - private readonly Filesystem $files, private readonly RootNodeTypeMapping $rootNodeTypeMapping, private readonly iterable $nodeDataRows, ) { @@ -98,17 +91,7 @@ public function __construct( $this->visitedNodes = new VisitedNodeAggregates(); } - public function setContentStreamId(ContentStreamId $contentStreamId): void - { - $this->contentStreamId = $contentStreamId; - } - - public function onMessage(\Closure $callback): void - { - $this->callbacks[] = $callback; - } - - public function run(): ProcessorResult + public function run(ProcessingContext $context): void { $this->resetRuntimeState(); @@ -122,15 +105,7 @@ public function run(): ProcessorResult continue; } } - if ($this->metaDataExported === false && $nodeDataRow['parentpath'] === '/sites') { - $this->exportMetaData($nodeDataRow); - $this->metaDataExported = true; - } - try { - $this->processNodeData($nodeDataRow); - } catch (MigrationException $exception) { - return ProcessorResult::error($exception->getMessage()); - } + $this->processNodeData($context, $nodeDataRow); } // Set References, now when the full import is done. foreach ($this->nodeReferencesWereSetEvents as $nodeReferencesWereSetEvent) { @@ -138,11 +113,10 @@ public function run(): ProcessorResult } try { - $this->files->writeStream('events.jsonl', $this->eventFileResource); - } catch (FilesystemException $exception) { - return ProcessorResult::error(sprintf('Failed to write events.jsonl: %s', $exception->getMessage())); + $context->files->writeStream('events.jsonl', $this->eventFileResource); + } catch (FilesystemException $e) { + throw new \RuntimeException(sprintf('Failed to write events.jsonl: %s', $e->getMessage()), 1729506930, $e); } - return ProcessorResult::success(sprintf('Exported %d event%s', $this->numberOfExportedEvents, $this->numberOfExportedEvents === 1 ? '' : 's')); } /** ----------------------------- */ @@ -152,7 +126,6 @@ private function resetRuntimeState(): void $this->visitedNodes = new VisitedNodeAggregates(); $this->nodeReferencesWereSetEvents = []; $this->numberOfExportedEvents = 0; - $this->metaDataExported = false; $this->eventFileResource = fopen('php://temp/maxmemory:5242880', 'rb+') ?: null; Assert::resource($this->eventFileResource, null, 'Failed to create temporary event file resource'); } @@ -165,6 +138,8 @@ private function exportEvent(EventInterface $event): void } catch (\JsonException $e) { throw new \RuntimeException(sprintf('Failed to JSON-decode "%s": %s', $normalizedEvent->data->value, $e->getMessage()), 1723032243, $e); } + // do not export crid and workspace as they are always imported into a single workspace + unset($exportedEventPayload['contentStreamId'], $exportedEventPayload['workspaceName']); $exportedEvent = new ExportedEvent( $normalizedEvent->id->value, $normalizedEvent->type->value, @@ -179,24 +154,7 @@ private function exportEvent(EventInterface $event): void /** * @param array $nodeDataRow */ - private function exportMetaData(array $nodeDataRow): void - { - if ($this->files->fileExists('meta.json')) { - $data = json_decode($this->files->read('meta.json'), true, 512, JSON_THROW_ON_ERROR); - } else { - $data = []; - } - $data['version'] = 1; - $data['sitePackageKey'] = strtok($nodeDataRow['nodetype'], ':'); - $data['siteNodeName'] = substr($nodeDataRow['path'], 7); - $data['siteNodeType'] = $nodeDataRow['nodetype']; - $this->files->write('meta.json', json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); - } - - /** - * @param array $nodeDataRow - */ - private function processNodeData(array $nodeDataRow): void + private function processNodeData(ProcessingContext $context, array $nodeDataRow): void { $nodeAggregateId = NodeAggregateId::fromString($nodeDataRow['identifier']); @@ -226,11 +184,11 @@ private function processNodeData(array $nodeDataRow): void foreach ($this->interDimensionalVariationGraph->getDimensionSpacePoints() as $dimensionSpacePoint) { $originDimensionSpacePoint = OriginDimensionSpacePoint::fromDimensionSpacePoint($dimensionSpacePoint); if (!$this->visitedNodes->alreadyVisitedOriginDimensionSpacePoints($nodeAggregateId)->contains($originDimensionSpacePoint)) { - $this->processNodeDataWithoutFallbackToEmptyDimension($nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); + $this->processNodeDataWithoutFallbackToEmptyDimension($context, $nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); } } } else { - $this->processNodeDataWithoutFallbackToEmptyDimension($nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); + $this->processNodeDataWithoutFallbackToEmptyDimension($context, $nodeAggregateId, $originDimensionSpacePoint, $nodeDataRow); } } @@ -241,12 +199,12 @@ private function processNodeData(array $nodeDataRow): void * @param array $nodeDataRow * @return NodeName[]|void */ - public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $originDimensionSpacePoint, array $nodeDataRow) + public function processNodeDataWithoutFallbackToEmptyDimension(ProcessingContext $context, NodeAggregateId $nodeAggregateId, OriginDimensionSpacePoint $originDimensionSpacePoint, array $nodeDataRow) { $nodePath = NodePath::fromString(strtolower($nodeDataRow['path'])); $parentNodeAggregate = $this->visitedNodes->findMostSpecificParentNodeInDimensionGraph($nodePath, $originDimensionSpacePoint, $this->interDimensionalVariationGraph); if ($parentNodeAggregate === null) { - $this->dispatch(Severity::ERROR, 'Failed to find parent node for node with id "%s" and dimensions: %s. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes.', $nodeAggregateId->value, $originDimensionSpacePoint->toJson()); + $context->dispatch(Severity::ERROR, "Failed to find parent node for node with id \"{$nodeAggregateId->value}\" and dimensions: {$originDimensionSpacePoint->toJson()}. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes."); return; } $pathParts = $nodePath->getParts(); @@ -257,18 +215,18 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ $nodeType = $this->nodeTypeManager->getNodeType($nodeTypeName); $isSiteNode = $nodeDataRow['parentpath'] === '/sites'; - if ($isSiteNode && !$nodeType?->isOfType(NodeTypeNameFactory::NAME_SITE)) { - throw new MigrationException(sprintf( - 'The site node "%s" (type: "%s") must be of type "%s"', $nodeDataRow['identifier'], $nodeTypeName->value, NodeTypeNameFactory::NAME_SITE - ), 1695801620); - } if (!$nodeType) { - $this->dispatch(Severity::ERROR, 'The node type "%s" is not available. Node: "%s"', $nodeTypeName->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::ERROR, "The node type \"{$nodeTypeName->value}\" is not available. Node: \"{$nodeDataRow['identifier']}\""); return; } - $serializedPropertyValuesAndReferences = $this->extractPropertyValuesAndReferences($nodeDataRow, $nodeType); + if ($isSiteNode && !$nodeType->isOfType(NodeTypeNameFactory::NAME_SITE)) { + $declaredSuperTypes = array_keys($nodeType->getDeclaredSuperTypes()); + throw new MigrationException(sprintf('The site node "%s" (type: "%s") must be of type "%s". Currently declared super types: "%s"', $nodeDataRow['identifier'], $nodeTypeName->value, NodeTypeNameFactory::NAME_SITE, join(',', $declaredSuperTypes)), 1695801620); + } + + $serializedPropertyValuesAndReferences = $this->extractPropertyValuesAndReferences($context, $nodeDataRow, $nodeType); if ($this->isAutoCreatedChildNode($parentNodeAggregate->nodeTypeName, $nodeName) && !$this->visitedNodes->containsNodeAggregate($nodeAggregateId)) { // Create tethered node if the node was not found before. @@ -329,7 +287,7 @@ public function processNodeDataWithoutFallbackToEmptyDimension(NodeAggregateId $ /** * @param array $nodeDataRow */ - public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType $nodeType): SerializedPropertyValuesAndReferences + public function extractPropertyValuesAndReferences(ProcessingContext $context, array $nodeDataRow, NodeType $nodeType): SerializedPropertyValuesAndReferences { $properties = []; $references = []; @@ -359,7 +317,7 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } if (!$nodeType->hasProperty($propertyName)) { - $this->dispatch(Severity::WARNING, 'Skipped node data processing for the property "%s". The property name is not part of the NodeType schema for the NodeType "%s". (Node: %s)', $propertyName, $nodeType->name->value, $nodeDataRow['identifier']); + $context->dispatch(Severity::WARNING, "Skipped node data processing for the property \"{$propertyName}\". The property name is not part of the NodeType schema for the NodeType \"{$nodeType->name->value}\". (Node: {$nodeDataRow['identifier']})"); continue; } $type = $nodeType->getPropertyType($propertyName); @@ -372,7 +330,6 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } else { $properties[$propertyName] = $this->propertyMapper->convert($propertyValue, $type); } - } catch (\Exception $e) { throw new MigrationException(sprintf('Failed to convert property "%s" of type "%s" (Node: %s): %s', $propertyName, $type, $nodeDataRow['identifier'], $e->getMessage()), 1655912878, $e); } @@ -394,7 +351,7 @@ public function extractPropertyValuesAndReferences(array $nodeDataRow, NodeType } } else { if ($nodeDataRow['hiddenbeforedatetime'] || $nodeDataRow['hiddenafterdatetime']) { - $this->dispatch(Severity::WARNING, 'Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them.'); + $context->dispatch(Severity::WARNING, 'Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them.'); } } @@ -505,14 +462,6 @@ private function isAutoCreatedChildNode(NodeTypeName $parentNodeTypeName, NodeNa return $nodeTypeOfParent->tetheredNodeTypeDefinitions->contain($nodeName); } - private function dispatch(Severity $severity, string $message, mixed ...$args): void - { - $renderedMessage = sprintf($message, ...$args); - foreach ($this->callbacks as $callback) { - $callback($severity, $renderedMessage); - } - } - /** * Determines actual hidden state based on "hidden", "hiddenafterdatetime" and "hiddenbeforedatetime" * @@ -530,7 +479,8 @@ private function isNodeHidden(array $nodeDataRow): bool $hiddenBeforeDateTime = $nodeDataRow['hiddenbeforedatetime'] ? new \DateTimeImmutable($nodeDataRow['hiddenbeforedatetime']) : null; // Hidden after a date time, without getting already re-enabled by hidden before date time - afterward - if ($hiddenAfterDateTime != null + if ( + $hiddenAfterDateTime != null && $hiddenAfterDateTime < $now && ( $hiddenBeforeDateTime == null @@ -542,7 +492,8 @@ private function isNodeHidden(array $nodeDataRow): bool } // Hidden before a date time, without getting enabled by hidden after date time - before - if ($hiddenBeforeDateTime != null + if ( + $hiddenBeforeDateTime != null && $hiddenBeforeDateTime > $now && ( $hiddenAfterDateTime == null diff --git a/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php new file mode 100644 index 00000000000..0f2510586a3 --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Classes/Processors/SitesExportProcessor.php @@ -0,0 +1,68 @@ +> $siteRows + * @param iterable> $domainRows + */ + public function __construct( + private readonly iterable $siteRows, + private readonly iterable $domainRows + ) { + } + + public function run(ProcessingContext $context): void + { + $sitesData = $this->getSiteData(); + $context->files->write('sites.json', json_encode($sitesData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * @return SiteShape[] + */ + private function getSiteData(): array + { + $siteData = []; + foreach ($this->siteRows as $siteRow) { + $siteData[] = [ + "name" => $siteRow['name'], + "nodeName" => $siteRow['nodename'], + "siteResourcesPackageKey" => $siteRow['siteresourcespackagekey'], + "online" => $siteRow['state'] === 1, + "domains" => array_values( + array_filter( + array_map( + function(array $domainRow) use ($siteRow) { + if ($siteRow['persistence_object_identifier'] !== $domainRow['site']) { + return null; + } + return [ + 'hostname' => $domainRow['hostname'], + 'scheme' => $domainRow['scheme'], + 'port' => $domainRow['port'], + 'active' => (bool)$domainRow['active'], + 'primary' => $domainRow['persistence_object_identifier'] === $siteRow['primarydomain'], + ]; + }, + iterator_to_array($this->domainRows) + ) + ) + ) + ]; + } + + return $siteData; + } +} diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php index d73bf80096c..415bf6d8b04 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Bootstrap/FeatureContext.php @@ -1,13 +1,13 @@ */ private array $mockResources = []; /** @var array */ private array $mockAssets = []; - private InMemoryFilesystemAdapter $mockFilesystemAdapter; - private Filesystem $mockFilesystem; - - private ProcessorResult|null $lastMigrationResult = null; - - /** - * @var array - */ - private array $loggedErrors = []; - - /** - * @var array - */ - private array $loggedWarnings = []; protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -77,21 +61,7 @@ public function __construct() self::bootstrapFlow(); $this->contentRepositoryRegistry = $this->getObject(ContentRepositoryRegistry::class); - $this->mockFilesystemAdapter = new InMemoryFilesystemAdapter(); - $this->mockFilesystem = new Filesystem($this->mockFilesystemAdapter); - } - - /** - * @AfterScenario - */ - public function failIfLastMigrationHasErrors(): void - { - if ($this->lastMigrationResult !== null && $this->lastMigrationResult->severity === Severity::ERROR) { - throw new \RuntimeException(sprintf('The last migration run led to an error: %s', $this->lastMigrationResult->message)); - } - if ($this->loggedErrors !== []) { - throw new \RuntimeException(sprintf('The last migration run logged %d error%s', count($this->loggedErrors), count($this->loggedErrors) === 1 ? '' : 's')); - } + $this->setupCrImportExportTrait(); } /** @@ -116,20 +86,18 @@ public function iHaveTheFollowingNodeDataRows(TableNode $nodeDataRows): void } /** - * @When /^I run the event migration for content stream (.*) with rootNode mapping (.*)$/ + * @When /^I run the event migration with rootNode mapping (.*)$/ */ - public function iRunTheEventMigrationForContentStreamWithRootnodeMapping(string $contentStream = null, string $rootNodeMapping): void + public function iRunTheEventMigrationWithRootnodeMapping(string $rootNodeMapping): void { - $contentStream = trim($contentStream, '"'); $rootNodeTypeMapping = RootNodeTypeMapping::fromArray(json_decode($rootNodeMapping, true)); - $this->iRunTheEventMigration($contentStream, $rootNodeTypeMapping); + $this->iRunTheEventMigration($rootNodeTypeMapping); } /** * @When I run the event migration - * @When I run the event migration for content stream :contentStream */ - public function iRunTheEventMigration(string $contentStream = null, RootNodeTypeMapping $rootNodeTypeMapping = null): void + public function iRunTheEventMigration(RootNodeTypeMapping $rootNodeTypeMapping = null): void { $nodeTypeManager = $this->currentContentRepository->getNodeTypeManager(); $propertyMapper = $this->getObject(PropertyMapper::class); @@ -147,96 +115,17 @@ public function build(ContentRepositoryServiceFactoryDependencies $serviceFactor }; $this->getContentRepositoryService($propertyConverterAccess); - $migration = new NodeDataToEventsProcessor( + $eventExportProcessor = new EventExportProcessor( $nodeTypeManager, $propertyMapper, $propertyConverterAccess->propertyConverter, $this->currentContentRepository->getVariationGraph(), $this->getObject(EventNormalizer::class), - $this->mockFilesystem, $rootNodeTypeMapping ?? RootNodeTypeMapping::fromArray(['/sites' => NodeTypeNameFactory::NAME_SITES]), $this->nodeDataRows ); - if ($contentStream !== null) { - $migration->setContentStreamId(ContentStreamId::fromString($contentStream)); - } - $migration->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->loggedWarnings[] = $message; - } - }); - $this->lastMigrationResult = $migration->run(); - } - - /** - * @Then I expect the following events to be exported - */ - public function iExpectTheFollowingEventsToBeExported(TableNode $table): void - { - - if (!$this->mockFilesystem->has('events.jsonl')) { - Assert::fail('No events were exported'); - } - $eventsJson = $this->mockFilesystem->read('events.jsonl'); - $exportedEvents = iterator_to_array(ExportedEvents::fromJsonl($eventsJson)); - $expectedEvents = $table->getHash(); - foreach ($exportedEvents as $exportedEvent) { - $expectedEventRow = array_shift($expectedEvents); - if ($expectedEventRow === null) { - Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); - } - if (!empty($expectedEventRow['Type'])) { - Assert::assertSame($expectedEventRow['Type'], $exportedEvent->type, 'Event: ' . $exportedEvent->toJson()); - } - try { - $expectedEventPayload = json_decode($expectedEventRow['Payload'], true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new \RuntimeException(sprintf('Failed to decode expected JSON: %s', $expectedEventRow['Payload']), 1655811083); - } - $actualEventPayload = $exportedEvent->payload; - foreach (array_keys($actualEventPayload) as $key) { - if (!array_key_exists($key, $expectedEventPayload)) { - unset($actualEventPayload[$key]); - } - } - Assert::assertEquals($expectedEventPayload, $actualEventPayload, 'Actual event: ' . $exportedEvent->toJson()); - } - Assert::assertCount(count($table->getHash()), $exportedEvents, 'Expected number of events does not match actual number'); - } - - /** - * @Then I expect the following errors to be logged - */ - public function iExpectTheFollowingErrorsToBeLogged(TableNode $table): void - { - Assert::assertSame($table->getColumn(0), $this->loggedErrors, 'Expected logged errors do not match'); - $this->loggedErrors = []; - } - - /** - * @Then I expect the following warnings to be logged - */ - public function iExpectTheFollowingWarningsToBeLogged(TableNode $table): void - { - Assert::assertSame($table->getColumn(0), $this->loggedWarnings, 'Expected logged warnings do not match'); - $this->loggedWarnings = []; - } - - /** - * @Then I expect a MigrationError - * @Then I expect a MigrationError with the message - */ - public function iExpectAMigrationErrorWithTheMessage(PyStringNode $expectedMessage = null): void - { - Assert::assertNotNull($this->lastMigrationResult, 'Expected the previous migration to contain errors, but no migration has been executed'); - Assert::assertSame(Severity::ERROR, $this->lastMigrationResult->severity, sprintf('Expected the previous migration to contain errors, but it ended with severity "%s"', $this->lastMigrationResult->severity->name)); - if ($expectedMessage !== null) { - Assert::assertSame($expectedMessage->getRaw(), $this->lastMigrationResult->message); - } - $this->lastMigrationResult = null; + $this->runCrImportExportProcessors($eventExportProcessor); } /** @@ -277,36 +166,20 @@ public function theFollowingAssetsExist(TableNode $images): void } } - /** - * @Given the following ImageVariants exist - */ - public function theFollowingImageVariantsExist(TableNode $imageVariants): void - { - foreach ($imageVariants->getHash() as $variantData) { - try { - $variantData['imageAdjustments'] = json_decode($variantData['imageAdjustments'], true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new \RuntimeException(sprintf('Failed to JSON decode imageAdjustments for variant "%s"', $variantData['identifier']), 1659530081, $e); - } - $variantData['width'] = (int)$variantData['width']; - $variantData['height'] = (int)$variantData['height']; - $mockImageVariant = SerializedImageVariant::fromArray($variantData); - $this->mockAssets[$mockImageVariant->identifier] = $mockImageVariant; - } - } - /** * @When I run the asset migration */ public function iRunTheAssetMigration(): void { $nodeTypeManager = $this->currentContentRepository->getNodeTypeManager(); - $mockResourceLoader = new class ($this->mockResources) implements ResourceLoaderInterface { - + $mockResourceLoader = new class ($this->mockResources) implements ResourceLoaderInterface + { /** * @param array $mockResources */ - public function __construct(private array $mockResources) {} + public function __construct(private array $mockResources) + { + } public function getStreamBySha1(string $sha1) { @@ -323,7 +196,9 @@ public function getStreamBySha1(string $sha1) /** * @param array $mockAssets */ - public function __construct(private array $mockAssets) {} + public function __construct(private array $mockAssets) + { + } public function findAssetById(string $assetId): SerializedAsset|SerializedImageVariant { @@ -334,84 +209,49 @@ public function findAssetById(string $assetId): SerializedAsset|SerializedImageV } }; - $this->mockFilesystemAdapter->deleteEverything(); - $assetExporter = new AssetExporter($this->mockFilesystem, $mockAssetLoader, $mockResourceLoader); - $migration = new NodeDataToAssetsProcessor($nodeTypeManager, $assetExporter, $this->nodeDataRows); - $migration->onMessage(function (Severity $severity, string $message) { - if ($severity === Severity::ERROR) { - $this->loggedErrors[] = $message; - } elseif ($severity === Severity::WARNING) { - $this->loggedWarnings[] = $message; - } - }); - $this->lastMigrationResult = $migration->run(); - } - - /** - * @Then /^I expect the following (Assets|ImageVariants) to be exported:$/ - */ - public function iExpectTheFollowingToBeExported(string $type, PyStringNode $expectedAssets): void - { - $actualAssets = []; - if (!$this->mockFilesystem->directoryExists($type)) { - Assert::fail(sprintf('No %1$s have been exported (Directory "/%1$s" does not exist)', $type)); - } - /** @var FileAttributes $file */ - foreach ($this->mockFilesystem->listContents($type) as $file) { - $actualAssets[] = json_decode($this->mockFilesystem->read($file->path()), true, 512, JSON_THROW_ON_ERROR); - } - Assert::assertJsonStringEqualsJsonString($expectedAssets->getRaw(), json_encode($actualAssets, JSON_THROW_ON_ERROR)); + $assetExporter = new AssetExporter($this->crImportExportTrait_filesystem, $mockAssetLoader, $mockResourceLoader); + $migration = new AssetExportProcessor($nodeTypeManager, $assetExporter, $this->nodeDataRows); + $this->runCrImportExportProcessors($migration); } /** - * @Then /^I expect no (Assets|ImageVariants) to be exported$/ + * @When I have the following site data rows: */ - public function iExpectNoAssetsToBeExported(string $type): void + public function iHaveTheFollowingSiteDataRows(TableNode $siteDataRows): void { - Assert::assertFalse($this->mockFilesystem->directoryExists($type)); + $this->siteDataRows = array_map( + fn (array $row) => array_map( + fn(string $value) => json_decode($value, true), + $row + ), + $siteDataRows->getHash() + ); } /** - * @Then I expect the following PersistentResources to be exported: + * @When I have the following domain data rows: */ - public function iExpectTheFollowingPersistentResourcesToBeExported(TableNode $expectedResources): void + public function iHaveTheFollowingDomainDataRows(TableNode $domainDataRows): void { - $actualResources = []; - if (!$this->mockFilesystem->directoryExists('Resources')) { - Assert::fail('No PersistentResources have been exported (Directory "/Resources" does not exist)'); - } - /** @var FileAttributes $file */ - foreach ($this->mockFilesystem->listContents('Resources') as $file) { - $actualResources[] = ['Filename' => basename($file->path()), 'Contents' => $this->mockFilesystem->read($file->path())]; - } - Assert::assertSame($expectedResources->getHash(), $actualResources); + $this->domainDataRows = array_map(static function (array $row) { + return array_map( + fn(string $value) => json_decode($value, true), + $row + ); + }, $domainDataRows->getHash()); } /** - * @Then /^I expect no PersistentResources to be exported$/ + * @When I run the site migration */ - public function iExpectNoPersistentResourcesToBeExported(): void + public function iRunTheSiteMigration(): void { - Assert::assertFalse($this->mockFilesystem->directoryExists('Resources')); + $migration = new SitesExportProcessor($this->siteDataRows, $this->domainDataRows); + $this->runCrImportExportProcessors($migration); } - /** ---------------------------------- */ - /** - * @param TableNode $table - * @return array - * @throws JsonException - */ - private function parseJsonTable(TableNode $table): array - { - return array_map(static function (array $row) { - return array_map(static function (string $jsonValue) { - return json_decode($jsonValue, true, 512, JSON_THROW_ON_ERROR); - }, $row); - }, $table->getHash()); - } - protected function getContentRepositoryService( ContentRepositoryServiceFactoryInterface $factory ): ContentRepositoryServiceInterface { diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature index 6dd05a8d31c..f99d5d3c399 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Basic.feature @@ -22,8 +22,8 @@ Feature: Simple migrations without content dimensions | Identifier | Path | Node Type | Properties | | sites-node-id | /sites | unstructured | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature index 49b2b021c08..3dc37a50acc 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Errors.feature @@ -5,6 +5,7 @@ Feature: Exceptional cases during migrations Given using no content dimensions And using the following node types: """yaml + 'unstructured': {} 'Neos.Neos:Site': {} 'Some.Package:Homepage': superTypes: @@ -34,7 +35,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | | site-node-id | /sites/test-site | Some.Package:SomeOtherHomepage | {"language": ["en"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node aggregate with id "site-node-id" has a type of "Some.Package:SomeOtherHomepage" in content dimension [{"language":"en"}]. I was visited previously for content dimension [{"language":"de"}] with the type "Some.Package:Homepage". Node variants must not have different types """ @@ -94,7 +95,7 @@ Feature: Exceptional cases during migrations | sites | /sites | | | a | /sites/a | not json | And I run the event migration - Then I expect a MigrationError + Then I expect a migration exception Scenario: Invalid node properties (no JSON) When I have the following node data rows: @@ -102,7 +103,7 @@ Feature: Exceptional cases during migrations | sites | /sites | | | | a | /sites/a | not json | Some.Package:Homepage | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Failed to decode properties "not json" of node "a" (type: "Some.Package:Homepage"): Could not convert database value "not json" to Doctrine Type flow_json_array """ @@ -118,7 +119,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["ch"]} | | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["ch"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node "site-node-id" with dimension space point "{"language":"ch"}" was already visited before """ @@ -133,7 +134,7 @@ Feature: Exceptional cases during migrations | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | | site-node-id | /sites/test-site | Some.Package:Homepage | {"language": ["de"]} | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ Node "site-node-id" for dimension {"language":"de"} was already created previously """ @@ -144,7 +145,7 @@ Feature: Exceptional cases during migrations | sites-node-id | /sites | unstructured | | site-node-id | /sites/test-site | unstructured | And I run the event migration - Then I expect a MigrationError with the message + Then I expect a migration exception with the message """ - The site node "site-node-id" (type: "unstructured") must be of type "Neos.Neos:Site" + The site node "site-node-id" (type: "unstructured") must be of type "Neos.Neos:Site". Currently declared super types: "" """ diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature index ef3ec403e0e..bb212a59393 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Hidden.feature @@ -30,46 +30,46 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 1 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden" property false must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | Scenario: A node with active "hidden after" property, after a "hidden before" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 1989-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with active "hidden before" property, after a "hidden after" property must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1989-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1989-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden before" property and a "hidden after" property in future must not get disabled @@ -77,90 +77,90 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden after" property and a "hidden before" property in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden after" property in future and a "hidden before" property later in future must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2098-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | Scenario: A node with a "hidden before" property in future and a "hidden after" property later in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 2098-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2098-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a active "hidden before" property must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | Scenario: A node with a active "hidden after" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "1990-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden after" property in future must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "disableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | Scenario: A node with a "hidden before" property in future must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}, "enableAfterDateTime": {"type": "DateTimeImmutable", "value": "2099-01-01 10:10:10"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature index bb701dab822..59d82c5b8e8 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/HiddenWithoutTimeableNodeVisibility.feature @@ -23,35 +23,35 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 1 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | Scenario: A node with a "hidden" property false must not get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | Scenario: A node with active "hidden after" property, after a "hidden before" property must get disabled When I have the following node data rows: | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 1989-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -60,11 +60,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1989-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -73,11 +73,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -86,12 +86,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -100,11 +100,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2098-01-01 10:10:10 | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -113,12 +113,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | 2098-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -127,11 +127,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 1990-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -140,12 +140,12 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 1990-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -154,11 +154,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | 2099-01-01 10:10:10 | | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | @@ -167,11 +167,11 @@ Feature: Simple migrations without content dimensions for hidden state migration | Identifier | Path | Node Type | Properties | Hidden | Hidden after DateTime | Hidden before DateTime | | sites-node-id | /sites | unstructured | | 0 | | | | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | 0 | | 2099-01-01 10:10:10 | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | SubtreeWasTagged | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "tag": "disabled"} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | SubtreeWasTagged | {"nodeAggregateId": "site-node-id", "tag": "disabled"} | And I expect the following warnings to be logged | Skipped the migration of your "hiddenBeforeDateTime" and "hiddenAfterDateTime" properties as your target NodeTypes do not inherit "Neos.TimeableNodeVisibility:Timeable". Please install neos/timeable-node-visibility, if you want to migrate them. | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature index 2834b27a237..6fd919dbf24 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/RootNodeTypeMapping.feature @@ -24,7 +24,7 @@ Feature: Simple migrations without content dimensions but other root nodetype na | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | | test-root-node-id | /test | unstructured | | | test-node-id | /test/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" + And I run the event migration Then I expect the following errors to be logged | Failed to find parent node for node with id "test-root-node-id" and dimensions: []. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes. | | Failed to find parent node for node with id "test-node-id" and dimensions: []. Please ensure that the new content repository has a valid content dimension configuration. Also note that the old CR can sometimes have orphaned nodes. | @@ -37,10 +37,10 @@ Feature: Simple migrations without content dimensions but other root nodetype na | site-node-id | /sites/test-site | Some.Package:Homepage | {"text": "foo"} | | test-root-node-id | /test | unstructured | | | test-node-id | /test/test-site | Some.Package:Homepage | {"text": "foo"} | - And I run the event migration for content stream "cs-id" with rootNode mapping {"/sites": "Neos.Neos:Sites", "/test": "Neos.ContentRepository.LegacyNodeMigration:TestRoot"} + And I run the event migration with rootNode mapping {"/sites": "Neos.Neos:Sites", "/test": "Neos.ContentRepository.LegacyNodeMigration:TestRoot"} Then I expect the following events to be exported | Type | Payload | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | - | RootNodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "test-root-node-id", "nodeTypeName": "Neos.ContentRepository.LegacyNodeMigration:TestRoot", "nodeAggregateClassification": "root"} | - | NodeAggregateWithNodeWasCreated | {"contentStreamId": "cs-id", "nodeAggregateId": "test-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "test-root-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "sites-node-id", "nodeTypeName": "Neos.Neos:Sites", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "site-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "sites-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | + | RootNodeAggregateWithNodeWasCreated | {"nodeAggregateId": "test-root-node-id", "nodeTypeName": "Neos.ContentRepository.LegacyNodeMigration:TestRoot", "nodeAggregateClassification": "root"} | + | NodeAggregateWithNodeWasCreated | {"nodeAggregateId": "test-node-id", "nodeTypeName": "Some.Package:Homepage", "nodeName": "test-site", "parentNodeAggregateId": "test-root-node-id", "nodeAggregateClassification": "regular", "initialPropertyValues": {"text": {"type": "string", "value": "foo"}}} | diff --git a/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature new file mode 100644 index 00000000000..12a1e808c17 --- /dev/null +++ b/Neos.ContentRepository.LegacyNodeMigration/Tests/Behavior/Features/Sites.feature @@ -0,0 +1,46 @@ +@contentrepository +Feature: Simple migrations without content dimensions + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.Neos:Site': {} + 'Some.Package:Homepage': + superTypes: + 'Neos.Neos:Site': true + properties: + 'text': + type: string + defaultValue: 'My default text' + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + + Scenario: Site records without domains + When I have the following site data rows: + | persistence_object_identifier | name | nodename | siteresourcespackagekey | state | domains | primarydomain | + | "site1" | "Site 1" | "site_1_node" | "Site1.Package" | 1 | null | null | + | "site2" | "Site 2" | "site_2_node" | "Site2.Package" | 2 | null | null | + And I run the site migration + Then I expect the following sites to be exported + | name | nodeName | siteResourcesPackageKey | online | domains | + | "Site 1" | "site_1_node" | "Site1.Package" | true | [] | + | "Site 2" | "site_2_node" | "Site2.Package" | false | [] | + + Scenario: Site records with domains + When I have the following site data rows: + | persistence_object_identifier | name | nodename | siteresourcespackagekey | state | domains | primarydomain | + | "site1" | "Site 1" | "site_1_node" | "Site1.Package" | 1 | null | "domain2" | + | "site2" | "Site 2" | "site_2_node" | "Site2.Package" | 1 | null | null | + When I have the following domain data rows: + | persistence_object_identifier | hostname | scheme | port | active | site | + | "domain1" | "domain_1.tld" | "https" | 123 | true | "site1" | + | "domain2" | "domain_2.tld" | "http" | null | true | "site1" | + | "domain3" | "domain_3.tld" | null | null | true | "site2" | + | "domain4" | "domain_4.tld" | null | null | false | "site2" | + And I run the site migration + Then I expect the following sites to be exported + | name | nodeName | siteResourcesPackageKey | online | domains | + | "Site 1" | "site_1_node" | "Site1.Package" | true | [{"hostname": "domain_1.tld", "scheme": "https", "port": 123, "active": true, "primary": false},{"hostname": "domain_2.tld", "scheme": "http", "port": null, "active": true, "primary": true}] | + | "Site 2" | "site_2_node" | "Site2.Package" | true | [{"hostname": "domain_3.tld", "scheme": null, "port": null, "active": true, "primary": false},{"hostname": "domain_4.tld", "scheme": null, "port": null, "active": false, "primary": false}] | diff --git a/Neos.ContentRepository.LegacyNodeMigration/composer.json b/Neos.ContentRepository.LegacyNodeMigration/composer.json index acb3ddbb2d1..e6a0f1c067f 100644 --- a/Neos.ContentRepository.LegacyNodeMigration/composer.json +++ b/Neos.ContentRepository.LegacyNodeMigration/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": ">=8.2", + "neos/neos": "self.version", "neos/contentrepository-core": "self.version", "neos/contentrepository-export": "self.version", "league/flysystem": "^3" diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php index 459398dfeb0..38149fcbe74 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/CrCommandController.php @@ -9,7 +9,7 @@ use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; -use Neos\ContentRepositoryRegistry\Service\ProjectionReplayServiceFactory; +use Neos\ContentRepositoryRegistry\Service\ProjectionServiceFactory; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\EventStore\StatusType; use Neos\Flow\Cli\CommandController; @@ -23,7 +23,7 @@ final class CrCommandController extends CommandController public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly ProjectionReplayServiceFactory $projectionServiceFactory, + private readonly ProjectionServiceFactory $projectionServiceFactory, ) { parent::__construct(); } diff --git a/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php new file mode 100644 index 00000000000..69587bb4806 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionCatchupProcessor.php @@ -0,0 +1,25 @@ +projectionservice->catchupAllProjections(CatchUpOptions::create()); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php new file mode 100644 index 00000000000..7a5a1f9013f --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Processors/ProjectionResetProcessor.php @@ -0,0 +1,24 @@ +projectionService->resetAllProjections(); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php similarity index 83% rename from Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php rename to Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php index 00a946be01a..06f11984d74 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php @@ -14,13 +14,12 @@ use Neos\EventStore\Model\EventStream\VirtualStreamName; /** - * Content Repository service to perform Projection replays + * Content Repository service to perform Projection operations * - * @internal this is currently only used by the {@see CrCommandController} + * @internal */ -final class ProjectionReplayService implements ContentRepositoryServiceInterface +final class ProjectionService implements ContentRepositoryServiceInterface { - public function __construct( private readonly Projections $projections, private readonly ContentRepository $contentRepository, @@ -53,6 +52,22 @@ public function resetAllProjections(): void } } + public function catchupProjection(string $projectionAliasOrClassName, CatchUpOptions $options): void + { + $projectionClassName = $this->resolveProjectionClassName($projectionAliasOrClassName); + $this->contentRepository->catchUpProjection($projectionClassName, $options); + } + + public function catchupAllProjections(CatchUpOptions $options, ?\Closure $progressCallback = null): void + { + foreach ($this->projectionClassNamesAndAliases() as $classNamesAndAlias) { + if ($progressCallback) { + $progressCallback($classNamesAndAlias['alias']); + } + $this->contentRepository->catchUpProjection($classNamesAndAlias['className'], $options); + } + } + public function highestSequenceNumber(): SequenceNumber { foreach ($this->eventStore->load(VirtualStreamName::all())->backwards()->limit(1) as $eventEnvelope) { diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php similarity index 70% rename from Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php rename to Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php index 0f4bc5f7a05..92114d47f1a 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayServiceFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/ProjectionServiceFactory.php @@ -6,22 +6,20 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; -use Neos\ContentRepositoryRegistry\Command\CrCommandController; use Neos\Flow\Annotations as Flow; /** - * Factory for the {@see ProjectionReplayService} + * Factory for the {@see ProjectionService} * - * @implements ContentRepositoryServiceFactoryInterface - * @internal this is currently only used by the {@see CrCommandController} + * @implements ContentRepositoryServiceFactoryInterface + * @internal */ #[Flow\Scope("singleton")] -final class ProjectionReplayServiceFactory implements ContentRepositoryServiceFactoryInterface +final class ProjectionServiceFactory implements ContentRepositoryServiceFactoryInterface { - public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface { - return new ProjectionReplayService( + return new ProjectionService( $serviceFactoryDependencies->projectionsAndCatchUpHooks->projections, $serviceFactoryDependencies->contentRepository, $serviceFactoryDependencies->eventStore, diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php deleted file mode 100644 index d94cbeac1c9..00000000000 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ /dev/null @@ -1,175 +0,0 @@ -contentRepositoryRegistry->get($contentRepositoryId); - - Files::createDirectoryRecursively($path); - $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); - - $liveWorkspace = $contentRepositoryInstance->findWorkspaceByName(WorkspaceName::forLive()); - if ($liveWorkspace === null) { - throw new \RuntimeException('Failed to find live workspace', 1716652280); - } - - $exportService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ExportServiceFactory( - $filesystem, - $liveWorkspace, - $this->assetRepository, - $this->assetUsageService, - ) - ); - assert($exportService instanceof ExportService); - $exportService->runAllProcessors($this->outputLine(...), $verbose); - $this->outputLine('Done'); - } - - /** - * Import the events from the path into the specified content repository - * - * @param string $path The path of the stored events like resource://Neos.Demo/Private/Content - * @param string $contentRepository The content repository identifier - * @param bool $verbose If set, all notices will be rendered - * @throws \Exception - */ - public function importCommand(string $path, string $contentRepository = 'default', bool $verbose = false): void - { - $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); - - $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $contentStreamIdentifier = ContentStreamId::create(); - - $importService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ImportServiceFactory( - $filesystem, - $contentStreamIdentifier, - $this->assetRepository, - $this->resourceRepository, - $this->resourceManager, - $this->persistenceManager, - ) - ); - assert($importService instanceof ImportService); - try { - $importService->runAllProcessors($this->outputLine(...), $verbose); - } catch (\RuntimeException $exception) { - $this->outputLine('Error: ' . $exception->getMessage() . ''); - $this->outputLine('Import stopped.'); - return; - } - - $this->outputLine('Replaying projections'); - - $projectionService = $this->contentRepositoryRegistry->buildService($contentRepositoryId, $this->projectionReplayServiceFactory); - $projectionService->replayAllProjections(CatchUpOptions::create()); - - $this->outputLine('Assigning live workspace role'); - // set the live-workspace title to (implicitly) create the metadata record for this workspace - $this->workspaceService->setWorkspaceTitle($contentRepositoryId, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace')); - $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); - - $this->outputLine('Done'); - } - - /** - * This will completely prune the data of the specified content repository. - * - * @param string $contentRepository Name of the content repository where the data should be pruned from. - * @param bool $force Prune the cr without confirmation. This cannot be reverted! - * @return void - */ - public function pruneCommand(string $contentRepository = 'default', bool $force = false): void - { - if (!$force && !$this->output->askConfirmation(sprintf('> This will prune your content repository "%s". Are you sure to proceed? (y/n) ', $contentRepository), false)) { - $this->outputLine('Abort.'); - return; - } - - $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - - $contentStreamPruner = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new ContentStreamPrunerFactory() - ); - - $workspaceMaintenanceService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new WorkspaceMaintenanceServiceFactory() - ); - - $projectionService = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - $this->projectionServiceFactory - ); - - // remove the workspace metadata and role assignments for this cr - $this->workspaceService->pruneRoleAssignments($contentRepositoryId); - $this->workspaceService->pruneWorkspaceMetadata($contentRepositoryId); - - // reset the events table - $contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); - - // reset the projections state - $projectionService->resetAllProjections(); - - $this->outputLine('Done.'); - } -} diff --git a/Neos.Neos/Classes/Command/SiteCommandController.php b/Neos.Neos/Classes/Command/SiteCommandController.php index c3763cf7252..24037d9b95e 100644 --- a/Neos.Neos/Classes/Command/SiteCommandController.php +++ b/Neos.Neos/Classes/Command/SiteCommandController.php @@ -14,8 +14,11 @@ namespace Neos\Neos\Command; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\NodeNameIsAlreadyCovered; use Neos\ContentRepository\Core\SharedModel\Exception\NodeTypeNotFound; +use Neos\ContentRepository\Export\Severity; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; @@ -24,9 +27,15 @@ use Neos\Neos\Domain\Exception\SiteNodeNameIsAlreadyInUseByAnotherSite; use Neos\Neos\Domain\Exception\SiteNodeTypeIsInvalid; use Neos\Neos\Domain\Model\Site; +use Neos\Neos\Domain\Repository\DomainRepository; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Neos\Domain\Service\SiteExportService; +use Neos\Neos\Domain\Service\SiteImportService; +use Neos\Neos\Domain\Service\SitePruningService; use Neos\Neos\Domain\Service\SiteService; +use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Utility\Files; /** * The Site Command Controller @@ -41,6 +50,12 @@ class SiteCommandController extends CommandController */ protected $siteRepository; + /** + * @Flow\Inject + * @var DomainRepository + */ + protected $domainRepository; + /** * @Flow\Inject * @var SiteService @@ -53,12 +68,42 @@ class SiteCommandController extends CommandController */ protected $packageManager; + /** + * @Flow\Inject + * @var ContentRepositoryRegistry + */ + protected $contentRepositoryRegistry; + /** * @Flow\Inject * @var PersistenceManagerInterface */ protected $persistenceManager; + /** + * @Flow\Inject + * @var SiteImportService + */ + protected $siteImportService; + + /** + * @Flow\Inject + * @var SiteExportService + */ + protected $siteExportService; + + /** + * @Flow\Inject + * @var SitePruningService + */ + protected $sitePruningService; + + /** + * @Flow\Inject + * @var WorkspaceService + */ + protected $workspaceService; + /** * Create a new site * @@ -111,31 +156,94 @@ public function createCommand($name, $packageKey, $nodeType, $nodeName = null, $ } /** - * Remove site with content and related data (with globbing) + * Import sites + * + * This command allows importing sites from the given path/package. The format must + * be identical to that produced by the exportAll command. * - * In the future we need some more sophisticated cleanup. + * If a path is specified, this command expects the corresponding directory to contain the exported files * - * @param string $siteNode Name for site root nodes to clear only content of this sites (globbing is supported) + * If a package key is specified, this command expects the export files to be located in the private resources + * directory of the given package (Resources/Private/Content). + * + * **Note that the live workspace has to be empty prior to importing.** + * + * @param string|null $packageKey Package key specifying the package containing the sites content + * @param string|null $path relative or absolute path and filename to the export files * @return void */ - public function pruneCommand($siteNode) + public function importAllCommand(string $packageKey = null, string $path = null, string $contentRepository = 'default', bool $verbose = false): void { - $sites = $this->findSitesByNodeNamePattern($siteNode); - if (empty($sites)) { - $this->outputLine('No Site found for pattern "%s".', [$siteNode]); - // Help the user a little about what he needs to provide as a parameter here - $this->outputLine('To find out which sites you have, use the site:list command.'); - $this->outputLine('The site:prune command expects the "Node name" from the site list as a parameter.'); - $this->outputLine('If you want to delete all sites, you can run site:prune \'*\'.'); - $this->quit(1); - } - foreach ($sites as $site) { - $this->siteService->pruneSite($site); - $this->outputLine( - 'Site with root "%s" matched pattern "%s" and has been removed.', - [$site->getNodeName(), $siteNode] - ); + // TODO check if this warning is still necessary with Neos 9 + // Since this command uses a lot of memory when large sites are imported, we warn the user to watch for + // the confirmation of a successful import. + $this->outputLine('This command can use a lot of memory when importing sites with many resources.'); + $this->outputLine('If the import is successful, you will see a message saying "Import finished".'); + $this->outputLine('If you do not see this message, the import failed, most likely due to insufficient memory.'); + $this->outputLine('Increase the memory_limit configuration parameter of your php CLI to attempt to fix this.'); + $this->outputLine('Starting import...'); + $this->outputLine('---'); + + $path = $this->determineTargetPath($packageKey, $path); + + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + + $this->siteImportService->importFromPath( + $contentRepositoryId, + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); + + $this->outputLine('Import finished.'); + } + + /** + * Export sites + * + * This command exports all sites of the content repository. + * + * If a path is specified, this command creates the directory if needed and exports into that. + * + * If a package key is specified, this command exports to the private resources + * directory of the given package (Resources/Private/Content). + * + * @param string|null $packageKey Package key specifying the package containing the sites content + * @param string|null $path relative or absolute path and filename to the export files + * @return void + */ + public function exportAllCommand(string $packageKey = null, string $path = null, string $contentRepository = 'default', bool $verbose = false): void + { + $path = $this->determineTargetPath($packageKey, $path); + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + Files::createDirectoryRecursively($path); + $this->siteExportService->exportToPath( + $contentRepositoryId, + $path, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); + } + + /** + * This will completely prune the data of the specified content repository and remove all site-records. + * + * @param bool $force Prune the cr without confirmation. This cannot be reverted! + * @return void + */ + public function pruneAllCommand(string $contentRepository = 'default', bool $force = false, bool $verbose = false): void + { + if (!$force && !$this->output->askConfirmation(sprintf('> This will prune your content repository "%s" and all its attached sites. Are you sure to proceed? (y/n) ', $contentRepository), false)) { + $this->outputLine('Abort.'); + return; } + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + + $this->sitePruningService->pruneAll( + $contentRepositoryId, + $this->createOnProcessorClosure(), + $this->createOnMessageClosure($verbose) + ); } /** @@ -220,4 +328,51 @@ function (Site $site) use ($siteNodePattern) { } ); } + + protected function determineTargetPath(?string $packageKey, ?string $path): string + { + $exceedingArguments = $this->request->getExceedingArguments(); + if (isset($exceedingArguments[0]) && $packageKey === null && $path === null) { + if (file_exists($exceedingArguments[0])) { + $path = $exceedingArguments[0]; + } elseif ($this->packageManager->isPackageAvailable($exceedingArguments[0])) { + $packageKey = $exceedingArguments[0]; + } + } + if ($packageKey === null && $path === null) { + $this->outputLine('You have to specify either --package-key or --path'); + $this->quit(1); + } + if ($path === null) { + $package = $this->packageManager->getPackage($packageKey); + $path = Files::concatenatePaths([$package->getPackagePath(), 'Resources/Private/Content']); + } + if (str_starts_with($path, 'resource://')) { + $this->outputLine('Resource paths are not allowed, please use --package-key instead or a real path.'); + $this->quit(1); + } + return $path; + } + + protected function createOnProcessorClosure(): \Closure + { + $onProcessor = function (string $processorLabel) { + $this->outputLine('%s...', [$processorLabel]); + }; + return $onProcessor; + } + + protected function createOnMessageClosure(bool $verbose): \Closure + { + return function (Severity $severity, string $message) use ($verbose) { + if (!$verbose && $severity === Severity::NOTICE) { + return; + } + $this->outputLine(match ($severity) { + Severity::NOTICE => $message, + Severity::WARNING => sprintf('Warning: %s', $message), + Severity::ERROR => sprintf('Error: %s', $message), + }); + }; + } } diff --git a/Neos.Neos/Classes/Domain/Export/SiteExportProcessor.php b/Neos.Neos/Classes/Domain/Export/SiteExportProcessor.php new file mode 100644 index 00000000000..a0c4beb5b53 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Export/SiteExportProcessor.php @@ -0,0 +1,107 @@ +getSiteData(); + $context->files->write( + 'sites.json', + json_encode($sites, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + } + + /** + * @return SiteShape[] + */ + private function getSiteData(): array + { + $sites = $this->findSites($this->workspaceName); + $siteData = []; + foreach ($sites as $site) { + $siteData[] = [ + "name" => $site->getName(), + "nodeName" => $site->getNodeName()->value, + "siteResourcesPackageKey" => $site->getSiteResourcesPackageKey(), + "online" => $site->isOnline(), + "domains" => array_map( + fn(Domain $domain) => [ + 'hostname' => $domain->getHostname(), + 'scheme' => $domain->getScheme(), + 'port' => $domain->getPort(), + 'active' => $domain->getActive(), + 'primary' => $domain === $site->getPrimaryDomain(fallbackToActive: false), + ], + $site->getDomains()->toArray() + ) + ]; + } + + return $siteData; + } + + /** + * @param WorkspaceName $workspaceName + * @return Site[] + */ + private function findSites(WorkspaceName $workspaceName): array + { + $contentGraph = $this->contentRepository->getContentGraph($workspaceName); + $sitesNodeAggregate = $contentGraph->findRootNodeAggregateByType(NodeTypeNameFactory::forSites()); + if ($sitesNodeAggregate === null) { + return []; + } + + $siteNodeAggregates = $contentGraph->findChildNodeAggregates($sitesNodeAggregate->nodeAggregateId); + $sites = []; + foreach ($siteNodeAggregates as $siteNodeAggregate) { + $siteNodeName = $siteNodeAggregate->nodeName?->value; + if ($siteNodeName === null) { + continue; + } + $site = $this->siteRepository->findOneByNodeName($siteNodeName); + if ($site === null) { + continue; + } + $sites[] = $site; + } + return $sites; + } +} diff --git a/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php new file mode 100644 index 00000000000..788dad93734 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php @@ -0,0 +1,50 @@ +dispatch(Severity::NOTICE, 'Creating live workspace'); + $liveWorkspace = $this->contentRepository->findWorkspaceByName(WorkspaceName::forLive()); + if ($liveWorkspace !== null) { + $context->dispatch(Severity::NOTICE, 'Workspace already exists, skipping'); + return; + } + $this->workspaceService->createRootWorkspace($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace'), WorkspaceDescription::fromString('')); + $this->workspaceService->assignWorkspaceRole($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + } +} diff --git a/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php b/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php new file mode 100644 index 00000000000..9c785a8a6d1 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Import/SiteCreationProcessor.php @@ -0,0 +1,135 @@ +files->has('sites.json')) { + $sitesJson = $context->files->read('sites.json'); + try { + $sites = json_decode($sitesJson, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode sites.json: {$e->getMessage()}", 1729506117, $e); + } + } else { + $context->dispatch(Severity::WARNING, 'Deprecated legacy handling: No "sites.json" in export found, attempting to extract neos sites from the events. Please update the export soonish.'); + $sites = self::extractSitesFromEventStream($context); + } + + /** @var SiteShape $site */ + foreach ($sites as $site) { + $context->dispatch(Severity::NOTICE, sprintf('Creating site "%s"', $site['name'])); + + $siteNodeName = NodeName::fromString($site['nodeName']); + if ($this->siteRepository->findOneByNodeName($siteNodeName->value)) { + $context->dispatch(Severity::NOTICE, sprintf('Site for node name "%s" already exists, skipping', $siteNodeName->value)); + continue; + } + $siteInstance = new Site($siteNodeName->value); + $siteInstance->setSiteResourcesPackageKey($site['siteResourcesPackageKey']); + $siteInstance->setState($site['online'] ? Site::STATE_ONLINE : Site::STATE_OFFLINE); + $siteInstance->setName($site['name']); + $this->siteRepository->add($siteInstance); + $this->persistenceManager->persistAll(); + foreach ($site['domains'] as $domain) { + $domainInstance = $this->domainRepository->findByHostname($domain['hostname'])->getFirst(); + if ($domainInstance instanceof Domain) { + $context->dispatch(Severity::NOTICE, sprintf('Domain "%s" already exists. Adding it to site "%s".', $domain['hostname'], $site['name'])); + } else { + $domainInstance = new Domain(); + $domainInstance->setSite($siteInstance); + $domainInstance->setHostname($domain['hostname']); + $domainInstance->setPort($domain['port'] ?? null); + $domainInstance->setScheme($domain['scheme'] ?? null); + $domainInstance->setActive($domain['active'] ?? false); + $this->domainRepository->add($domainInstance); + } + if ($domain['primary'] ?? false) { + $siteInstance->setPrimaryDomain($domainInstance); + $this->siteRepository->update($siteInstance); + } + $this->persistenceManager->persistAll(); + } + } + } + + /** + * @deprecated with Neos 9 Beta 15 please make sure that exports contain `sites.json` + * @return array + */ + private static function extractSitesFromEventStream(ProcessingContext $context): array + { + $eventFileResource = $context->files->readStream('events.jsonl'); + $siteRooNodeAggregateId = null; + $sites = []; + while (($line = fgets($eventFileResource)) !== false) { + $event = ExportedEvent::fromJson($line); + if ($event->type === 'RootNodeAggregateWithNodeWasCreated' && $event->payload['nodeTypeName'] === NodeTypeNameFactory::NAME_SITES) { + $siteRooNodeAggregateId = $event->payload['nodeAggregateId']; + continue; + } + if ($event->type === 'NodeAggregateWithNodeWasCreated' && $event->payload['parentNodeAggregateId'] === $siteRooNodeAggregateId) { + if (!isset($event->payload['nodeName'])) { + throw new \RuntimeException(sprintf('The nodeName of the site node "%s" must not be empty', $event->payload['nodeAggregateId']), 1731236316); + } + $sites[] = [ + 'siteResourcesPackageKey' => self::extractPackageKeyFromNodeTypeName($event->payload['nodeTypeName']), + 'name' => $event->payload['initialPropertyValues']['title']['value'] ?? $event->payload['nodeTypeName'], + 'nodeTypeName' => $event->payload['nodeTypeName'], + 'nodeName' => $event->payload['nodeName'], + 'domains' => [], + 'online' => true + ]; + } + }; + return $sites; + } + + private static function extractPackageKeyFromNodeTypeName(string $nodeTypeName): string + { + if (preg_match('/^([^:])+/', $nodeTypeName, $matches) !== 1) { + throw new \RuntimeException("Failed to extract package key from '$nodeTypeName'.", 1729505701); + } + return $matches[0]; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/Site.php b/Neos.Neos/Classes/Domain/Model/Site.php index d6937a6655d..98d3cf8f7c5 100644 --- a/Neos.Neos/Classes/Domain/Model/Site.php +++ b/Neos.Neos/Classes/Domain/Model/Site.php @@ -63,7 +63,10 @@ class Site * Node name of this site in the content repository. * * The first level of nodes of a site can be reached via a path like - * "/Sites/MySite/" where "MySite" is the nodeName. + * "//my-site" where "my-site" is the nodeName. + * + * TODO use node aggregate identifier instead of node name + * see https://github.com/neos/neos-development-collection/issues/4470 * * @var string * @Flow\Identity @@ -328,11 +331,15 @@ public function setPrimaryDomain(Domain $domain = null) /** * Returns the primary domain, if one has been defined. * + * @param boolean $fallbackToActive if true falls back to the first active domain instead returning null if no primary domain was explicitly set * @return ?Domain The primary domain or NULL * @api */ - public function getPrimaryDomain(): ?Domain + public function getPrimaryDomain(bool $fallbackToActive = true): ?Domain { + if (!$fallbackToActive) { + return $this->primaryDomain; + } return $this->primaryDomain instanceof Domain && $this->primaryDomain->getActive() ? $this->primaryDomain : $this->getFirstActiveDomain(); diff --git a/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php new file mode 100644 index 00000000000..0b94195c9d1 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/ContentRepositoryPruningProcessor.php @@ -0,0 +1,35 @@ +contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); + } +} diff --git a/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php new file mode 100644 index 00000000000..063f7e6f4de --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php @@ -0,0 +1,38 @@ +workspaceService->pruneRoleAssignments($this->contentRepositoryId); + $this->workspaceService->pruneWorkspaceMetadata($this->contentRepositoryId); + } +} diff --git a/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php new file mode 100644 index 00000000000..d393d13f828 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Pruning/SitePruningProcessor.php @@ -0,0 +1,93 @@ +contentRepository->getContentGraph($this->workspaceName); + } catch (WorkspaceDoesNotExist) { + $context->dispatch(Severity::NOTICE, sprintf('Could not find any matching sites, because the workspace "%s" does not exist.', $this->workspaceName->value)); + return; + } + $sites = $this->findAllSites($contentGraph); + foreach ($sites as $site) { + $domains = $site->getDomains(); + if ($site->getPrimaryDomain() !== null) { + $site->setPrimaryDomain(null); + $this->siteRepository->update($site); + } + foreach ($domains as $domain) { + $this->domainRepository->remove($domain); + } + $this->persistenceManager->persistAll(); + $this->siteRepository->remove($site); + $this->persistenceManager->persistAll(); + } + } + + /** + * @return Site[] + */ + protected function findAllSites(ContentGraphInterface $contentGraph): array + { + $sitesNodeAggregate = $contentGraph->findRootNodeAggregateByType(NodeTypeNameFactory::forSites()); + if ($sitesNodeAggregate === null) { + return []; + } + + $siteNodeAggregates = $contentGraph->findChildNodeAggregates($sitesNodeAggregate->nodeAggregateId); + $sites = []; + foreach ($siteNodeAggregates as $siteNodeAggregate) { + $siteNodeName = $siteNodeAggregate->nodeName?->value; + if ($siteNodeName === null) { + continue; + } + $site = $this->siteRepository->findOneByNodeName($siteNodeName); + if ($site === null) { + continue; + } + $sites[] = $site; + } + return $sites; + } +} diff --git a/Neos.Neos/Classes/Domain/Repository/DomainRepository.php b/Neos.Neos/Classes/Domain/Repository/DomainRepository.php index a86fe3eac93..6b1cb089ef2 100644 --- a/Neos.Neos/Classes/Domain/Repository/DomainRepository.php +++ b/Neos.Neos/Classes/Domain/Repository/DomainRepository.php @@ -84,6 +84,7 @@ public function findByHost($hostname, $onlyActive = false) public function findOneByHost($hostname, $onlyActive = false): ?Domain { $allMatchingDomains = $this->findByHost($hostname, $onlyActive); + // Fixme, requesting `onedimension.localhost` if domain `localhost` exists in the set would return the latter because of `getSortedMatches` return count($allMatchingDomains) > 0 ? $allMatchingDomains[0] : null; } diff --git a/Neos.Neos/Classes/Domain/Service/SiteExportService.php b/Neos.Neos/Classes/Domain/Service/SiteExportService.php new file mode 100644 index 00000000000..ba305374607 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SiteExportService.php @@ -0,0 +1,88 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $liveWorkspace = $contentRepository->findWorkspaceByName(WorkspaceName::forLive()); + if ($liveWorkspace === null) { + throw new \RuntimeException('Failed to find live workspace', 1716652280); + } + + $processors = Processors::fromArray([ + 'Exporting events' => $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new EventExportProcessorFactory( + $liveWorkspace->currentContentStreamId + ) + ), + 'Exporting assets' => new AssetExportProcessor( + $contentRepositoryId, + $this->assetRepository, + $liveWorkspace, + $this->assetUsageService + ), + 'Export sites' => new SiteExportProcessor( + $contentRepository, + $liveWorkspace->workspaceName, + $this->siteRepository + ), + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteImportService.php b/Neos.Neos/Classes/Domain/Service/SiteImportService.php new file mode 100644 index 00000000000..741424a02e2 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SiteImportService.php @@ -0,0 +1,114 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $this->requireDataBaseSchemaToBeSetup(); + $this->requireContentRepositoryToBeSetup($contentRepository); + + $filesystem = new Filesystem(new LocalFilesystemAdapter($path)); + $context = new ProcessingContext($filesystem, $onMessage); + + $processors = Processors::fromArray([ + 'Create Live workspace' => new LiveWorkspaceCreationProcessor($contentRepository, $this->workspaceService), + 'Create Neos sites' => new SiteCreationProcessor($this->siteRepository, $this->domainRepository, $this->persistenceManager), + 'Import events' => $this->contentRepositoryRegistry->buildService($contentRepositoryId, new EventStoreImportProcessorFactory(WorkspaceName::forLive(), keepEventIds: true)), + 'Import assets' => new AssetRepositoryImportProcessor($this->assetRepository, $this->resourceRepository, $this->resourceManager, $this->persistenceManager), + 'Catchup all projections' => new ProjectionCatchupProcessor($this->contentRepositoryRegistry->buildService($contentRepositoryId, new ProjectionServiceFactory())), + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } + + private function requireContentRepositoryToBeSetup(ContentRepository $contentRepository): void + { + $status = $contentRepository->status(); + if (!$status->isOk()) { + throw new \RuntimeException(sprintf('Content repository %s is not setup correctly, please run `./flow cr:setup`', $contentRepository->id->value)); + } + } + + private function requireDataBaseSchemaToBeSetup(): void + { + try { + [ + 'new' => $_newMigrationCount, + 'executed' => $executedMigrationCount, + 'available' => $availableMigrationCount + ] = $this->doctrineService->getMigrationStatus(); + } catch (DBALException | \PDOException) { + throw new \RuntimeException('Not database connected. Please check your database connection settings or run `./flow setup` for further information.', 1684075689386); + } + + if ($executedMigrationCount === 0 && $availableMigrationCount > 0) { + throw new \RuntimeException('No doctrine migrations have been executed. Please run `./flow doctrine:migrate`'); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SitePruningService.php b/Neos.Neos/Classes/Domain/Service/SitePruningService.php new file mode 100644 index 00000000000..71ed7c18763 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/SitePruningService.php @@ -0,0 +1,88 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $processors = Processors::fromArray([ + 'Remove site nodes' => new SitePruningProcessor( + $contentRepository, + WorkspaceName::forLive(), + $this->siteRepository, + $this->domainRepository, + $this->persistenceManager + ), + 'Prune content repository' => new ContentRepositoryPruningProcessor( + $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new ContentStreamPrunerFactory() + ) + ), + 'Prune roles and metadata' => new RoleAndMetadataPruningProcessor($contentRepositoryId, $this->workspaceService), + 'Reset all projections' => new ProjectionResetProcessor( + $this->contentRepositoryRegistry->buildService( + $contentRepositoryId, + new ProjectionServiceFactory() + ) + ) + ]); + + foreach ($processors as $processorLabel => $processor) { + ($onProcessor)($processorLabel); + $processor->run($context); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Service/SiteService.php b/Neos.Neos/Classes/Domain/Service/SiteService.php index 6041fceb202..42f7bea37e6 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteService.php +++ b/Neos.Neos/Classes/Domain/Service/SiteService.php @@ -167,13 +167,12 @@ public function createSite( ?string $nodeName = null, bool $inactive = false ): Site { - $siteNodeName = NodeName::fromString($nodeName ?: $siteName); + $siteNodeName = NodeName::transliterateFromString($nodeName ?: $siteName); if ($this->siteRepository->findOneByNodeName($siteNodeName->value)) { throw SiteNodeNameIsAlreadyInUseByAnotherSite::butWasAttemptedToBeClaimed($siteNodeName); } - // @todo use node aggregate identifier instead of node name $site = new Site($siteNodeName->value); $site->setSiteResourcesPackageKey($packageKey); $site->setState($inactive ? Site::STATE_OFFLINE : Site::STATE_ONLINE); diff --git a/README.md b/README.md index b7cb5af08a2..4183dd4275c 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,16 @@ You can chose from one of the following options: #### Migrating an existing (Neos < 9.0) Site ``` bash -# WORKAROUND: for now, you still need to create a site (which must match the root node name) -# !! in the future, you would want to import *INTO* a given site (and replace its root node) -./flow site:create neosdemo Neos.Demo Neos.Demo:Document.Homepage - -# the following config points to a Neos 8.0 database (adjust to your needs), created by -# the legacy "./flow site:import Neos.Demo" command. -./flow cr:migrateLegacyData --config '{"dbal": {"dbname": "neos80"}, "resourcesPath": "/path/to/neos-8.0/Data/Persistent/Resources"}' +# the following config points to a Neos 8.0 database (adjust to your needs) +./flow site:exportLegacyData --path ./migratedContent --config '{"dbal": {"dbname": "neos80"}, "resourcesPath": "/path/to/neos-8.0/Data/Persistent/Resources"}' +# import the migrated data +./flow site:importAll --path ./migratedContent ``` #### Importing an existing (Neos >= 9.0) Site from an Export ``` bash -# import the event stream from the Neos.Demo package -./flow cr:import Packages/Sites/Neos.Demo/Resources/Private/Content +./flow site:importAll --package-key Neos.Demo ``` ### Running Neos diff --git a/composer.json b/composer.json index 4425532f575..5c059f778c1 100644 --- a/composer.json +++ b/composer.json @@ -96,7 +96,7 @@ "scripts": { "lint:phpcs": "../../bin/phpcs --colors", "lint:phpcs:fix": "../../bin/phpcbf --colors", - "lint:phpstan": "../../bin/phpstan analyse", + "lint:phpstan": "../../bin/phpstan analyse -v", "lint:phpstan-generate-baseline": "../../bin/phpstan analyse --generate-baseline", "lint:distributionintegrity": "[ -d 'Neos.ContentRepository' ] && { echo 'Package Neos.ContentRepository should not exist.' 1>&2; exit 1; } || exit 0;", "lint": [ @@ -293,7 +293,7 @@ }, "require-dev": { "roave/security-advisories": "dev-latest", - "phpstan/phpstan": "^1.8", + "phpstan/phpstan": "^1.11", "squizlabs/php_codesniffer": "^3.6", "phpunit/phpunit": "^9.0", "neos/behat": "*", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ebbd2ba29a9..12d96178fbb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3,7 +3,7 @@ parameters: - message: "#^The internal method \"Neos\\\\ContentRepository\\\\Core\\\\Projection\\\\Projections\\:\\:getClassNames\" is called\\.$#" count: 1 - path: Neos.ContentRepositoryRegistry/Classes/Service/ProjectionReplayService.php + path: Neos.ContentRepositoryRegistry/Classes/Service/ProjectionService.php - message: "#^Method Neos\\\\Neos\\\\Controller\\\\Backend\\\\MenuHelper\\:\\:buildModuleList\\(\\) return type has no value type specified in iterable type array\\.$#"