From e6c7a9238d0f43fe91ef17a8e6b972bac4090cbc Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Fri, 3 May 2024 09:35:06 +0200 Subject: [PATCH 01/17] Fix bundle --- .../Application/Session/SessionManager.php | 2 +- .../Command/MigratePhpcrCommand.php | 59 +++++++++++++++++++ Resources/config/command.xml | 2 +- Resources/config/session.xml | 2 +- ...Bundle.php => SuluPhpcrMigrationBundle.php | 2 +- .../Command/MigratePhpcrCommand.php | 35 ----------- 6 files changed, 63 insertions(+), 39 deletions(-) rename {src => PhpcrMigration}/Application/Session/SessionManager.php (96%) create mode 100644 PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php rename src/SuluPhpcrMigrationBundle.php => SuluPhpcrMigrationBundle.php (99%) delete mode 100644 src/UserInterface/Command/MigratePhpcrCommand.php diff --git a/src/Application/Session/SessionManager.php b/PhpcrMigration/Application/Session/SessionManager.php similarity index 96% rename from src/Application/Session/SessionManager.php rename to PhpcrMigration/Application/Session/SessionManager.php index 7ee6a1a..5500b61 100644 --- a/src/Application/Session/SessionManager.php +++ b/PhpcrMigration/Application/Session/SessionManager.php @@ -9,7 +9,7 @@ * with this source code in the file LICENSE. */ -namespace Sulu\Bundle\PhpcrMigrationBundle\Application\Session; +namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Session; use Doctrine\DBAL\Connection; use Jackalope\RepositoryFactoryDoctrineDBAL; diff --git a/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php new file mode 100644 index 0000000..ed77c7a --- /dev/null +++ b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php @@ -0,0 +1,59 @@ +sessionManager->getDefaultSession(); + $liveSession = $this->sessionManager->getLiveSession(); + + $queryManager = $session->getWorkspace()->getQueryManager(); + $sql = + sprintf( + 'SELECT * FROM [nt:unstructured] as document WHERE [jcr:mixinTypes] = "sulu:page" AND (isdescendantnode(document, "/cmf/%s/contents") OR issamenode(document, "/cmf/%s/contents"))', + 'sulu', + 'sulu' + );; + $query = $queryManager->createQuery($sql, 'JCR-SQL2'); + $result = $query->execute(); + + $documents = []; + foreach ($result->getNodes() as $node) { + $document = []; + + foreach ($node->getProperties() as $property) { + $name = $property->getName(); + $value = $property->getValue(); + $document[$name] = $value; + } + + $documents[$node->getIdentifier()] = $document; + } + + + return Command::SUCCESS; + } +} diff --git a/Resources/config/command.xml b/Resources/config/command.xml index 039df9a..fc91b19 100644 --- a/Resources/config/command.xml +++ b/Resources/config/command.xml @@ -5,7 +5,7 @@ + class="Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\UserInterface\Command\MigratePhpcrCommand"> diff --git a/Resources/config/session.xml b/Resources/config/session.xml index 0888f23..8573479 100644 --- a/Resources/config/session.xml +++ b/Resources/config/session.xml @@ -5,7 +5,7 @@ + class="Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Session\SessionManager"> %sulu_phpcr_migration.configuration% diff --git a/src/SuluPhpcrMigrationBundle.php b/SuluPhpcrMigrationBundle.php similarity index 99% rename from src/SuluPhpcrMigrationBundle.php rename to SuluPhpcrMigrationBundle.php index b20ff18..f4fcc8b 100644 --- a/src/SuluPhpcrMigrationBundle.php +++ b/SuluPhpcrMigrationBundle.php @@ -26,7 +26,7 @@ class SuluPhpcrMigrationBundle extends AbstractBundle */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { - $loader = new XmlFileLoader($builder, new FileLocator(__DIR__ . '/../Resources/config')); + $loader = new XmlFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); $loader->load('session.xml'); $loader->load('command.xml'); } diff --git a/src/UserInterface/Command/MigratePhpcrCommand.php b/src/UserInterface/Command/MigratePhpcrCommand.php deleted file mode 100644 index 174355a..0000000 --- a/src/UserInterface/Command/MigratePhpcrCommand.php +++ /dev/null @@ -1,35 +0,0 @@ -sessionManager->getDefaultSession(); - $liveSession = $this->sessionManager->getLiveSession(); - - return Command::SUCCESS; - } -} From b2fe50bab3ff3024976c5ae25e79a6966b770f0f Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Tue, 7 May 2024 00:51:05 +0200 Subject: [PATCH 02/17] Add phpcr parser + ArticlePersister --- .../Application/Parser/NodeParser.php | 89 +++++++ .../Persister/ArticlePersister.php | 234 ++++++++++++++++++ .../Persister/PersisterInterface.php | 25 ++ .../Command/MigratePhpcrCommand.php | 60 +++-- Resources/config/command.xml | 2 + Resources/config/parser.xml | 12 + Resources/config/persister.xml | 12 + SuluPhpcrMigrationBundle.php | 14 ++ phpstan.neon | 2 +- rector.php | 2 +- 10 files changed, 428 insertions(+), 24 deletions(-) create mode 100644 PhpcrMigration/Application/Parser/NodeParser.php create mode 100644 PhpcrMigration/Application/Persister/ArticlePersister.php create mode 100644 PhpcrMigration/Application/Persister/PersisterInterface.php create mode 100644 Resources/config/parser.xml create mode 100644 Resources/config/persister.xml diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php new file mode 100644 index 0000000..8f1e8d8 --- /dev/null +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -0,0 +1,89 @@ +getProperties() as $property) { + $document = $this->parseProperty($property, $document); + } + + return $document; + } + + private function parseProperty(PropertyInterface $property, array $document): array + { + $name = $property->getName(); + $value = $property->getValue(); + $propertyPath = $this->getLocalizedPath($name); + $propertyPath = $this->getPropertyPath($propertyPath, $name); + $this->propertyAccessor->setValue( + $document, + $propertyPath, + $value + ); + + return $document; + } + + private function getLocalizedPath(string &$name): string + { + $propertyPath = ''; + if (\str_starts_with($name, 'i18n:')) { + $localizationOffset = 5; + $firstDashPosition = \strpos($name, '-', $localizationOffset); + $locale = \substr($name, 5, $firstDashPosition - $localizationOffset); + $propertyPath .= '[localizations][' . $locale . ']'; + $name = \substr($name, $firstDashPosition + 1); + } + + return $propertyPath; + } + + private function getPropertyPath(string $propertyPath, $name): string + { + if (\str_starts_with((string) $name, 'jcr:')) { + $name = \substr((string) $name, 4); + $propertyPath .= '[jcr][' . $name . ']'; + } elseif (\str_starts_with((string) $name, 'sulu:')) { + $name = \substr((string) $name, 5); + $propertyPath .= '[sulu][' . $name . ']'; + } elseif (\str_starts_with((string) $name, 'seo-')) { + $name = \substr((string) $name, 4); + $propertyPath .= '[seo][' . $name . ']'; + } elseif (\str_starts_with((string) $name, 'excerpt-')) { + $name = \substr((string) $name, 8); + $propertyPath .= '[excerpt][' . $name . ']'; + } elseif (\preg_match('/^(.+)#(\d+)$/', (string) $name, $matches)) { + $name = $matches[1]; + $index = (int) $matches[2]; + [$blocksKey, $type] = \explode('-', $name); + $propertyPath .= '[' . $blocksKey . '][' . $index . '][' . $type . ']'; + } else { + $propertyPath .= '[' . $name . ']'; + } + + return $propertyPath; + } +} diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php new file mode 100644 index 0000000..5b09765 --- /dev/null +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -0,0 +1,234 @@ +supports($document)) { + throw new \Exception('Document type not supported'); + } + + if (!isset($document['jcr']['uuid'])) { + throw new \Exception('UUID not found'); + } + + $this->connection->beginTransaction(); + $this->insertArticle($document); + $this->insertArticleDimensionContent($document, $isLive); + $this->connection->commit(); + } + + private function mapData(array &$data, array $mapping, bool $setUsedValueNull = false): array + { + $mappedData = []; + foreach ($mapping as $target => $source) { + $this->propertyAccessor->setValue( + $mappedData, + $target, + $this->propertyAccessor->getValue($data, $source) + ); + // set data to null, so that it can be filtered out later + if ($setUsedValueNull) { + $this->propertyAccessor->setValue($data, $source, null); + } + } + + return $mappedData; + } + + private function insertArticle(array $document): void + { + $mapping = $this->getEntityPathMapping(); + $data = $this->mapData($document, $mapping); + + $exists = $this->connection->fetchAssociative( + 'SELECT * FROM ar_articles WHERE uuid = :uuid', + ['uuid' => $data['uuid']] + ); + + if (!$exists) { + $this->connection->insert(self::getEntityTableName(), $data, self::getEntityTableTypes()); + + return; + } + + $this->connection->update( + self::getEntityTableName(), + $data, + ['uuid' => $data['uuid']], + self::getEntityTableTypes() + ); + } + + private function insertArticleDimensionContent(array $document, bool $isLive): void + { + foreach ($document['localizations'] as $locale => $localizedData) { + $data = $this->mapData($document, $this->getArticleDimensionContentMapping($locale)); + $data['locale'] = $locale; + $data['stage'] = $isLive ? 'live' : 'draft'; + $data['title'] = \str_split((string) $data['title'], 64)[0]; + + $data['workflowPlace'] = 2 === $data['workflowPlace'] ? 'published' : 'draft'; + + if ($data['excerptImageId'] ?? null) { + $data['excerptImageId'] = \json_decode((string) $data['excerptImageId'], true)['ids'][0] ?? null; + } + + if ($data['excerptIconId'] ?? null) { + $data['excerptIconId'] = \json_decode((string) $data['excerptIconId'], true)['ids'][0] ?? null; + } + + // remove known keys that do not belong to the templateData + $document['localizations'][$locale] = \array_filter($document['localizations'][$locale]); + unset($document['localizations'][$locale]['seo']); + unset($document['localizations'][$locale]['excerpt']); + unset($document['localizations'][$locale]['routePath']); + unset($document['localizations'][$locale]['stage']); + $data['templateData'] = $document['localizations'][$locale]; + + $exists = $this->connection->fetchAssociative( + 'SELECT * FROM ar_article_dimension_contents WHERE articleUuid = :articleUuid AND locale = :locale AND stage = :stage', + [ + 'articleUuid' => $document['jcr']['uuid'], + 'locale' => $locale, + 'stage' => $data['stage'], + ] + ); + + if ($exists) { + $this->connection->update( + self::getDimensionContentTableName(), + $data, + [ + 'articleUuid' => $document['jcr']['uuid'], + 'locale' => $locale, + 'stage' => $data['stage'], + ], + self::getDimensionContentTableTypes() + ); + + continue; + } + + $this->connection->insert( + self::getDimensionContentTableName(), + $data, + self::getDimensionContentTableTypes() + ); + } + } + + public function supports(array $document): bool + { + return \in_array('sulu:article', $document['jcr']['mixinTypes']); + } + + public static function getType(): string + { + return 'article'; + } + + public static function getEntityTableName(): string + { + return 'ar_articles'; + } + + public static function getEntityTableTypes(): array + { + return [ + 'uuid' => 'string', + 'created' => 'datetime', + 'changed' => 'datetime', + ]; + } + + public static function getDimensionContentTableName(): string + { + return 'ar_article_dimension_contents'; + } + + public static function getDimensionContentTableTypes(): array + { + return [ + 'author_id' => 'integer', + 'authored' => 'datetime', + 'title' => 'string', + 'locale' => 'string', + 'ghostLocale' => 'string', + 'availableLocales' => 'json', + 'templateKey' => 'string', + 'stage' => 'string', + 'workflowPlace' => 'string', + 'workflowPublished' => 'datetime', + 'seoTitle' => 'string', + 'seoDescription' => 'string', + 'seoKeywords' => 'string', + 'seoCanonicalUrl' => 'string', + 'seoNoIndex' => 'boolean', + 'seoNoFollow' => 'boolean', + 'seoHideInSitemap' => 'boolean', + 'excerptTitle' => 'string', + 'excerptMore' => 'string', + 'excerptDescription' => 'string', + 'excerptImageId' => 'integer', + 'excerptIconId' => 'integer', + 'templateData' => 'json', + ]; + } + + public function getEntityPathMapping(): array + { + return [ + '[uuid]' => '[jcr][uuid]', + '[created]' => '[sulu][created]', + '[changed]' => '[sulu][changed]', + ]; + } + + public function getArticleDimensionContentMapping(string $locale): array + { + return [ + '[author_id]' => '[localizations][' . $locale . '][author]', + '[authored]' => '[localizations][' . $locale . '][authored]', + '[title]' => '[localizations][' . $locale . '][title]', + '[ghostLocale]' => '[localizations][' . $locale . '][ghostLocale]', + '[availableLocales]' => '[localizations][' . $locale . '][availableLocales]', + '[templateKey]' => '[localizations][' . $locale . '][template]', + '[workflowPlace]' => '[localizations][' . $locale . '][state]', // TODO + '[workflowPublished]' => '[localizations][' . $locale . '][published]', + '[articleUuid]' => '[jcr][uuid]', + '[seoTitle]' => '[localizations][' . $locale . '][seo][title]', + '[seoDescription]' => '[localizations][' . $locale . '][seo][description]', + '[seoKeywords]' => '[localizations][' . $locale . '][seo][keywords]', + '[seoCanonicalUrl]' => '[localizations][' . $locale . '][seo][canonicalUrl]', + '[seoNoIndex]' => '[localizations][' . $locale . '][seo][noIndex]', + '[seoNoFollow]' => '[localizations][' . $locale . '][seo][noFollow]', + '[seoHideInSitemap]' => '[localizations][' . $locale . '][seo][hideInSitemap]', + '[excerptTitle]' => '[localizations][' . $locale . '][excerpt][title]', + '[excerptMore]' => '[localizations][' . $locale . '][excerpt][more]', + '[excerptDescription]' => '[localizations][' . $locale . '][excerpt][description]', + '[excerptImageId]' => '[localizations][' . $locale . '][excerpt][images]', // TODO + '[excerptIconId]' => '[localizations][' . $locale . '][excerpt][icon]', // TODO + ]; + } +} diff --git a/PhpcrMigration/Application/Persister/PersisterInterface.php b/PhpcrMigration/Application/Persister/PersisterInterface.php new file mode 100644 index 0000000..d88385c --- /dev/null +++ b/PhpcrMigration/Application/Persister/PersisterInterface.php @@ -0,0 +1,25 @@ +addArgument('documentTypes', InputArgument::OPTIONAL, 'The document type to migrate. (e.g. snippet, page, article)', 'article'); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $session = $this->sessionManager->getDefaultSession(); $liveSession = $this->sessionManager->getLiveSession(); - $queryManager = $session->getWorkspace()->getQueryManager(); - $sql = - sprintf( - 'SELECT * FROM [nt:unstructured] as document WHERE [jcr:mixinTypes] = "sulu:page" AND (isdescendantnode(document, "/cmf/%s/contents") OR issamenode(document, "/cmf/%s/contents"))', - 'sulu', - 'sulu' - );; - $query = $queryManager->createQuery($sql, 'JCR-SQL2'); - $result = $query->execute(); + $documentTypes = \explode(',', $input->getArgument('documentTypes') ?? 'article'); - $documents = []; - foreach ($result->getNodes() as $node) { - $document = []; - - foreach ($node->getProperties() as $property) { - $name = $property->getName(); - $value = $property->getValue(); - $document[$name] = $value; + /** @var SessionInterface $session */ + foreach ([$session, $liveSession] as $session) { + foreach ($documentTypes as $documentType) { + $nodes = $this->fetchPhpcrNodes($session, $documentType); + foreach ($nodes as $node) { + $document = $this->nodeParser->parse($node); + // TODO persisterPool + $this->articlePersister->persist($document, \str_ends_with($session->getWorkspace()->getName(), '_live')); + } } - - $documents[$node->getIdentifier()] = $document; } - return Command::SUCCESS; } + + private function fetchPhpcrNodes(SessionInterface $session, string $documentType): \Traversable + { + $queryManager = $session->getWorkspace()->getQueryManager(); + + $sql = \sprintf( + 'SELECT * FROM [nt:unstructured] as document WHERE [jcr:mixinTypes] = "sulu:%s"', + $documentType + ); + $query = $queryManager->createQuery($sql, 'JCR-SQL2'); + $result = $query->execute(); + + return $result->getNodes(); + } } diff --git a/Resources/config/command.xml b/Resources/config/command.xml index fc91b19..aa798b3 100644 --- a/Resources/config/command.xml +++ b/Resources/config/command.xml @@ -7,6 +7,8 @@ + + diff --git a/Resources/config/parser.xml b/Resources/config/parser.xml new file mode 100644 index 0000000..88a1c68 --- /dev/null +++ b/Resources/config/parser.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/Resources/config/persister.xml b/Resources/config/persister.xml new file mode 100644 index 0000000..1c9dd0f --- /dev/null +++ b/Resources/config/persister.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/SuluPhpcrMigrationBundle.php b/SuluPhpcrMigrationBundle.php index f4fcc8b..fe9b16f 100644 --- a/SuluPhpcrMigrationBundle.php +++ b/SuluPhpcrMigrationBundle.php @@ -29,6 +29,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $loader = new XmlFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); $loader->load('session.xml'); $loader->load('command.xml'); + $loader->load('parser.xml'); + $loader->load('persister.xml'); } public function configure(DefinitionConfigurator $definition): void @@ -38,6 +40,15 @@ public function configure(DefinitionConfigurator $definition): void $rootNode ->children() ->scalarNode('DSN')->isRequired()->end() + ->arrayNode('target') + ->children() + ->arrayNode('dbal') + ->children() + ->scalarNode('connection')->isRequired()->end() + ->end() + ->end() + ->end() + ->end() ->end(); } @@ -49,6 +60,9 @@ public function prependExtension(ContainerConfigurator $container, ContainerBuil $dsn = $config[0]['DSN']; $builder->setParameter('sulu_phpcr_migration.dsn', $dsn); + $targetConnectionName = $config[0]['target']['dbal']['connection']; + $builder->setAlias('sulu_phpcr_migration.target_connection', \sprintf('doctrine.dbal.%s_connection', $targetConnectionName)); + $configuration = $this->getConnectionConfiguration($dsn); $builder->setParameter('sulu_phpcr_migration.configuration', $configuration); diff --git a/phpstan.neon b/phpstan.neon index 9f31aca..015b39e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: paths: - - src + - PhpcrMigration - Tests level: max diff --git a/rector.php b/rector.php index 60374f6..9e64ef8 100644 --- a/rector.php +++ b/rector.php @@ -16,7 +16,7 @@ use Rector\Set\ValueObject\SetList; return static function(RectorConfig $rectorConfig): void { - $rectorConfig->paths([__DIR__ . '/src', __DIR__ . '/Tests']); + $rectorConfig->paths([__DIR__ . '/PhpcrMigration', __DIR__ . '/Tests']); $rectorConfig->phpstanConfigs([ __DIR__ . '/phpstan.neon', From e965cd785b9d870445b8d963118ff4e15ecb57c4 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Tue, 7 May 2024 12:49:55 +0200 Subject: [PATCH 03/17] Fix jackrabbit issues --- PhpcrMigration/Application/Parser/NodeParser.php | 12 +++++++++++- .../Application/Persister/ArticlePersister.php | 10 +++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php index 8f1e8d8..cebf7ed 100644 --- a/PhpcrMigration/Application/Parser/NodeParser.php +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -35,7 +35,7 @@ public function parse(NodeInterface $node): array private function parseProperty(PropertyInterface $property, array $document): array { $name = $property->getName(); - $value = $property->getValue(); + $value = $this->resolvePropertyValue($property); $propertyPath = $this->getLocalizedPath($name); $propertyPath = $this->getPropertyPath($propertyPath, $name); $this->propertyAccessor->setValue( @@ -47,6 +47,16 @@ private function parseProperty(PropertyInterface $property, array $document): ar return $document; } + private function resolvePropertyValue(PropertyInterface $property): mixed + { + $value = $property->getValueForStorage(); + if (\is_string($value) && json_validate($value) && ('' !== $value && '0' !== $value)) { + return \json_decode($value, true); + } + + return $value; + } + private function getLocalizedPath(string &$name): string { $propertyPath = ''; diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 5b09765..4996b4b 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -91,11 +91,11 @@ private function insertArticleDimensionContent(array $document, bool $isLive): v $data['workflowPlace'] = 2 === $data['workflowPlace'] ? 'published' : 'draft'; if ($data['excerptImageId'] ?? null) { - $data['excerptImageId'] = \json_decode((string) $data['excerptImageId'], true)['ids'][0] ?? null; + $data['excerptImageId'] = $data['excerptImageId']['ids'][0] ?? null; } if ($data['excerptIconId'] ?? null) { - $data['excerptIconId'] = \json_decode((string) $data['excerptIconId'], true)['ids'][0] ?? null; + $data['excerptIconId'] = $data['excerptIconId']['ids'][0] ?? null; } // remove known keys that do not belong to the templateData @@ -214,7 +214,7 @@ public function getArticleDimensionContentMapping(string $locale): array '[ghostLocale]' => '[localizations][' . $locale . '][ghostLocale]', '[availableLocales]' => '[localizations][' . $locale . '][availableLocales]', '[templateKey]' => '[localizations][' . $locale . '][template]', - '[workflowPlace]' => '[localizations][' . $locale . '][state]', // TODO + '[workflowPlace]' => '[localizations][' . $locale . '][state]', '[workflowPublished]' => '[localizations][' . $locale . '][published]', '[articleUuid]' => '[jcr][uuid]', '[seoTitle]' => '[localizations][' . $locale . '][seo][title]', @@ -227,8 +227,8 @@ public function getArticleDimensionContentMapping(string $locale): array '[excerptTitle]' => '[localizations][' . $locale . '][excerpt][title]', '[excerptMore]' => '[localizations][' . $locale . '][excerpt][more]', '[excerptDescription]' => '[localizations][' . $locale . '][excerpt][description]', - '[excerptImageId]' => '[localizations][' . $locale . '][excerpt][images]', // TODO - '[excerptIconId]' => '[localizations][' . $locale . '][excerpt][icon]', // TODO + '[excerptImageId]' => '[localizations][' . $locale . '][excerpt][images]', + '[excerptIconId]' => '[localizations][' . $locale . '][excerpt][icon]', ]; } } From 38a78ea7b7eead14d64520f8ba6f480f179f9788 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Tue, 7 May 2024 14:57:07 +0200 Subject: [PATCH 04/17] add PersisterPool --- .../Application/Parser/NodeParser.php | 31 ++- .../Persister/AbstractPersister.php | 214 ++++++++++++++++++ .../Persister/ArticlePersister.php | 198 ++++++---------- .../Persister/PersisterInterface.php | 10 +- .../Application/Persister/PersisterPool.php | 33 +++ .../Command/MigratePhpcrCommand.php | 23 +- Resources/config/command.xml | 2 +- Resources/config/persister.xml | 7 + composer.json | 3 +- 9 files changed, 362 insertions(+), 159 deletions(-) create mode 100644 PhpcrMigration/Application/Persister/AbstractPersister.php create mode 100644 PhpcrMigration/Application/Persister/PersisterPool.php diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php index cebf7ed..b419951 100644 --- a/PhpcrMigration/Application/Parser/NodeParser.php +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -11,6 +11,7 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Parser; +use Jackalope\Property; use PHPCR\NodeInterface; use PHPCR\PropertyInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -22,6 +23,9 @@ public function __construct( ) { } + /** + * @return mixed[] + */ public function parse(NodeInterface $node): array { $document = []; @@ -32,6 +36,11 @@ public function parse(NodeInterface $node): array return $document; } + /** + * @param mixed[] $document + * + * @return mixed[] + */ private function parseProperty(PropertyInterface $property, array $document): array { $name = $property->getName(); @@ -49,7 +58,7 @@ private function parseProperty(PropertyInterface $property, array $document): ar private function resolvePropertyValue(PropertyInterface $property): mixed { - $value = $property->getValueForStorage(); + $value = $property instanceof Property ? $property->getValueForStorage() : $property->getValue(); if (\is_string($value) && json_validate($value) && ('' !== $value && '0' !== $value)) { return \json_decode($value, true); } @@ -71,21 +80,21 @@ private function getLocalizedPath(string &$name): string return $propertyPath; } - private function getPropertyPath(string $propertyPath, $name): string + private function getPropertyPath(string $propertyPath, string $name): string { - if (\str_starts_with((string) $name, 'jcr:')) { - $name = \substr((string) $name, 4); + if (\str_starts_with($name, 'jcr:')) { + $name = \substr($name, 4); $propertyPath .= '[jcr][' . $name . ']'; - } elseif (\str_starts_with((string) $name, 'sulu:')) { - $name = \substr((string) $name, 5); + } elseif (\str_starts_with($name, 'sulu:')) { + $name = \substr($name, 5); $propertyPath .= '[sulu][' . $name . ']'; - } elseif (\str_starts_with((string) $name, 'seo-')) { - $name = \substr((string) $name, 4); + } elseif (\str_starts_with($name, 'seo-')) { + $name = \substr($name, 4); $propertyPath .= '[seo][' . $name . ']'; - } elseif (\str_starts_with((string) $name, 'excerpt-')) { - $name = \substr((string) $name, 8); + } elseif (\str_starts_with($name, 'excerpt-')) { + $name = \substr($name, 8); $propertyPath .= '[excerpt][' . $name . ']'; - } elseif (\preg_match('/^(.+)#(\d+)$/', (string) $name, $matches)) { + } elseif (\preg_match('/^(.+)#(\d+)$/', $name, $matches)) { $name = $matches[1]; $index = (int) $matches[2]; [$blocksKey, $type] = \explode('-', $name); diff --git a/PhpcrMigration/Application/Persister/AbstractPersister.php b/PhpcrMigration/Application/Persister/AbstractPersister.php new file mode 100644 index 0000000..f32a6bd --- /dev/null +++ b/PhpcrMigration/Application/Persister/AbstractPersister.php @@ -0,0 +1,214 @@ +supports($document)) { + throw new \Exception('Document type not supported'); + } + + if (!isset($document['jcr']['uuid'])) { + throw new \Exception('UUID not found'); + } + + $this->connection->beginTransaction(); + $this->insertEntity($document); + $this->insertDimensionContent($document, $isLive); + $this->connection->commit(); + } + + /** + * @param mixed[] $data + * @param array $mapping + * + * @return mixed[] + */ + protected function mapDataViaMapping(array &$data, array $mapping, bool $setUsedValueNull = false): array + { + $mappedData = []; + foreach ($mapping as $target => $source) { + $this->propertyAccessor->setValue( + $mappedData, + $target, + $this->propertyAccessor->getValue($data, $source) + ); + // set data to null, so that it can be filtered out later + if ($setUsedValueNull) { + $this->propertyAccessor->setValue($data, $source, null); + } + } + + return $mappedData; + } + + /** + * @param mixed[] $document + */ + protected function insertEntity(array $document): void + { + $data = $this->mapDataViaMapping($document, $this->getEntityMapping()); + + $this->insertOrUpdate( + $data, + $this->getEntityTableName(), + $this->getEntityTableTypes(), + [ + 'uuid' => $data['uuid'], + ] + ); + } + + /** + * @param mixed[] $data + * + * @return mixed[] + */ + protected function mapExcerptImages(array $data): array + { + if ($data['excerptImageId'] ?? null) { + $data['excerptImageId'] = $data['excerptImageId']['ids'][0] ?? null; + } + + return $data; + } + + /** + * @param mixed[] $data + * + * @return mixed[] + */ + protected function mapExcerptIcons(array $data): array + { + if ($data['excerptIconId'] ?? null) { + $data['excerptIconId'] = $data['excerptIconId']['ids'][0] ?? null; + } + + return $data; + } + + /** + * @param mixed[] $document + */ + protected function insertDimensionContent(array $document, bool $isLive): void + { + //TODO unlocalized data + /** @var mixed[] $localizations */ + $localizations = $document['localizations'] ?? []; + foreach ($localizations as $locale => $localizedData) { + $data = $this->mapDataViaMapping($localizedData, $this->getDimensionContentMapping(), true); + $data = $this->mapData($document, $locale, $data, $isLive); + $data = $this->mapExcerptImages($data); + $data = $this->mapExcerptIcons($data); + + // remove known keys that do not belong to the templateData + $localizedData = $this->removeNonTemplateData($localizedData); + $data['templateData'] = $localizedData; + + $this->insertOrUpdate( + $data, + $this->getDimensionContentTableName(), + $this->getDimensionContentTableTypes(), [ + 'articleUuid' => $data['articleUuid'], //TODO make dynamic + 'locale' => $locale, + 'stage' => $data['stage'], + ]); + } + } + + /** + * @param mixed[] $data + * @param array $types + * @param array $where + */ + protected function insertOrUpdate(array $data, string $tableName, array $types, array $where): void + { + $exists = $this->connection->fetchAssociative( + 'SELECT * FROM ' . $tableName . ' WHERE ' . \implode(' AND ', \array_map(fn ($key) => $key . ' = :' . $key, \array_keys($where))), + $where + ); + + match (null !== $exists) { + true => $this->connection->update( + $tableName, + $data, + $where, + $types + ), + default => $this->connection->insert( + $tableName, + $data, + $types + ), + }; + } + + /** + * @param mixed[] $document + * @param mixed[] $data + * + * @return mixed[] + */ + protected function mapData(array $document, string $locale, array $data, bool $isLive): array + { + return $data; + } + + /** + * @param mixed[] $data + * + * @return mixed[] + */ + protected function removeNonTemplateData(array $data): array + { + return $data; + } + + abstract public function supports(array $document): bool; + + abstract public static function getType(): string; + + abstract protected function getEntityTableName(): string; + + abstract protected function getEntityTableTypes(): array; + + /** + * @return array + */ + abstract protected function getEntityMapping(): array; + + abstract protected function getDimensionContentTableName(): string; + + /** + * @return array + */ + abstract protected function getDimensionContentTableTypes(): array; + + /** + * @return array + */ + abstract protected function getDimensionContentMapping(): array; +} diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 4996b4b..6e3669c 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -14,130 +14,61 @@ use Doctrine\DBAL\Connection; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -readonly class ArticlePersister implements PersisterInterface +class ArticlePersister extends AbstractPersister { public function __construct( - private Connection $connection, - private PropertyAccessorInterface $propertyAccessor + Connection $connection, + PropertyAccessorInterface $propertyAccessor ) { + parent::__construct($connection, $propertyAccessor); } - public function persist(array $document, bool $isLive): void + protected function removeNonTemplateData(array $data): array { - if (false === $this->supports($document)) { - throw new \Exception('Document type not supported'); - } - - if (!isset($document['jcr']['uuid'])) { - throw new \Exception('UUID not found'); - } + $data['seo'] = null; + $data['excerpt'] = null; + $data['routePath'] = null; + $data['stage'] = null; - $this->connection->beginTransaction(); - $this->insertArticle($document); - $this->insertArticleDimensionContent($document, $isLive); - $this->connection->commit(); + return \array_filter($data); } - private function mapData(array &$data, array $mapping, bool $setUsedValueNull = false): array + protected function mapData(array $document, string $locale, array $data, bool $isLive): array { - $mappedData = []; - foreach ($mapping as $target => $source) { - $this->propertyAccessor->setValue( - $mappedData, - $target, - $this->propertyAccessor->getValue($data, $source) - ); - // set data to null, so that it can be filtered out later - if ($setUsedValueNull) { - $this->propertyAccessor->setValue($data, $source, null); - } - } + $data['articleUuid'] = $document['jcr']['uuid']; + $data['locale'] = $locale; + $data['stage'] = $isLive ? 'live' : 'draft'; + $data['title'] = \str_split((string) $data['title'], 64)[0]; + $data['workflowPlace'] = 2 === $data['workflowPlace'] ? 'published' : 'draft'; - return $mappedData; + return $data; } - private function insertArticle(array $document): void + protected function insertOrUpdate(array $data, string $tableName, array $types, array $where): void { - $mapping = $this->getEntityPathMapping(); - $data = $this->mapData($document, $mapping); - $exists = $this->connection->fetchAssociative( - 'SELECT * FROM ar_articles WHERE uuid = :uuid', - ['uuid' => $data['uuid']] + 'SELECT * FROM ' . $tableName . ' WHERE ' . \implode(' AND ', \array_map(fn ($key) => $key . ' = :' . $key, \array_keys($where))), + $where ); - if (!$exists) { - $this->connection->insert(self::getEntityTableName(), $data, self::getEntityTableTypes()); + if ($exists) { + $this->connection->update( + $tableName, + $data, + $where, + $types + ); return; } - $this->connection->update( - self::getEntityTableName(), + $this->connection->insert( + $tableName, $data, - ['uuid' => $data['uuid']], - self::getEntityTableTypes() + $types ); } - private function insertArticleDimensionContent(array $document, bool $isLive): void - { - foreach ($document['localizations'] as $locale => $localizedData) { - $data = $this->mapData($document, $this->getArticleDimensionContentMapping($locale)); - $data['locale'] = $locale; - $data['stage'] = $isLive ? 'live' : 'draft'; - $data['title'] = \str_split((string) $data['title'], 64)[0]; - - $data['workflowPlace'] = 2 === $data['workflowPlace'] ? 'published' : 'draft'; - - if ($data['excerptImageId'] ?? null) { - $data['excerptImageId'] = $data['excerptImageId']['ids'][0] ?? null; - } - - if ($data['excerptIconId'] ?? null) { - $data['excerptIconId'] = $data['excerptIconId']['ids'][0] ?? null; - } - - // remove known keys that do not belong to the templateData - $document['localizations'][$locale] = \array_filter($document['localizations'][$locale]); - unset($document['localizations'][$locale]['seo']); - unset($document['localizations'][$locale]['excerpt']); - unset($document['localizations'][$locale]['routePath']); - unset($document['localizations'][$locale]['stage']); - $data['templateData'] = $document['localizations'][$locale]; - - $exists = $this->connection->fetchAssociative( - 'SELECT * FROM ar_article_dimension_contents WHERE articleUuid = :articleUuid AND locale = :locale AND stage = :stage', - [ - 'articleUuid' => $document['jcr']['uuid'], - 'locale' => $locale, - 'stage' => $data['stage'], - ] - ); - - if ($exists) { - $this->connection->update( - self::getDimensionContentTableName(), - $data, - [ - 'articleUuid' => $document['jcr']['uuid'], - 'locale' => $locale, - 'stage' => $data['stage'], - ], - self::getDimensionContentTableTypes() - ); - - continue; - } - - $this->connection->insert( - self::getDimensionContentTableName(), - $data, - self::getDimensionContentTableTypes() - ); - } - } - public function supports(array $document): bool { return \in_array('sulu:article', $document['jcr']['mixinTypes']); @@ -148,12 +79,12 @@ public static function getType(): string return 'article'; } - public static function getEntityTableName(): string + protected function getEntityTableName(): string { return 'ar_articles'; } - public static function getEntityTableTypes(): array + protected function getEntityTableTypes(): array { return [ 'uuid' => 'string', @@ -162,12 +93,21 @@ public static function getEntityTableTypes(): array ]; } - public static function getDimensionContentTableName(): string + protected function getEntityMapping(): array + { + return [ + '[uuid]' => '[jcr][uuid]', + '[created]' => '[sulu][created]', + '[changed]' => '[sulu][changed]', + ]; + } + + protected function getDimensionContentTableName(): string { return 'ar_article_dimension_contents'; } - public static function getDimensionContentTableTypes(): array + protected function getDimensionContentTableTypes(): array { return [ 'author_id' => 'integer', @@ -196,39 +136,29 @@ public static function getDimensionContentTableTypes(): array ]; } - public function getEntityPathMapping(): array - { - return [ - '[uuid]' => '[jcr][uuid]', - '[created]' => '[sulu][created]', - '[changed]' => '[sulu][changed]', - ]; - } - - public function getArticleDimensionContentMapping(string $locale): array + protected function getDimensionContentMapping(): array { return [ - '[author_id]' => '[localizations][' . $locale . '][author]', - '[authored]' => '[localizations][' . $locale . '][authored]', - '[title]' => '[localizations][' . $locale . '][title]', - '[ghostLocale]' => '[localizations][' . $locale . '][ghostLocale]', - '[availableLocales]' => '[localizations][' . $locale . '][availableLocales]', - '[templateKey]' => '[localizations][' . $locale . '][template]', - '[workflowPlace]' => '[localizations][' . $locale . '][state]', - '[workflowPublished]' => '[localizations][' . $locale . '][published]', - '[articleUuid]' => '[jcr][uuid]', - '[seoTitle]' => '[localizations][' . $locale . '][seo][title]', - '[seoDescription]' => '[localizations][' . $locale . '][seo][description]', - '[seoKeywords]' => '[localizations][' . $locale . '][seo][keywords]', - '[seoCanonicalUrl]' => '[localizations][' . $locale . '][seo][canonicalUrl]', - '[seoNoIndex]' => '[localizations][' . $locale . '][seo][noIndex]', - '[seoNoFollow]' => '[localizations][' . $locale . '][seo][noFollow]', - '[seoHideInSitemap]' => '[localizations][' . $locale . '][seo][hideInSitemap]', - '[excerptTitle]' => '[localizations][' . $locale . '][excerpt][title]', - '[excerptMore]' => '[localizations][' . $locale . '][excerpt][more]', - '[excerptDescription]' => '[localizations][' . $locale . '][excerpt][description]', - '[excerptImageId]' => '[localizations][' . $locale . '][excerpt][images]', - '[excerptIconId]' => '[localizations][' . $locale . '][excerpt][icon]', + '[author_id]' => '[author]', + '[authored]' => '[authored]', + '[title]' => '[title]', + '[ghostLocale]' => '[ghostLocale]', + '[availableLocales]' => '[availableLocales]', + '[templateKey]' => '[template]', + '[workflowPlace]' => '[state]', + '[workflowPublished]' => '[published]', + '[seoTitle]' => '[seo][title]', + '[seoDescription]' => '[seo][description]', + '[seoKeywords]' => '[seo][keywords]', + '[seoCanonicalUrl]' => '[seo][canonicalUrl]', + '[seoNoIndex]' => '[seo][noIndex]', + '[seoNoFollow]' => '[seo][noFollow]', + '[seoHideInSitemap]' => '[seo][hideInSitemap]', + '[excerptTitle]' => '[excerpt][title]', + '[excerptMore]' => '[excerpt][more]', + '[excerptDescription]' => '[excerpt][description]', + '[excerptImageId]' => '[excerpt][images]', + '[excerptIconId]' => '[excerpt][icon]', ]; } } diff --git a/PhpcrMigration/Application/Persister/PersisterInterface.php b/PhpcrMigration/Application/Persister/PersisterInterface.php index d88385c..b14b99b 100644 --- a/PhpcrMigration/Application/Persister/PersisterInterface.php +++ b/PhpcrMigration/Application/Persister/PersisterInterface.php @@ -13,13 +13,15 @@ interface PersisterInterface { + /** + * @param mixed[] $document + */ public function persist(array $document, bool $isLive): void; + /** + * @param mixed[] $document + */ public function supports(array $document): bool; public static function getType(): string; - - public static function getEntityTableName(): string; - - public static function getDimensionContentTableName(): string; } diff --git a/PhpcrMigration/Application/Persister/PersisterPool.php b/PhpcrMigration/Application/Persister/PersisterPool.php new file mode 100644 index 0000000..fe8b7e8 --- /dev/null +++ b/PhpcrMigration/Application/Persister/PersisterPool.php @@ -0,0 +1,33 @@ + $persisters + */ + public function __construct(private readonly iterable $persisters) + { + } + + public function getPersister(string $type): PersisterInterface + { + foreach ($this->persisters as $persister) { + if ($persister::getType() === $type) { + return $persister; + } + } + + throw new \Exception('Persister for type "' . $type . '" not found.'); + } +} diff --git a/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php index 15fa7b2..4dcfa7d 100644 --- a/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php +++ b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php @@ -11,9 +11,10 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\UserInterface\Command; +use PHPCR\NodeInterface; use PHPCR\SessionInterface; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Parser\NodeParser; -use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister\PersisterInterface; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister\PersisterPool; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Session\SessionManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -27,7 +28,7 @@ class MigratePhpcrCommand extends Command public function __construct( private readonly SessionManager $sessionManager, private readonly NodeParser $nodeParser, - private readonly PersisterInterface $articlePersister + private readonly PersisterPool $persisterPool ) { parent::__construct(); } @@ -42,16 +43,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $session = $this->sessionManager->getDefaultSession(); $liveSession = $this->sessionManager->getLiveSession(); - $documentTypes = \explode(',', $input->getArgument('documentTypes') ?? 'article'); + /** @var string $documentTypes */ + $documentTypes = $input->getArgument('documentTypes'); + $documentTypes = \explode(',', $documentTypes); - /** @var SessionInterface $session */ - foreach ([$session, $liveSession] as $session) { - foreach ($documentTypes as $documentType) { + foreach ($documentTypes as $documentType) { + $persister = $this->persisterPool->getPersister($documentType); + + /** @var SessionInterface $session */ + foreach ([$session, $liveSession] as $session) { $nodes = $this->fetchPhpcrNodes($session, $documentType); foreach ($nodes as $node) { $document = $this->nodeParser->parse($node); - // TODO persisterPool - $this->articlePersister->persist($document, \str_ends_with($session->getWorkspace()->getName(), '_live')); + $persister->persist($document, \str_ends_with($session->getWorkspace()->getName(), '_live')); } } } @@ -59,6 +63,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + /** + * @return \Traversable + */ private function fetchPhpcrNodes(SessionInterface $session, string $documentType): \Traversable { $queryManager = $session->getWorkspace()->getQueryManager(); diff --git a/Resources/config/command.xml b/Resources/config/command.xml index aa798b3..bbf2122 100644 --- a/Resources/config/command.xml +++ b/Resources/config/command.xml @@ -8,7 +8,7 @@ class="Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\UserInterface\Command\MigratePhpcrCommand"> - + diff --git a/Resources/config/persister.xml b/Resources/config/persister.xml index 1c9dd0f..757c30d 100644 --- a/Resources/config/persister.xml +++ b/Resources/config/persister.xml @@ -7,6 +7,13 @@ class="Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister\ArticlePersister"> + + + + + + diff --git a/composer.json b/composer.json index d4096f7..82fcbad 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "symfony/config": "^6.0 | ^7.0", "symfony/console": "^6.0 | ^7.0", "symfony/dependency-injection": "^6.0 | ^7.0", - "symfony/framework-bundle": "^6.0 | ^7.0" + "symfony/framework-bundle": "^6.0 | ^7.0", + "symfony/property-access": "^6.0 | ^7.0" }, "autoload": { "psr-4": { From fca828c27623cf51a24cf4a604b24fc5378c2e10 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Thu, 16 May 2024 08:44:16 +0200 Subject: [PATCH 05/17] Add todo --- PhpcrMigration/Application/Persister/ArticlePersister.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 6e3669c..8305634 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -30,7 +30,7 @@ protected function removeNonTemplateData(array $data): array $data['routePath'] = null; $data['stage'] = null; - return \array_filter($data); + return \array_filter($data); // TODO callback function mit null } protected function mapData(array $document, string $locale, array $data, bool $isLive): array From 82eeaa862650bf271ddf45545c1656910957b148 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Wed, 17 Jul 2024 13:43:47 +0200 Subject: [PATCH 06/17] Add routing / tags / category migration + polishing --- .../Exception/InvalidPathException.php | 20 ++ .../Exception/PersisterNotFoundException.php | 20 ++ .../UnsupportedDocumentTypeException.php | 23 ++ .../Application/Parser/NodeParser.php | 13 +- .../Persister/AbstractPersister.php | 322 ++++++++++++++---- .../Persister/ArticlePersister.php | 113 ++++-- .../Application/Persister/PersisterPool.php | 4 +- .../Repository/EntityRepository.php | 117 +++++++ .../Command/MigratePhpcrCommand.php | 12 + Resources/config/command.xml | 4 +- Resources/config/persister.xml | 2 +- Resources/config/repository.xml | 12 + Resources/config/session.xml | 2 +- SuluPhpcrMigrationBundle.php | 1 + composer.json | 4 +- 15 files changed, 565 insertions(+), 104 deletions(-) create mode 100644 PhpcrMigration/Application/Exception/InvalidPathException.php create mode 100644 PhpcrMigration/Application/Exception/PersisterNotFoundException.php create mode 100644 PhpcrMigration/Application/Exception/UnsupportedDocumentTypeException.php create mode 100644 PhpcrMigration/Infrastructure/Repository/EntityRepository.php create mode 100644 Resources/config/repository.xml diff --git a/PhpcrMigration/Application/Exception/InvalidPathException.php b/PhpcrMigration/Application/Exception/InvalidPathException.php new file mode 100644 index 0000000..e46e998 --- /dev/null +++ b/PhpcrMigration/Application/Exception/InvalidPathException.php @@ -0,0 +1,20 @@ + $types + */ + public function __construct(array $types) + { + parent::__construct(\sprintf('Unsupported document type(s) "%s"', \implode('", "', $types))); + } +} diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php index b419951..aa57795 100644 --- a/PhpcrMigration/Application/Parser/NodeParser.php +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -28,7 +28,11 @@ public function __construct( */ public function parse(NodeInterface $node): array { - $document = []; + $document = [ + 'localizations' => [ + 'null' => [], // required to always create the unlocalized dimension + ], + ]; foreach ($node->getProperties() as $property) { $document = $this->parseProperty($property, $document); } @@ -56,6 +60,11 @@ private function parseProperty(PropertyInterface $property, array $document): ar return $document; } + private function isUnLocalizedProperty(string $name): bool + { + return !\str_contains($name, ':'); + } + private function resolvePropertyValue(PropertyInterface $property): mixed { $value = $property instanceof Property ? $property->getValueForStorage() : $property->getValue(); @@ -75,6 +84,8 @@ private function getLocalizedPath(string &$name): string $locale = \substr($name, 5, $firstDashPosition - $localizationOffset); $propertyPath .= '[localizations][' . $locale . ']'; $name = \substr($name, $firstDashPosition + 1); + } elseif ($this->isUnLocalizedProperty($name)) { + $propertyPath .= '[localizations][null]'; } return $propertyPath; diff --git a/PhpcrMigration/Application/Persister/AbstractPersister.php b/PhpcrMigration/Application/Persister/AbstractPersister.php index f32a6bd..c7b299a 100644 --- a/PhpcrMigration/Application/Persister/AbstractPersister.php +++ b/PhpcrMigration/Application/Persister/AbstractPersister.php @@ -11,34 +11,52 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister; -use Doctrine\DBAL\Connection; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\UnsupportedDocumentTypeException; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository\EntityRepository; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +/** + * @phpstan-type Document array{ + * jcr: array{uuid: string, mixinTypes: string[]}, + * localizations: array + * } + * @phpstan-type DimensionContent array{ + * id: int + * } + */ abstract class AbstractPersister implements PersisterInterface { + public const ROUTE_TABLE = 'ro_routes'; + public function __construct( - protected Connection $connection, - protected PropertyAccessorInterface $propertyAccessor + protected PropertyAccessorInterface $propertyAccessor, + protected EntityRepository $entityRepository, ) { } /** - * @param mixed[] $document + * @param Document $document */ public function persist(array $document, bool $isLive): void { if (false === $this->supports($document)) { - throw new \Exception('Document type not supported'); - } - - if (!isset($document['jcr']['uuid'])) { - throw new \Exception('UUID not found'); + throw new UnsupportedDocumentTypeException($document['jcr']['mixinTypes']); } - $this->connection->beginTransaction(); - $this->insertEntity($document); - $this->insertDimensionContent($document, $isLive); - $this->connection->commit(); + $this->entityRepository->beginTransaction(); + $this->createOrUpdateEntity($document); + $this->createOrUpdateDimensionContent($document, $isLive); + $this->createOrUpdateRoute($document); + $this->entityRepository->commit(); } /** @@ -47,39 +65,31 @@ public function persist(array $document, bool $isLive): void * * @return mixed[] */ - protected function mapDataViaMapping(array &$data, array $mapping, bool $setUsedValueNull = false): array + protected function mapDataViaMapping(array &$data, array $mapping): array { $mappedData = []; foreach ($mapping as $target => $source) { + if (null === $this->propertyAccessor->getValue($data, $source)) { + continue; + } $this->propertyAccessor->setValue( $mappedData, $target, $this->propertyAccessor->getValue($data, $source) ); - // set data to null, so that it can be filtered out later - if ($setUsedValueNull) { - $this->propertyAccessor->setValue($data, $source, null); - } } return $mappedData; } /** - * @param mixed[] $document + * @param Document $document + * @param DimensionContent $dimensionContent */ - protected function insertEntity(array $document): void + protected function insertDataRelationsToDimensionContent(array $document, ?string $locale, array $dimensionContent): void { - $data = $this->mapDataViaMapping($document, $this->getEntityMapping()); - - $this->insertOrUpdate( - $data, - $this->getEntityTableName(), - $this->getEntityTableTypes(), - [ - 'uuid' => $data['uuid'], - ] - ); + $this->insertOrUpdateExcerptCategories($document, $locale, $dimensionContent); + $this->insertOrUpdateExcerptTags($document, $locale, $dimensionContent); } /** @@ -111,69 +121,215 @@ protected function mapExcerptIcons(array $data): array } /** - * @param mixed[] $document + * @param Document $document + * @param DimensionContent $dimensionContent + */ + protected function insertOrUpdateExcerptCategories(array $document, ?string $locale, array $dimensionContent): void + { + if ($categoryIds = ($document['localizations'][$locale]['excerpt']['categories'] ?? null)) { + // remove all existing categories + $this->entityRepository->removeBy( + $this->getDimensionContentExcerptCategoriesTableName(), + [ + $this->getDimensionContentExcerptCategoriesIdName() => $dimensionContent['id'], + ] + ); + + foreach ($categoryIds as $categoryId) { + $this->entityRepository->insertOrUpdate( + [ + $this->getDimensionContentExcerptCategoriesIdName() => $dimensionContent['id'], + 'category_id' => $categoryId, + ], + $this->getDimensionContentExcerptCategoriesTableName(), + [ + $this->getDimensionContentExcerptCategoriesIdName() => 'integer', + 'category_id' => 'integer', + ] + ); + } + } + } + + /** + * @param Document $document + * @param DimensionContent $dimensionContent + */ + protected function insertOrUpdateExcerptTags(array $document, ?string $locale, array $dimensionContent): void + { + if ($tagIds = ($document['localizations'][$locale]['excerpt']['tags'] ?? null)) { + // remove all existing tags + $this->entityRepository->removeBy( + $this->getDimensionContentExcerptTagsTableName(), + [ + $this->getDimensionContentExcerptTagsIdName() => $dimensionContent['id'], + ] + ); + + foreach ($tagIds as $tagId) { + $this->entityRepository->insertOrUpdate( + [ + $this->getDimensionContentExcerptTagsIdName() => $dimensionContent['id'], + 'tag_id' => $tagId, + ], + $this->getDimensionContentExcerptTagsTableName(), + [ + $this->getDimensionContentExcerptTagsIdName() => 'integer', + 'tag_id' => 'integer', + ] + ); + } + } + } + + /** + * @param Document $document + */ + protected function createOrUpdateEntity(array $document): void + { + $data = $this->mapDataViaMapping($document, $this->getEntityMapping()); + + $this->entityRepository->insertOrUpdate( + $data, + $this->getEntityTableName(), + $this->getEntityTableTypes(), + [ + 'uuid' => $data['uuid'], + ] + ); + } + + /** + * @param Document $document */ - protected function insertDimensionContent(array $document, bool $isLive): void + protected function createOrUpdateDimensionContent(array $document, bool $isLive): void { - //TODO unlocalized data /** @var mixed[] $localizations */ - $localizations = $document['localizations'] ?? []; + $localizations = $document['localizations']; + $availableLocales = \array_values(\array_filter(\array_keys($localizations), static fn ($locale) => 'null' !== $locale)); + /** + * @var array{ + * availableLocales?: string[], + * templateData?: mixed[], + * } $localizedData + * @var string $locale + */ foreach ($localizations as $locale => $localizedData) { - $data = $this->mapDataViaMapping($localizedData, $this->getDimensionContentMapping(), true); - $data = $this->mapData($document, $locale, $data, $isLive); + $locale = 'null' === $locale ? null : $locale; + $localizedData['availableLocales'] = $availableLocales; + $data = $this->mapDataViaMapping($localizedData, $this->getDimensionContentMapping()); + $data = \array_merge($this->getDefaultData(), $data); $data = $this->mapExcerptImages($data); $data = $this->mapExcerptIcons($data); + $data = $this->mapData($document, $locale, $data, $isLive); // remove known keys that do not belong to the templateData $localizedData = $this->removeNonTemplateData($localizedData); - $data['templateData'] = $localizedData; - $this->insertOrUpdate( + /** @var mixed[] $templateData */ + $templateData = $data['templateData'] ?? []; + $data['templateData'] = \array_merge($localizedData, $templateData); + + $this->entityRepository->insertOrUpdate( $data, $this->getDimensionContentTableName(), - $this->getDimensionContentTableTypes(), [ - 'articleUuid' => $data['articleUuid'], //TODO make dynamic + $this->getDimensionContentTableTypes(), + [ + $this->getDimensionContentEntityIdMappingName() => $data[$this->getDimensionContentEntityIdMappingName()], 'locale' => $locale, 'stage' => $data['stage'], - ]); + ] + ); + + /** + * @var DimensionContent $dimensionContent + */ + $dimensionContent = $this->entityRepository->findBy($this->getDimensionContentTableName(), [ + $this->getDimensionContentEntityIdMappingName() => $data[$this->getDimensionContentEntityIdMappingName()], + 'locale' => $locale, + 'stage' => $data['stage'], + ]); + + $this->insertDataRelationsToDimensionContent($document, $locale, $dimensionContent); } } /** - * @param mixed[] $data - * @param array $types - * @param array $where + * @param Document $document */ - protected function insertOrUpdate(array $data, string $tableName, array $types, array $where): void + protected function createOrUpdateRoute(array $document): void { - $exists = $this->connection->fetchAssociative( - 'SELECT * FROM ' . $tableName . ' WHERE ' . \implode(' AND ', \array_map(fn ($key) => $key . ' = :' . $key, \array_keys($where))), - $where - ); + $localizations = $document['localizations']; + foreach ($localizations as $locale => $localizedData) { + // skip unlocalized data + if ('null' === $locale) { + continue; + } + // skip non-published entries + if (1 === $localizedData['state']) { + continue; + } - match (null !== $exists) { - true => $this->connection->update( - $tableName, - $data, - $where, - $types - ), - default => $this->connection->insert( - $tableName, - $data, - $types - ), - }; + $defaultData = [ + 'history' => false, + 'created' => new \DateTime(), + 'changed' => new \DateTime(), + ]; + + $existingRoute = $this->entityRepository->findBy(self::ROUTE_TABLE, [ + 'entity_id' => $document['jcr']['uuid'], + 'locale' => $locale, + ]) ?? []; + + $data = \array_merge( + $defaultData, + [ + 'entity_class' => $this->getEntityClassName(), + 'entity_id' => $document['jcr']['uuid'], + 'locale' => $locale, + 'path' => $existingRoute['path'] ?? $this->getPath($document, $locale), + 'history' => $existingRoute['history'] ?? 0, + 'created' => new \DateTime($existingRoute['created'] ?? 'now'), + 'changed' => new \DateTime(), + ] + ); + + try { + $this->entityRepository->insertOrUpdate( + $data, + self::ROUTE_TABLE, + [ + 'entity_class' => 'string', + 'path' => 'string', + 'locale' => 'string', + 'history' => 'boolean', + 'created' => 'datetime', + 'changed' => 'datetime', + ], + [ + 'entity_id' => $document['jcr']['uuid'], + 'path' => $data['path'], + 'locale' => $locale, + ] + ); + } catch (\Exception $e) { + echo \PHP_EOL; + echo \PHP_EOL; + echo $e->getMessage(); + } + } } /** - * @param mixed[] $document + * @param Document $document * @param mixed[] $data * * @return mixed[] */ - protected function mapData(array $document, string $locale, array $data, bool $isLive): array + protected function mapData(array $document, ?string $locale, array $data, bool $isLive): array { + $data['templateData'] = []; + return $data; } @@ -184,15 +340,28 @@ protected function mapData(array $document, string $locale, array $data, bool $i */ protected function removeNonTemplateData(array $data): array { + foreach ($data as $key => $value) { + // remove block-length property + if (\is_array($value) && \is_int($data[$key . '-length'] ?? null)) { + $data[$key . '-length'] = null; + } + } + return $data; } + /** + * @param Document $document + */ abstract public function supports(array $document): bool; abstract public static function getType(): string; abstract protected function getEntityTableName(): string; + /** + * @return array + */ abstract protected function getEntityTableTypes(): array; /** @@ -211,4 +380,29 @@ abstract protected function getDimensionContentTableTypes(): array; * @return array */ abstract protected function getDimensionContentMapping(): array; + + abstract protected function getDimensionContentEntityIdMappingName(): string; + + abstract protected function getEntityClassName(): string; + + abstract protected function getDimensionContentExcerptCategoriesTableName(): string; + + abstract protected function getDimensionContentExcerptCategoriesIdName(): string; + + abstract protected function getDimensionContentExcerptTagsTableName(): string; + + abstract protected function getDimensionContentExcerptTagsIdName(): string; + + /** + * @param Document $document + */ + abstract protected function getPath(array $document, string $locale): string; + + /** + * @return array + */ + protected function getDefaultData(): array + { + return []; + } } diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 8305634..8e75e27 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -11,62 +11,61 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister; -use Doctrine\DBAL\Connection; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\InvalidPathException; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository\EntityRepository; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; class ArticlePersister extends AbstractPersister { public function __construct( - Connection $connection, - PropertyAccessorInterface $propertyAccessor + PropertyAccessorInterface $propertyAccessor, + EntityRepository $entityRepository ) { - parent::__construct($connection, $propertyAccessor); + parent::__construct($propertyAccessor, $entityRepository); } protected function removeNonTemplateData(array $data): array { + $data = parent::removeNonTemplateData($data); + $data['seo'] = null; $data['excerpt'] = null; - $data['routePath'] = null; $data['stage'] = null; + $data['suluPages'] = null; + $data['author'] = null; + $data['authored'] = null; + $data['template'] = null; + $data['state'] = null; + $data['availableLocales'] = null; + $data['routePath'] = null; + $data['routePathName'] = null; - return \array_filter($data); // TODO callback function mit null + return \array_filter($data, static fn ($entry) => null !== $entry); } - protected function mapData(array $document, string $locale, array $data, bool $isLive): array + protected function mapData(array $document, ?string $locale, array $data, bool $isLive): array { - $data['articleUuid'] = $document['jcr']['uuid']; + $data = parent::mapData($document, $locale, $data, $isLive); + + $data[$this->getDimensionContentEntityIdMappingName()] = $document['jcr']['uuid']; $data['locale'] = $locale; $data['stage'] = $isLive ? 'live' : 'draft'; - $data['title'] = \str_split((string) $data['title'], 64)[0]; - $data['workflowPlace'] = 2 === $data['workflowPlace'] ? 'published' : 'draft'; - - return $data; - } + $data['workflowPlace'] = 2 === ($data['workflowPlace'] ?? null) ? 'published' : 'draft'; - protected function insertOrUpdate(array $data, string $tableName, array $types, array $where): void - { - $exists = $this->connection->fetchAssociative( - 'SELECT * FROM ' . $tableName . ' WHERE ' . \implode(' AND ', \array_map(fn ($key) => $key . ' = :' . $key, \array_keys($where))), - $where - ); + if (isset($data['title'])) { + $data['title'] = \str_split((string) $data['title'], 64)[0]; + $data['templateData']['title'] = $data['title']; + } - if ($exists) { - $this->connection->update( - $tableName, - $data, - $where, - $types - ); + if (isset($document['localizations'][$locale]['routePathName']) && isset($document['localizations'][$locale]['routePath'])) { + $routePathName = $document['localizations'][$locale]['routePathName']; + $routePathName = \str_starts_with($routePathName, 'i18n:') ? \explode('-', $routePathName, 2)[1] : $routePathName; + $routePath = $document['localizations'][$locale]['routePath']; - return; + $data['templateData'][$routePathName] = $routePath; } - $this->connection->insert( - $tableName, - $data, - $types - ); + return $data; } public function supports(array $document): bool @@ -161,4 +160,54 @@ protected function getDimensionContentMapping(): array '[excerptIconId]' => '[excerpt][icon]', ]; } + + protected function getDimensionContentEntityIdMappingName(): string + { + return 'articleUuid'; + } + + protected function getEntityClassName(): string + { + return 'Sulu\Article\Domain\Model\ArticleInterface'; + } + + protected function getDimensionContentExcerptCategoriesTableName(): string + { + return 'ar_article_dimension_content_excerpt_categories'; + } + + protected function getDimensionContentExcerptCategoriesIdName(): string + { + return 'article_dimension_content_id'; + } + + protected function getDimensionContentExcerptTagsTableName(): string + { + return 'ar_article_dimension_content_excerpt_tags'; + } + + protected function getDimensionContentExcerptTagsIdName(): string + { + return 'article_dimension_content_id'; + } + + protected function getPath(array $document, string $locale): string + { + $localizedData = $document['localizations'][$locale]; + + if (!isset($localizedData['routePath'])) { + throw new InvalidPathException('routePath'); + } + + return $localizedData['routePath']; + } + + protected function getDefaultData(): array + { + return [ + 'seoNoIndex' => false, + 'seoNoFollow' => false, + 'seoHideInSitemap' => false, + ]; + } } diff --git a/PhpcrMigration/Application/Persister/PersisterPool.php b/PhpcrMigration/Application/Persister/PersisterPool.php index fe8b7e8..919a8be 100644 --- a/PhpcrMigration/Application/Persister/PersisterPool.php +++ b/PhpcrMigration/Application/Persister/PersisterPool.php @@ -11,6 +11,8 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\PersisterNotFoundException; + class PersisterPool { /** @@ -28,6 +30,6 @@ public function getPersister(string $type): PersisterInterface } } - throw new \Exception('Persister for type "' . $type . '" not found.'); + throw new PersisterNotFoundException($type); } } diff --git a/PhpcrMigration/Infrastructure/Repository/EntityRepository.php b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php new file mode 100644 index 0000000..18224a5 --- /dev/null +++ b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php @@ -0,0 +1,117 @@ +connection->beginTransaction(); + } + + public function commit(): void + { + $this->connection->commit(); + } + + /** + * @param mixed[] $data + * @param array $types + * @param array $where + */ + public function insertOrUpdate(array $data, string $tableName, array $types, array $where = []): void + { + $exists = !([] === $where) && $this->exists($tableName, $where); + + match ($exists) { + true => $this->connection->update( + $tableName, + $data, + $where, + $types + ), + default => $this->connection->insert( + $tableName, + $data, + $types + ), + }; + } + + /** + * @param mixed[] $where + * + * @return mixed[]|null + */ + public function findBy(string $tableName, array $where): ?array + { + [$conditions, $params] = $this->parseWhereParts($where); + + $query = 'SELECT * FROM ' . $tableName . ' WHERE ' . \implode(' AND ', $conditions); + $result = $this->connection->fetchAssociative($query, $params); + + return $result ?: null; + } + + /** + * @param mixed[] $where + */ + public function exists(string $tableName, array $where): bool + { + [$conditions, $params] = $this->parseWhereParts($where); + + $query = 'SELECT 1 FROM ' . $tableName . ' WHERE ' . \implode(' AND ', $conditions); + $result = $this->connection->fetchOne($query, $params); + + return false !== $result; + } + + /** + * @param mixed[] $where + */ + public function removeBy(string $tableName, array $where): int|string + { + [$conditions, $params] = $this->parseWhereParts($where); + + $query = 'DELETE FROM ' . $tableName . ' WHERE ' . \implode(' AND ', $conditions); + + return $this->connection->executeStatement($query, $params); + } + + /** + * @param mixed[] $where + * + * @return mixed[][] + */ + private function parseWhereParts(array $where): array + { + $conditions = []; + $params = []; + foreach ($where as $key => $value) { + if (null === $value) { + $conditions[] = $key . ' IS NULL'; + } else { + $conditions[] = $key . ' = :' . $key; + $params[$key] = $value; + } + } + + return [$conditions, $params]; + } +} diff --git a/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php index 4dcfa7d..2394ecc 100644 --- a/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php +++ b/PhpcrMigration/UserInterface/Command/MigratePhpcrCommand.php @@ -18,9 +18,11 @@ use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Session\SessionManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand(name: 'sulu:phpcr-migration:migrate', description: 'Migrate the PHPCR content repository to the SuluContentBundle.')] class MigratePhpcrCommand extends Command @@ -47,19 +49,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int $documentTypes = $input->getArgument('documentTypes'); $documentTypes = \explode(',', $documentTypes); + $io = new SymfonyStyle($input, $output); foreach ($documentTypes as $documentType) { + $io->title('Migrating ' . $documentType . ' documents'); $persister = $this->persisterPool->getPersister($documentType); /** @var SessionInterface $session */ foreach ([$session, $liveSession] as $session) { + $io->section('Migrating ' . $documentType . ' documents in ' . $session->getWorkspace()->getName()); $nodes = $this->fetchPhpcrNodes($session, $documentType); + $progressBar = $io->createProgressBar(\iterator_count($nodes)); + $progressBar->setFormat(ProgressBar::FORMAT_DEBUG); foreach ($nodes as $node) { $document = $this->nodeParser->parse($node); $persister->persist($document, \str_ends_with($session->getWorkspace()->getName(), '_live')); + $progressBar->advance(); } + $progressBar->finish(); + $io->newLine(2); } } + $io->success('Migration completed'); + return Command::SUCCESS; } diff --git a/Resources/config/command.xml b/Resources/config/command.xml index bbf2122..767ed5b 100644 --- a/Resources/config/command.xml +++ b/Resources/config/command.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - + diff --git a/Resources/config/persister.xml b/Resources/config/persister.xml index 757c30d..67521f1 100644 --- a/Resources/config/persister.xml +++ b/Resources/config/persister.xml @@ -5,8 +5,8 @@ - + diff --git a/Resources/config/repository.xml b/Resources/config/repository.xml new file mode 100644 index 0000000..06def51 --- /dev/null +++ b/Resources/config/repository.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/Resources/config/session.xml b/Resources/config/session.xml index 8573479..372886d 100644 --- a/Resources/config/session.xml +++ b/Resources/config/session.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - %sulu_phpcr_migration.configuration% diff --git a/SuluPhpcrMigrationBundle.php b/SuluPhpcrMigrationBundle.php index fe9b16f..bb31deb 100644 --- a/SuluPhpcrMigrationBundle.php +++ b/SuluPhpcrMigrationBundle.php @@ -27,6 +27,7 @@ class SuluPhpcrMigrationBundle extends AbstractBundle public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { $loader = new XmlFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); + $loader->load('repository.xml'); $loader->load('session.xml'); $loader->load('command.xml'); $loader->load('parser.xml'); diff --git a/composer.json b/composer.json index 82fcbad..dfa0184 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,8 @@ ], "require": { "php": "^8.1", - "jackalope/jackalope-doctrine-dbal": "^1.7", - "jackalope/jackalope-jackrabbit": "^1.4", + "jackalope/jackalope-doctrine-dbal": "^1.3.4 || ^2.0", + "jackalope/jackalope-jackrabbit": "^1.3 || ^2.0", "symfony/config": "^6.0 | ^7.0", "symfony/console": "^6.0 | ^7.0", "symfony/dependency-injection": "^6.0 | ^7.0", From 8ff222b2c998c214d51845f45b301bfbc6c9a68e Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Thu, 25 Jul 2024 07:59:30 +0200 Subject: [PATCH 07/17] Force url as routePath --- PhpcrMigration/Application/Persister/AbstractPersister.php | 2 +- PhpcrMigration/Application/Persister/ArticlePersister.php | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PhpcrMigration/Application/Persister/AbstractPersister.php b/PhpcrMigration/Application/Persister/AbstractPersister.php index c7b299a..219c105 100644 --- a/PhpcrMigration/Application/Persister/AbstractPersister.php +++ b/PhpcrMigration/Application/Persister/AbstractPersister.php @@ -312,7 +312,7 @@ protected function createOrUpdateRoute(array $document): void 'locale' => $locale, ] ); - } catch (\Exception $e) { + } catch (\Exception $e) { // @phpstan-ignore-line echo \PHP_EOL; echo \PHP_EOL; echo $e->getMessage(); diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 8e75e27..5d9de9c 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -60,9 +60,11 @@ protected function mapData(array $document, ?string $locale, array $data, bool $ if (isset($document['localizations'][$locale]['routePathName']) && isset($document['localizations'][$locale]['routePath'])) { $routePathName = $document['localizations'][$locale]['routePathName']; $routePathName = \str_starts_with($routePathName, 'i18n:') ? \explode('-', $routePathName, 2)[1] : $routePathName; - $routePath = $document['localizations'][$locale]['routePath']; + // check routePathName property and fallback to routePath + $routePath = $document['localizations'][$locale][$routePathName] ?? $document['localizations'][$locale]['routePath']; - $data['templateData'][$routePathName] = $routePath; + // content bundle is only compatible with "url" + $data['templateData']['url'] = $routePath; } return $data; From 5956d8ca61ab51042ff1df792cbc576edc434f7e Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Thu, 25 Jul 2024 08:35:33 +0200 Subject: [PATCH 08/17] Add fallback for routePath --- PhpcrMigration/Application/Persister/ArticlePersister.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index 5d9de9c..c9c1c5e 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -37,7 +37,6 @@ protected function removeNonTemplateData(array $data): array $data['template'] = null; $data['state'] = null; $data['availableLocales'] = null; - $data['routePath'] = null; $data['routePathName'] = null; return \array_filter($data, static fn ($entry) => null !== $entry); @@ -64,7 +63,8 @@ protected function mapData(array $document, ?string $locale, array $data, bool $ $routePath = $document['localizations'][$locale][$routePathName] ?? $document['localizations'][$locale]['routePath']; // content bundle is only compatible with "url" - $data['templateData']['url'] = $routePath; + $data['templateData']['url'] = $routePath; // is used in the content bundle + $data['templateData'][$routePath] = $routePath; // can still be used in the template TODO } return $data; From 94eba94dbd952e4d331083f978ff52009f9848c0 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Thu, 25 Jul 2024 09:40:19 +0200 Subject: [PATCH 09/17] Fix url construction --- SuluPhpcrMigrationBundle.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/SuluPhpcrMigrationBundle.php b/SuluPhpcrMigrationBundle.php index bb31deb..df82b16 100644 --- a/SuluPhpcrMigrationBundle.php +++ b/SuluPhpcrMigrationBundle.php @@ -128,13 +128,14 @@ private function getConnectionConfiguration(string $dsn): array return $result; } - $result['connection']['url'] = \sprintf( - '%s:%s%s%s', + $result['connection']['url'] = \implode('', \array_filter([ $parts['host'] ?? '', - $parts['port'] ?? '', - $parts['path'] ?? '', + isset($parts['port']) ? ':' . $parts['port'] : null, + $parts['path'] ?? null, $query ? '?' . \http_build_query($query) : '', - ); + ], function($value) { + return null !== $value && '' !== $value; + })); $result['connection']['user'] = $parts['user'] ?? null; $result['connection']['password'] = $parts['pass'] ?? null; From 9fd4f4088dafa98ce2432bcbb7d9adc00e4b5f73 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Wed, 14 Aug 2024 07:53:07 +0200 Subject: [PATCH 10/17] Fix php-cs --- .github/workflows/test-application.yaml | 5 +++++ PhpcrMigration/Application/Parser/NodeParser.php | 2 +- .../Infrastructure/Repository/EntityRepository.php | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-application.yaml b/.github/workflows/test-application.yaml index d8ee319..7994c3f 100644 --- a/.github/workflows/test-application.yaml +++ b/.github/workflows/test-application.yaml @@ -37,6 +37,11 @@ jobs: env: SYMFONY_DEPRECATIONS_HELPER: weak + - php-version: '8.4' + dependency-versions: 'highest' + env: + SYMFONY_DEPRECATIONS_HELPER: weak + services: mysql: image: mysql:8.0 diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php index aa57795..d40526d 100644 --- a/PhpcrMigration/Application/Parser/NodeParser.php +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -68,7 +68,7 @@ private function isUnLocalizedProperty(string $name): bool private function resolvePropertyValue(PropertyInterface $property): mixed { $value = $property instanceof Property ? $property->getValueForStorage() : $property->getValue(); - if (\is_string($value) && json_validate($value) && ('' !== $value && '0' !== $value)) { + if (\is_string($value) && \json_validate($value) && ('' !== $value && '0' !== $value)) { return \json_decode($value, true); } diff --git a/PhpcrMigration/Infrastructure/Repository/EntityRepository.php b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php index 18224a5..a6f0f2d 100644 --- a/PhpcrMigration/Infrastructure/Repository/EntityRepository.php +++ b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php @@ -37,7 +37,7 @@ public function commit(): void */ public function insertOrUpdate(array $data, string $tableName, array $types, array $where = []): void { - $exists = !([] === $where) && $this->exists($tableName, $where); + $exists = [] !== $where && $this->exists($tableName, $where); match ($exists) { true => $this->connection->update( From 8799e3b0bf87694ae4ca88a8a0f0eed5fda4a3dd Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Wed, 14 Aug 2024 07:54:33 +0200 Subject: [PATCH 11/17] Use shim instead of css fixer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index dfa0184..af42ef2 100644 --- a/composer.json +++ b/composer.json @@ -34,8 +34,8 @@ ] }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.41", "jangregor/phpstan-prophecy": "^1.0", + "php-cs-fixer/shim": "^3.62", "phpspec/prophecy-phpunit": "^2.1", "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "^1.10", From eee46962447fbcd0f71adbbf805cb01cf5abe04e Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Wed, 14 Aug 2024 07:55:12 +0200 Subject: [PATCH 12/17] Fix PHP 8.4 run --- .github/workflows/test-application.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-application.yaml b/.github/workflows/test-application.yaml index 7994c3f..45beabc 100644 --- a/.github/workflows/test-application.yaml +++ b/.github/workflows/test-application.yaml @@ -39,6 +39,7 @@ jobs: - php-version: '8.4' dependency-versions: 'highest' + composer-options: '--ignore-platform-reqs' env: SYMFONY_DEPRECATIONS_HELPER: weak From 39206a624f9373d41adadfe695b919d63ad61e5e Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Wed, 14 Aug 2024 07:55:44 +0200 Subject: [PATCH 13/17] Run PHP Lint on 8.3 --- .github/workflows/test-application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-application.yaml b/.github/workflows/test-application.yaml index 45beabc..e5fad8f 100644 --- a/.github/workflows/test-application.yaml +++ b/.github/workflows/test-application.yaml @@ -83,7 +83,7 @@ jobs: - name: Install and configure PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 extensions: ctype, iconv, mysql - name: Install composer dependencies From 756a5bc5ac3ce270560084eab47b5cb9a9e55d21 Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Wed, 14 Aug 2024 07:58:24 +0200 Subject: [PATCH 14/17] Fix deptrac config --- deptrac.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deptrac.yaml b/deptrac.yaml index 947ed1b..a3b1ed7 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -1,6 +1,6 @@ parameters: paths: - - ./src + - ./PhpcrMigration layers: - name: UserInterface From f6f92e81185c041ed5803bb44a767b8e123a8b53 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Wed, 14 Aug 2024 16:01:38 +0200 Subject: [PATCH 15/17] Throw exception in case of missing phpcr migration --- .../RoutePathNameNotFoundException.php | 26 +++++++++++++++++++ .../Application/Parser/NodeParser.php | 2 +- .../Persister/AbstractPersister.php | 11 ++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 PhpcrMigration/Application/Exception/RoutePathNameNotFoundException.php diff --git a/PhpcrMigration/Application/Exception/RoutePathNameNotFoundException.php b/PhpcrMigration/Application/Exception/RoutePathNameNotFoundException.php new file mode 100644 index 0000000..309dca5 --- /dev/null +++ b/PhpcrMigration/Application/Exception/RoutePathNameNotFoundException.php @@ -0,0 +1,26 @@ +getValueForStorage() : $property->getValue(); - if (\is_string($value) && \json_validate($value) && ('' !== $value && '0' !== $value)) { + if (\is_string($value) && json_validate($value) && ('' !== $value && '0' !== $value)) { return \json_decode($value, true); } diff --git a/PhpcrMigration/Application/Persister/AbstractPersister.php b/PhpcrMigration/Application/Persister/AbstractPersister.php index 219c105..51858f4 100644 --- a/PhpcrMigration/Application/Persister/AbstractPersister.php +++ b/PhpcrMigration/Application/Persister/AbstractPersister.php @@ -11,6 +11,7 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\RoutePathNameNotFoundException; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\UnsupportedDocumentTypeException; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository\EntityRepository; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -52,6 +53,16 @@ public function persist(array $document, bool $isLive): void throw new UnsupportedDocumentTypeException($document['jcr']['mixinTypes']); } + foreach ($document['localizations'] as $locale => $localizedData) { + if ( + [] !== $localizedData + && isset($localizedData['routePath']) + && !isset($localizedData['routePathName']) + ) { + throw new RoutePathNameNotFoundException($document['jcr']['uuid'], $locale); + } + } + $this->entityRepository->beginTransaction(); $this->createOrUpdateEntity($document); $this->createOrUpdateDimensionContent($document, $isLive); From 7a919698cb4ceee452200dd4ee6b34792c34369c Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Fri, 23 Aug 2024 09:54:02 +0200 Subject: [PATCH 16/17] Fix linting --- .../Persister/AbstractPersister.php | 4 +- .../Persister/ArticlePersister.php | 4 +- .../Repository/EntityRepositoryInterface.php | 43 +++++++++++++++++++ .../Repository/EntityRepository.php | 19 +------- 4 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 PhpcrMigration/Application/Repository/EntityRepositoryInterface.php diff --git a/PhpcrMigration/Application/Persister/AbstractPersister.php b/PhpcrMigration/Application/Persister/AbstractPersister.php index 51858f4..ddd8260 100644 --- a/PhpcrMigration/Application/Persister/AbstractPersister.php +++ b/PhpcrMigration/Application/Persister/AbstractPersister.php @@ -13,7 +13,7 @@ use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\RoutePathNameNotFoundException; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\UnsupportedDocumentTypeException; -use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository\EntityRepository; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Repository\EntityRepositoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** @@ -40,7 +40,7 @@ abstract class AbstractPersister implements PersisterInterface public function __construct( protected PropertyAccessorInterface $propertyAccessor, - protected EntityRepository $entityRepository, + protected EntityRepositoryInterface $entityRepository, ) { } diff --git a/PhpcrMigration/Application/Persister/ArticlePersister.php b/PhpcrMigration/Application/Persister/ArticlePersister.php index c9c1c5e..8fee001 100644 --- a/PhpcrMigration/Application/Persister/ArticlePersister.php +++ b/PhpcrMigration/Application/Persister/ArticlePersister.php @@ -12,14 +12,14 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Persister; use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Exception\InvalidPathException; -use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository\EntityRepository; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Repository\EntityRepositoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; class ArticlePersister extends AbstractPersister { public function __construct( PropertyAccessorInterface $propertyAccessor, - EntityRepository $entityRepository + EntityRepositoryInterface $entityRepository ) { parent::__construct($propertyAccessor, $entityRepository); } diff --git a/PhpcrMigration/Application/Repository/EntityRepositoryInterface.php b/PhpcrMigration/Application/Repository/EntityRepositoryInterface.php new file mode 100644 index 0000000..0232855 --- /dev/null +++ b/PhpcrMigration/Application/Repository/EntityRepositoryInterface.php @@ -0,0 +1,43 @@ + $types + * @param array $where + */ + public function insertOrUpdate(array $data, string $tableName, array $types, array $where = []): void; + + /** + * @param mixed[] $where + * + * @return mixed[]|null + */ + public function findBy(string $tableName, array $where): ?array; + + /** + * @param mixed[] $where + */ + public function exists(string $tableName, array $where): bool; + + /** + * @param mixed[] $where + */ + public function removeBy(string $tableName, array $where): int|string; +} diff --git a/PhpcrMigration/Infrastructure/Repository/EntityRepository.php b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php index a6f0f2d..a5e998e 100644 --- a/PhpcrMigration/Infrastructure/Repository/EntityRepository.php +++ b/PhpcrMigration/Infrastructure/Repository/EntityRepository.php @@ -12,8 +12,9 @@ namespace Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Infrastructure\Repository; use Doctrine\DBAL\Connection; +use Sulu\Bundle\PhpcrMigrationBundle\PhpcrMigration\Application\Repository\EntityRepositoryInterface; -class EntityRepository +class EntityRepository implements EntityRepositoryInterface { public function __construct( protected Connection $connection, @@ -30,11 +31,6 @@ public function commit(): void $this->connection->commit(); } - /** - * @param mixed[] $data - * @param array $types - * @param array $where - */ public function insertOrUpdate(array $data, string $tableName, array $types, array $where = []): void { $exists = [] !== $where && $this->exists($tableName, $where); @@ -54,11 +50,6 @@ public function insertOrUpdate(array $data, string $tableName, array $types, arr }; } - /** - * @param mixed[] $where - * - * @return mixed[]|null - */ public function findBy(string $tableName, array $where): ?array { [$conditions, $params] = $this->parseWhereParts($where); @@ -69,9 +60,6 @@ public function findBy(string $tableName, array $where): ?array return $result ?: null; } - /** - * @param mixed[] $where - */ public function exists(string $tableName, array $where): bool { [$conditions, $params] = $this->parseWhereParts($where); @@ -82,9 +70,6 @@ public function exists(string $tableName, array $where): bool return false !== $result; } - /** - * @param mixed[] $where - */ public function removeBy(string $tableName, array $where): int|string { [$conditions, $params] = $this->parseWhereParts($where); From 66e5bcf359fef88e745dfc0b2063659e283f9804 Mon Sep 17 00:00:00 2001 From: Prokyonn Date: Fri, 23 Aug 2024 13:24:53 +0200 Subject: [PATCH 17/17] Fix linting --- PhpcrMigration/Application/Parser/NodeParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PhpcrMigration/Application/Parser/NodeParser.php b/PhpcrMigration/Application/Parser/NodeParser.php index aa57795..d40526d 100644 --- a/PhpcrMigration/Application/Parser/NodeParser.php +++ b/PhpcrMigration/Application/Parser/NodeParser.php @@ -68,7 +68,7 @@ private function isUnLocalizedProperty(string $name): bool private function resolvePropertyValue(PropertyInterface $property): mixed { $value = $property instanceof Property ? $property->getValueForStorage() : $property->getValue(); - if (\is_string($value) && json_validate($value) && ('' !== $value && '0' !== $value)) { + if (\is_string($value) && \json_validate($value) && ('' !== $value && '0' !== $value)) { return \json_decode($value, true); }